mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
66 Commits
v1.2.1
...
574dfbbe55
| Author | SHA1 | Date | |
|---|---|---|---|
| 574dfbbe55 | |||
| 1d3bcdfe47 | |||
| 74369940bf | |||
| d188cec306 | |||
| 641a61f804 | |||
| 3025c64fea | |||
| c52cfc7e7a | |||
| 4ed9f775f6 | |||
| 0b08ad283a | |||
| 08895f77ff | |||
| 74f889f160 | |||
| c51090ab16 | |||
| cdb044cb45 | |||
| c83b26b556 | |||
| a202c666bc | |||
| e05046af10 | |||
| 41ed03e5c6 | |||
| da071e8452 | |||
| a0bb6df2b8 | |||
| 6f5c444ec5 | |||
| 20f66a4fe1 | |||
| 879745da53 | |||
| 3afec0a0be | |||
| 06c270a6e1 | |||
| 87e93636dc | |||
| 074d1f2e51 | |||
| 40f922b0e8 | |||
| a7214b6cff | |||
| 8ca66e72eb | |||
| 90be5799e4 | |||
| 1a93b0eca2 | |||
| c2360a35ad | |||
| 030a32f8fa | |||
| 25e72b43ce | |||
| 41e9883daa | |||
| ae461ebc81 | |||
| 3ebf256c5d | |||
| 0905e2be6e | |||
| bd380bc1a1 | |||
| cb4fd3a0e6 | |||
| bbe0acac5c | |||
| 45e7c25308 | |||
| 86aa1d8059 | |||
| 74254756ef | |||
| 259e08e47c | |||
| 6647c45731 | |||
| b614a5c447 | |||
| b80830913c | |||
| e57e11897e | |||
| 8a16034135 | |||
| 7fc3b7e5be | |||
| b0c7a1baae | |||
| 6411d83d46 | |||
| 74a063b0e5 | |||
| 96376e5cce | |||
| e7c26bf722 | |||
| cef4129fc9 | |||
| 0a28500848 | |||
| fc3f3f3bdb | |||
| fa58297973 | |||
| 5d1a22fb8f | |||
| d2f50f26f1 | |||
| 4a6042d0b4 | |||
| 846206d958 | |||
| 0daf4924f0 | |||
| d38a3d091d |
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: lora-manager-e2e
|
||||
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode in a SANDBOXED, disposable configuration: check the port, start/restart the standalone server on a free port, use Chrome DevTools MCP to interact with the web UI (http://127.0.0.1:{PORT}/loras), and verify frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox.
|
||||
description: "End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode in a SANDBOXED, disposable configuration: check the port, start/restart the standalone server on a free port, use Chrome DevTools MCP to interact with the web UI (http://127.0.0.1:{PORT}/loras), and verify frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox."
|
||||
---
|
||||
|
||||
# LoRa Manager E2E Testing
|
||||
@@ -11,13 +11,14 @@ This skill provides workflows and utilities for end-to-end testing of LoRa Manag
|
||||
|
||||
- **`{PORT}`**: The server port. The default candidate is `8188`, but **`8188` is commonly occupied by a live ComfyUI process** and MUST NOT be assumed to be free. Always check availability first (see [Port Selection](#port-selection)) and use a free port (e.g. `8199`) for the E2E run. Substitute the actual port for every `{PORT}` in the commands below.
|
||||
- **`<repo-root>`**: The repository/worktree root. Always run commands from the repo or worktree root; never assume a specific absolute path (paths such as `/home/<user>/...` differ per machine). The E2E scripts resolve the project root themselves, but fixture/settings paths are relative to `<repo-root>`.
|
||||
- **`<settings-dir>`**: The sandboxed explicit settings directory passed via `--settings-path` (see [SANDBOX](#sandbox-mandatory)); substitute the actual path (e.g. `/tmp/opencode/<plan>-e2e/settings`) for every `{PATH}` in commands below that target the sandbox config.
|
||||
|
||||
## SANDBOX (MANDATORY)
|
||||
|
||||
> **Read this section before running anything.** Every E2E run MUST target a throwaway sandbox, never the real user data. A fresh subagent that skips this section WILL permanently mutate real user recipes.
|
||||
|
||||
1. **Portable settings**: create `<repo-root>/settings.json` (gitignored) with `"use_portable_settings": true` plus sandboxed `folder_paths` (lora/checkpoint roots) and `recipes_path`. This keeps the configuration inside the repo instead of the real user config dir (`~/.config/ComfyUI-LoRA-Manager/settings.json`).
|
||||
2. **Sandboxed paths**: point `folder_paths` / `recipes_path` / `example_images_path` at disposable dirs — e.g. under `/tmp/opencode/<plan-name>-e2e/` (or worktree-local dirs). NEVER point the E2E at the real library (`~/models/...`), real recipe dir, or real settings.
|
||||
1. **Explicit settings directory (preferred)**: launch the standalone server with `--settings-path <sandbox>/settings` (or set `LORA_MANAGER_SETTINGS_DIR`). This pins ALL runtime data — `settings.json`, `cache/`, `wildcards/`, `backups/`, `logs/`, `stats/` — under that directory, independent of portable mode and of the real user config dir. **Do NOT** write `<repo-root>/settings.json` for sandboxing: the repo folder is usually the real ComfyUI plugin folder, and a portable `settings.json` there is read by the real instance — exactly the conflict this E2E must avoid.
|
||||
2. **Sandboxed paths**: point `folder_paths` / `recipes_path` / `example_images_path` at disposable dirs under the sandbox — e.g. `<sandbox>/models/loras`, `<sandbox>/recipes`. NEVER point the E2E at the real library (`~/models/...`), real recipe dir, or real settings.
|
||||
3. **Never touch the real config**: the real user config at `~/.config/ComfyUI-LoRA-Manager/settings.json` and the real recipe dir must remain byte-identical before and after the run.
|
||||
4. **Record real-data protection proof** before starting and after finishing:
|
||||
```bash
|
||||
@@ -27,13 +28,14 @@ This skill provides workflows and utilities for end-to-end testing of LoRa Manag
|
||||
find ~/models/recipes -name '*.recipe.json' -newermt "$(date -Iseconds)" | head # expect empty after run
|
||||
# AFTER: record again, then diff the two snapshots. Any change = the run leaked into real data.
|
||||
```
|
||||
Also confirm `<repo-root>/git status` stays clean for `settings.json`/`cache/` (both are gitignored).
|
||||
Also confirm `<repo-root>/git status` stays clean (`settings.json`/`cache/` are gitignored and must not be created by the run).
|
||||
|
||||
### Portable Settings Example
|
||||
### Sandbox Settings (via `--settings-path`)
|
||||
|
||||
Write this file as `<sandbox>/settings/settings.json` — `<settings-dir>` in the commands below:
|
||||
|
||||
```json
|
||||
{
|
||||
"use_portable_settings": true,
|
||||
"folder_paths": {
|
||||
"loras": ["/tmp/opencode/<plan>-e2e/models/loras"],
|
||||
"checkpoints": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
|
||||
@@ -45,7 +47,7 @@ This skill provides workflows and utilities for end-to-end testing of LoRa Manag
|
||||
}
|
||||
```
|
||||
|
||||
The scanner computes and persists model hashes during the library scan, so the sandbox model dirs just need the model files + `.metadata.json` sidecars (see [Fixture + Fresh-State Guidance](#fixture--fresh-state-guidance)).
|
||||
The scanner computes and persists model hashes during the library scan, so the sandbox model dirs just need the model files + `.metadata.json` sidecars (see [Fixture + Fresh-State Guidance](#fixture--fresh-state-guidance)). With `--settings-path`, all derived data lands under `<settings-dir>` (`cache/`, `backups/`, `logs/`, `stats/`, `wildcards/`), and NO `cache/` appears in `<repo-root>`.
|
||||
|
||||
## Time Budgets & Abort Guidance
|
||||
|
||||
@@ -90,9 +92,10 @@ ss -tlnp | grep ':8188' || echo "8188 is free"
|
||||
|
||||
```bash
|
||||
cd <repo-root> # ALWAYS run from the repo/worktree root
|
||||
mkdir -p /tmp/opencode/<plan>-e2e/settings
|
||||
mkdir -p /tmp/opencode/<plan>-e2e/models/{loras,checkpoints}
|
||||
mkdir -p /tmp/opencode/<plan>-e2e/{recipes,example_images,recipes-before}
|
||||
# write <repo-root>/settings.json per the portable-settings example above
|
||||
# write <sandbox>/settings/settings.json per the sandbox-settings example above
|
||||
# record real-data protection proof (see SANDBOX section)
|
||||
```
|
||||
|
||||
@@ -106,16 +109,18 @@ If `{PORT}` is occupied by an unrelated process, pick a free one and use it ever
|
||||
|
||||
### 3. Start LoRa Manager Standalone (detached)
|
||||
|
||||
The standalone server **dies with the shell unless launched fully detached** — a plain background `&` from the bash tool is killed when the tool call returns. Launch via the helper script:
|
||||
The standalone server **dies with the shell unless launched fully detached** — a plain background `&` from the bash tool is killed when the tool call returns. Launch via the helper script (note `--settings-path`):
|
||||
|
||||
```bash
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --wait --timeout 30 --detach
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
|
||||
--port {PORT} --settings-path /tmp/opencode/<plan>-e2e/settings \
|
||||
--wait --timeout 30 --detach
|
||||
```
|
||||
|
||||
Or manually (equivalent detached form):
|
||||
|
||||
```bash
|
||||
setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 < /dev/null \
|
||||
setsid nohup python standalone.py --port {PORT} --settings-path /tmp/opencode/<plan>-e2e/settings --host 127.0.0.1 < /dev/null \
|
||||
>> /tmp/opencode/<plan>-e2e/server.log 2>&1 &
|
||||
echo "started" # record the printed/pidfile PID for cleanup
|
||||
```
|
||||
@@ -169,7 +174,9 @@ snapshot = take_snapshot()
|
||||
# Stop current server (if running), start with new configuration.
|
||||
# --restart only kills the E2E server this script started before (via its pidfile);
|
||||
# it refuses to blindly kill unrelated processes on the port.
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --detach
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
|
||||
--port {PORT} --settings-path /tmp/opencode/<plan>-e2e/settings \
|
||||
--restart --wait --detach
|
||||
|
||||
# Wait and refresh browser
|
||||
navigate_page(type="reload", ignoreCache=True)
|
||||
@@ -250,17 +257,21 @@ Each entry point (global / per-recipe / selection-bulk) must start from the same
|
||||
```bash
|
||||
# 1. Reset fixtures to the before-state snapshot (copy back from recipes-before/)
|
||||
cp /tmp/opencode/<plan>-e2e/recipes-before/*.recipe.json /tmp/opencode/<plan>-e2e/recipes/
|
||||
# 2. Clear the recipe/FTS caches so the stale in-memory/library state is gone
|
||||
rm -f <repo-root>/cache/recipe/*.sqlite
|
||||
rm -rf <repo-root>/cache/fts/*
|
||||
# 2. Clear the recipe/FTS caches so the stale in-memory/library state is gone.
|
||||
# With --settings-path these live under the sandbox settings dir, NOT <repo-root>/cache.
|
||||
rm -f /tmp/opencode/<plan>-e2e/settings/cache/recipe/*.sqlite
|
||||
rm -rf /tmp/opencode/<plan>-e2e/settings/cache/fts/*
|
||||
# 3. Restart the server (fresh process, fresh scan)
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
|
||||
--port {PORT} --settings-path /tmp/opencode/<plan>-e2e/settings \
|
||||
--restart --wait --timeout 30 --detach
|
||||
# 4. Re-verify server listening + reload the browser page
|
||||
```
|
||||
|
||||
## Server Lifecycle
|
||||
|
||||
- **Detached launch is mandatory**: the standalone server dies with the shell unless launched via `setsid` (or the helper script's `--detach`). Use `setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 ... < /dev/null &`.
|
||||
- **Detached launch is mandatory**: the standalone server dies with the shell unless launched via `setsid` (or the helper script's `--detach`). Use `setsid nohup python standalone.py --port {PORT} --settings-path <sandbox>/settings --host 127.0.0.1 ... < /dev/null &`.
|
||||
- **Always pass `--settings-path`** pointing at the sandbox settings dir — this is what keeps the run fully sandboxed (see [SANDBOX](#sandbox-mandatory)).
|
||||
- **Verify with `ss -tlnp`** after every (re)start; do not proceed on a blind "server starting" message.
|
||||
- **Never kill pre-existing processes** — only kill the E2E server PID you started (`start_server.py --restart` kills only PIDs it manages via its pidfile). The live ComfyUI or a stale QA Chrome must never be killed as part of cleanup unless explicitly identified as such (see Chrome troubleshooting).
|
||||
- **Record your PID for cleanup**: note the PID printed/pidfile, and stop exactly that PID at the end (`kill <PID>`, then confirm with `ss -tlnp` that `{PORT}` is released).
|
||||
@@ -306,11 +317,12 @@ Testing the rematch-cancel path E2E requires a run long enough to cancel mid-fli
|
||||
Starts or restarts the LoRa Manager standalone server for E2E testing.
|
||||
|
||||
```bash
|
||||
python scripts/start_server.py [--port PORT] [--restart] [--wait] [--timeout SECONDS] [--detach]
|
||||
python scripts/start_server.py [--port PORT] [--settings-path DIR] [--restart] [--wait] [--timeout SECONDS] [--detach]
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--port`: Server port (default: 8188). The script exits early with a clear message if the port is already in use by an unrelated process.
|
||||
- `--settings-path`: Explicit sandbox settings directory passed through to `standalone.py` (equivalent to `LORA_MANAGER_SETTINGS_DIR`). Creates the directory if needed and refuses to start if the path exists as a file. **Use this for every sandboxed E2E run.**
|
||||
- `--restart`: Kill the E2E server this script previously managed (tracked via `/tmp/lora-manager-e2e-server-{PORT}.pid`) before starting. If unrelated processes still hold the port after that, the script reports them and aborts instead of killing them.
|
||||
- `--wait`: Wait for the server to be ready before exiting.
|
||||
- `--timeout`: Readiness wait timeout in seconds (default: 30).
|
||||
@@ -369,5 +381,5 @@ results = performance_stop_trace()
|
||||
Always ensure proper cleanup after tests:
|
||||
1. Stop the standalone server: `kill <recorded-pid>` (only the PID you started), then confirm `ss -tlnp | grep ':{PORT}'` is empty.
|
||||
2. Close browser pages (keep at least one open).
|
||||
3. Remove the sandbox: `rm -rf /tmp/opencode/<plan>-e2e` and `<repo-root>/settings.json` + `<repo-root>/cache` (both gitignored).
|
||||
3. Remove the sandbox: `rm -rf /tmp/opencode/<plan>-e2e`. Verify `<repo-root>` has NOT gained a `settings.json` or `cache/` (with `--settings-path` they never appear there).
|
||||
4. Re-run the real-data protection check from the SANDBOX section and record the result in your evidence.
|
||||
|
||||
@@ -211,6 +211,17 @@ def main() -> int:
|
||||
help="Launch the server fully detached (setsid-style) so it survives shell "
|
||||
"death. REQUIRED for E2E: a plain background process dies with the shell",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--settings-path",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Explicit settings directory passed to standalone.py (--settings-path, "
|
||||
"equivalent to LORA_MANAGER_SETTINGS_DIR). settings.json, cache/, "
|
||||
"wildcards/, backups/, logs/, stats/ all live under this directory instead "
|
||||
"of the project root or the user config dir. Recommended for sandboxed E2E "
|
||||
"so the real instance and the repo stay untouched",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -283,6 +294,16 @@ def main() -> int:
|
||||
"--port",
|
||||
str(args.port),
|
||||
]
|
||||
if args.settings_path:
|
||||
settings_dir = os.path.abspath(os.path.expanduser(args.settings_path))
|
||||
if os.path.exists(settings_dir) and not os.path.isdir(settings_dir):
|
||||
print(
|
||||
f"ERROR: --settings-path '{settings_dir}' exists but is not a directory."
|
||||
)
|
||||
return 2
|
||||
os.makedirs(settings_dir, exist_ok=True)
|
||||
cmd.extend(["--settings-path", settings_dir])
|
||||
print(f"Settings directory: {settings_dir}")
|
||||
|
||||
if args.detach:
|
||||
# Fully detached launch: new session (setsid), no controlling terminal,
|
||||
|
||||
@@ -9,7 +9,10 @@ description: Inspect ComfyUI LoRA Manager runtime configuration and local diagno
|
||||
|
||||
- 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`.
|
||||
- Resolve paths from the runtime configuration before guessing. Settings-directory precedence (highest first):
|
||||
1. **Explicit override** — env `LORA_MANAGER_SETTINGS_DIR` or standalone `--settings-path` (also accepted by the inspect script as `--settings-path DIR`). Pins EVERYTHING (`settings.json`, `cache/`, `wildcards/`, `backups/`, `logs/`, `stats/`) under the given directory; bypasses portable mode and the user config dir. Common when inspecting a sandboxed/E2E instance.
|
||||
2. **Portable** — repository `<repo-root>/settings.json` with `"use_portable_settings": true` (or `LORA_MANAGER_PORTABLE=1`): settings dir = `<repo-root>`.
|
||||
3. **Default** — `~/.config/ComfyUI-LoRA-Manager` on this machine (`platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`).
|
||||
- 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.
|
||||
|
||||
@@ -32,9 +35,17 @@ python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_conte
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py sqlite --db /path/to/cache.sqlite --limit 3
|
||||
```
|
||||
|
||||
To inspect a sandboxed/E2E instance that pins its settings directory:
|
||||
|
||||
```bash
|
||||
# --settings-path DIR (or LORA_MANAGER_SETTINGS_DIR) works with every subcommand:
|
||||
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py \
|
||||
--settings-path /tmp/opencode/<plan>-e2e/settings summary
|
||||
```
|
||||
|
||||
## 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 directory: resolve via `py/utils/settings_paths.py` — `get_settings_dir()` honors the `LORA_MANAGER_SETTINGS_DIR` / programmatic override first, then portable mode, then `platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`. The inspect script mirrors this precedence in `resolve_settings_path()`.
|
||||
- Settings file: `<settings_dir>/settings.json`.
|
||||
- Cache root: `<settings_dir>/cache`.
|
||||
- Canonical cache files:
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any
|
||||
|
||||
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
|
||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
|
||||
CACHE_SQLITE = {
|
||||
"model": ("model", "{library}.sqlite"),
|
||||
"recipe": ("recipe", "{library}.sqlite"),
|
||||
@@ -30,6 +31,15 @@ CACHE_JSON = {
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Inspect LoRA Manager runtime state read-only.")
|
||||
parser.add_argument(
|
||||
"--settings-path",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Explicit settings directory (same as LORA_MANAGER_SETTINGS_DIR / "
|
||||
"standalone --settings-path). Overrides portable mode and the default "
|
||||
"user config dir.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
subparsers.add_parser("summary", help="Print redacted settings and resolved paths.")
|
||||
@@ -44,6 +54,8 @@ def main() -> int:
|
||||
sqlite_parser.add_argument("--limit", type=int, default=3, help="Rows to sample from each user table.")
|
||||
|
||||
args = parser.parse_args()
|
||||
if args.settings_path:
|
||||
os.environ[SETTINGS_DIR_ENV] = args.settings_path
|
||||
context = build_context()
|
||||
|
||||
if args.command == "summary":
|
||||
@@ -78,6 +90,11 @@ def build_context() -> dict[str, Any]:
|
||||
|
||||
|
||||
def resolve_settings_path() -> Path:
|
||||
# Explicit override: LORA_MANAGER_SETTINGS_DIR env or --settings-path.
|
||||
explicit = os.environ.get(SETTINGS_DIR_ENV)
|
||||
if explicit:
|
||||
return Path(explicit).expanduser() / "settings.json"
|
||||
|
||||
repo_root = find_repo_root()
|
||||
portable = repo_root / "settings.json"
|
||||
if portable.exists():
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# Plan: Multi-File Downloads Within a Single CivitAI Model Version
|
||||
|
||||
**Issue:** [#1058 — Cannot download multiple file variants from the same model version](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1058)
|
||||
**Status:** v2 — revised after adversarial review (backend correctness + frontend/tests)
|
||||
**Scope:** CivitAI/CivArchive downloads of `lora`, `checkpoint`, `embedding` model types. HuggingFace downloads are out of scope (already per-file).
|
||||
|
||||
> v2 changelog: incorporated 18 review findings. Key changes vs v1:
|
||||
> shared file resolver + `resolved_version_id` for the gate (R1); `file_params` normalization at API boundary (R2); D2 hash-matching rule fixed for empty-hash cases (R6/R7); D3 extended to re-point `version_index` on removal (R4); D4 replaced with a child table (R3); `delete_model_version` interaction documented (R5); `ModelVersionsTab` surface added to phase 2 (F6); phase-2 multi-file loop requires a reload-deferred download variant (F7); queue-retry `file_params=NULL` known issue recorded (R9); test-fixture gaps and revised estimates (F10).
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
A CivitAI model version can contain multiple downloadable weight files (e.g. fp16/fp32, safetensors/ckpt, different sizes). LoRA Manager already has a working file-selection pipeline (frontend file dialog → `fileParams` → backend file matching), but downloaded state is tracked at the **model-version** level. After any single file of a version is downloaded:
|
||||
|
||||
1. The version is marked **In Library** and the file-selection entry point disappears.
|
||||
2. The backend rejects further download attempts for that version.
|
||||
|
||||
There is no way to download the remaining files of the same version through LoRA Manager.
|
||||
|
||||
## 2. Current State (verified against code; all references confirmed by review)
|
||||
|
||||
### 2.1 Download gating — backend (`py/services/download_manager.py`)
|
||||
|
||||
`_execute_original_download` enforces two version-level gates:
|
||||
|
||||
- **Library gate, early** (lines 1157–1184, before metadata fetch, fires when `model_version_id` given) and **late** (lines 1350–1376, fires only when `model_version_id is None`): `scanner.check_model_version_exists(version_id)` across lora/checkpoint/embedding scanners → hard error `"Model version already exists in ... library"`.
|
||||
- **History gate** (lines 1238–1279): when `skip_previously_downloaded_model_versions` setting is on, `_has_been_downloaded(model_type, version_id)` → silent skip. History DB primary key is `(model_type, version_id)` (`py/services/downloaded_version_history_service.py:61`).
|
||||
|
||||
File selection works: `file_params {id, type, format, size, fp}` is matched against `version_info.files` (lines 1498–1569), **but only under `if file_params and model_version_id:` (line 1499)** — with `model_id`-only requests the selection silently falls back to the primary file (1571–1619). `file_params` currently carries no file `name` or hash.
|
||||
|
||||
### 2.2 Downloaded-state surfacing — backend (`py/routes/handlers/model_handlers.py`)
|
||||
|
||||
`get_civitai_versions` (lines 2148–2188) sets per-version `existsLocally` via `cache.version_index.get(version_id)` (plus a single `localPath` from that entry) and `hasBeenDownloaded` via the history service. No per-file granularity.
|
||||
|
||||
### 2.3 Frontend blockers (`static/js/managers/DownloadManager.js`)
|
||||
|
||||
Three independent gates prevent re-entering the file dialog:
|
||||
|
||||
1. **Line 598:** file-select badge rendered only when `modelFiles.length > 1 && !existsLocally`.
|
||||
2. **Lines 666–681 (`updateNextButtonState`):** Next button disabled with "Already in Library" when `currentVersion.existsLocally`.
|
||||
3. **Lines 784–787 (`proceedToLocation`):** toast + abort when `currentVersion.existsLocally`.
|
||||
|
||||
The badge path (`confirmFileSelection` lines 737–759 → `proceedToLocationContent` → `startDownload` single mode → `executeDownloadWithProgress` → POST `file_params`, `static/js/api/baseModelApi.js:1236–1250`) has **zero** `existsLocally` guards (all 12 occurrences enumerated; none on this path; `import/DownloadManager.js` has none either). The `.exists-locally` CSS class is purely visual (`download-modal.css:496–499`). **Making the badge visible again is sufficient to unlock the flow** for phase 1.
|
||||
|
||||
Post-download refresh is clean: the modal closes and `resetAndReload(true)` performs a full library refetch (`DownloadManager.js:1063`); dialog reopen resets state and refetches versions with no client-side cache. No same-session staleness.
|
||||
|
||||
### 2.4 Local identity of the downloaded file
|
||||
|
||||
`LoraMetadata/CheckpointMetadata/EmbeddingMetadata.from_civitai_info(version_info, file_info, ...)` (`py/utils/models.py:245–369`) persists:
|
||||
|
||||
- `sha256` = `file_info.hashes.SHA256` (lowercased, defaults to `""`) — a stable per-file identity;
|
||||
- `civitai` = the full `version_info` payload (including the `files` list).
|
||||
|
||||
Metadata refresh (`metadata_sync_service.py:104–105`) replaces the `civitai` blob wholesale but never overwrites top-level `sha256`; `verify_duplicate_hashes` (481–526) corrects it to the on-disk hash. Top-level-sha256 matching is refresh-robust.
|
||||
|
||||
**Caveats (review R6/R7):**
|
||||
- SHA256 is not guaranteed: CivArchive's transform only sets `hashes` when source data carries it (`civarchive_client.py:185–189`); `from_civitai_info` defaults to `""`.
|
||||
- Name fallback is unreliable exactly when it matters: local `file_name` is extension-less (`models.py:264`) and `generate_unique_filename` rewrites it with a hash suffix on conflict (`download_manager.py:1125–1136`); checkpoints with `hash_status='pending'` keep empty sha256 until on-demand hashing (`model_scanner.py:1232–1240`).
|
||||
|
||||
### 2.5 Version index collision (pre-existing hazard)
|
||||
|
||||
`ModelCache.version_index` is single-valued (`model_cache.py:133`: `version_index[version_id] = item`). Two files of the same version in the library → second entry overwrites the first; `remove_from_version_index` (lines 151–181) drops the whole version key when the indexed entry is removed, even if a sibling file remains. ~10 read sites depend on this index (48 grep touch points total; readers include `recipe_scanner.py:2682–2726`, `recipe_format.py:37–40`, `misc_handlers.py:2440–2444`, `model_handlers.py`, `model_scanner.check_model_version_exists:2444`).
|
||||
|
||||
Review correction (F3): bulk paths `remove_models` (`model_scanner.py:2376`) and `update_single_model_cache` (`:1689`) call `rebuild_version_index()` right after, so a sibling re-enters the index in those flows — the hazard is narrower than v1 stated, but direct `remove_from_version_index` callers (e.g. `model_scanner.py:1018`) still drop the key, and the user-visible artifact in phase 1 is real: `localPath` in the dialog flips to whichever file was indexed last.
|
||||
|
||||
### 2.6 Entry points that send / don't send `file_params` (fully enumerated by review)
|
||||
|
||||
**Send `file_params` (user-initiated dialog flows only):** `DownloadManager.js:1611–1639` (single mode). API surface accepting arbitrary JSON `file_params`: GET `/api/lm/download-model-get` (`model_handlers.py:1634–1686`), POST `/api/lm/downloads/queue/add` (`model_handlers.py:1799–1832`).
|
||||
|
||||
**Never send `file_params` (keep version-level semantics):** batch download (`DownloadManager.js:1756–1766`; batch also filters out in-library versions at `:1648`), `downloadVersionWithDefaults` (`:1810–1830`), recipe import (`import/DownloadManager.js:269–276`), bulk missing-LoRA (`BulkMissingLoraDownloadManager.js:292–299`), `RecipeModal.js:1728–1736`, `ModelVersionsTab.js:1427`. `web/comfyui/` and `vue-widgets/src` contain **no** download triggers at all (grep-verified). `py/services/use_cases/` has only `download_model_use_case.py` (pass-through).
|
||||
|
||||
### 2.7 Paths that do NOT need changes (verified)
|
||||
|
||||
- **aria2 pause/resume** (`_resume_restored_aria2_download`, line 754+): resumes from persisted `resume_context`; never re-runs existence gates.
|
||||
- **`download_coordinator.py:90`**: pure pass-through of `file_params`.
|
||||
- **Update checker / plugin self-update** (`update_routes.py:496–501`): only closes the history DB handle.
|
||||
- **History delete semantics**: `mark_as_deleted` sets `is_deleted_override=1` and `has_been_downloaded` then returns False (`downloaded_version_history_service.py:276`) — LM-initiated deletes already reset the history skip.
|
||||
|
||||
### 2.8 Related pre-existing issues (record, not necessarily fix)
|
||||
|
||||
- **Queue retry drops file selection** (R9): `download_queue_service.retry_from_history` / `retry_all_failed` re-queue with `file_params=NULL` (`download_queue_service.py:705, 758`) although the queue table has a `file_params` column (`:43`) — a retried non-primary download silently reverts to the primary file. Fix alongside phase 1 (small: persist and reuse the column).
|
||||
- **`delete_model_version`** (`misc_handlers.py:2410–2487`): resolves the file via the single-valued `version_index` (2440–2444), deletes only that one file, and `mark_as_deleted` flags the **entire version** as deleted in history (2479) even when a sibling file remains in the library. See phase 2 item 6.1.5.
|
||||
|
||||
## 3. Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- G1: A user can download any not-yet-downloaded file of a version already partially in the library (issue repro steps 6–8).
|
||||
- G2: True duplicates stay blocked: downloading the *same* file of the same version twice is rejected.
|
||||
- G3: Per-file downloaded state visible in the file dialog; multiple files selectable and downloadable in one pass.
|
||||
- G4: No regression for version-level semantics relied on by batch download, recipe missing-LoRA detection, and `skip_previously_downloaded_model_versions`.
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- No change to recipe `inLibrary` semantics ("any file of the version present" remains sufficient).
|
||||
- No change to the update-checker (version-level comparison).
|
||||
- No primary-key rebuild of the history database.
|
||||
- HuggingFace download flow untouched.
|
||||
|
||||
## 4. Design Decisions
|
||||
|
||||
- **D1 — Explicit file selection bypasses the history gate, version-level gates stay for everyone else.** The history skip exists to dedupe automated flows. A user explicitly picking a file is unambiguous intent; the file-level library gate (G2) still prevents real duplicates. **Guard conditions use normalized truthiness** (see D1a). All confirmed `file_params` senders are user-initiated dialog flows (2.6), and LM-initiated deletes already reset history (2.7), so the bypass only affects "downloaded but not LM-deleted" versions with the setting on — intended.
|
||||
- **D1a — `file_params` normalization at the boundary (R2).** `download-model-get` and `downloads/queue/add` accept arbitrary JSON; `{}` is `not None` but falsy and would bypass gates while downloading the primary file. Normalize `file_params = file_params or None` in the coordinator/handlers, and treat the bypass as active only when a target file id is resolvable.
|
||||
- **D2 — File identity matching rule (R6/R7):** hash-compare **only when both sides are non-empty** (lowercase SHA256 equality); name-compare when either side is empty. Never let `"" == ""` match. Name fallback caveats from 2.4 apply (renamed files, pending checkpoint hashes) — acceptable residual risk, worst case is a blocked re-download the user can retry after hashing completes.
|
||||
- **D3 — Cache indexes: additive multi-index + removal re-pointing (R4).** Add `version_files_index: Dict[int, List[dict]]` maintained alongside `version_index` by the same add/remove/rebuild methods; existing readers of `version_index` untouched. Additionally fix `remove_from_version_index`: when the popped entry has a surviving sibling (per the multi-index), re-point `version_index[version_id]` to the sibling instead of dropping the key; same for the `model_id_index` descriptor. This closes the 2.5 hazard for existing readers (`check_model_version_exists`, `existsLocally`, recipe matching) without restructuring anything.
|
||||
- **D4 — Per-file history via a child table (R3).** v1's additive-column approach is structurally impossible on a `(model_type, version_id)` PK (`ON CONFLICT DO UPDATE` would keep only the last file). Instead add `downloaded_version_files(model_type, version_id, file_id, file_name, downloaded_at, PRIMARY KEY(model_type, version_id, file_id))` — additive, no PK rebuild, honors the Non-Goal. Existing version-level table and queries unchanged. New per-file queries are opt-in. `_initialize_schema` uses `CREATE TABLE IF NOT EXISTS`, so the new table is created for existing DBs without any ALTER.
|
||||
- **D5 — UI flow reuse, with an extracted inner download function for multi-file (F7).** Phase 1 unlocks the existing badge → file dialog → location → download pipeline. Phase 2 upgrades the dialog to multi-select; iterating `executeDownloadWithProgress` as-is would produce N full library reloads, N toasts, and competing failure-summary modals — so phase 2 extracts a reload-deferred, failure-aggregating inner variant and runs one reload + one summary at the end.
|
||||
|
||||
## 5. Implementation — Phase 1 (fix the issue; independently shippable)
|
||||
|
||||
### 5.1 Backend — `py/services/download_manager.py`
|
||||
|
||||
1. **Normalize `file_params`** at the boundary (D1a): `download_coordinator.schedule_download` and the two API handlers (`model_handlers.py:1649–1666`, `1810–1832`) apply `file_params = file_params or None`.
|
||||
2. **Extract a shared file resolver** (R1): pull the matching logic at 1498–1569 into `_resolve_target_file(version_info, file_params) -> Optional[dict]`, used by **both** the new gate and the download-selection path. The selection path's condition (line 1499) switches from `model_version_id` to `resolved_version_id` (already computed at 1230–1236 from `version_info.id`), so gate and download always agree on the target file — including the `model_id`-only case.
|
||||
3. **New helper** `_find_local_file_entry(version_id, target_file) -> Optional[dict]`: iterate the three scanners' cached `raw_data` (NOT `version_index` — single-valued); candidates = entries whose `civitai.id` normalizes to `version_id`; match per D2.
|
||||
4. **Gate restructure in `_execute_original_download`**:
|
||||
- Early scanner gate (1157–1184): add `file_params is None` guard; with normalized `file_params`, defer (file identity not resolvable before metadata fetch).
|
||||
- After `version_info` fetch + `resolved_version_id` (~1229): when `file_params` present, resolve target file via the shared resolver; unresolvable → hard error "No matching file" (fail closed, prevents empty-dict bypass). Resolvable → `_find_local_file_entry`; hit → same hard error shape as today with the file name in the message.
|
||||
- History gate (1238–1279): add `file_params is None` (D1). Base-model skip (1281–1324) unchanged — still applies.
|
||||
- Late gate (1350–1376): add `file_params is None` guard (F2) — the post-fetch file-level check above already covers this case.
|
||||
- Nothing between the early gate and the post-fetch point assumes the version is absent (review task 6: only provider selection + metadata fetch; no DB writes; `_persist_aria2_state` runs only when actually downloading at 1659).
|
||||
5. **Queue retry fix** (2.8, small): persist `file_params` into the queue table on enqueue and reuse it in `retry_from_history` / `retry_all_failed`.
|
||||
6. Logging: `[download]` lines for file-level allow/block, consistent with existing style.
|
||||
|
||||
**Estimated:** ~150–220 LOC + resolver extraction.
|
||||
|
||||
### 5.2 Frontend — `static/js/managers/DownloadManager.js`
|
||||
|
||||
1. Line 598: drop `&& !existsLocally` from the badge condition (badge shows whenever `modelFiles.length > 1`).
|
||||
2. `fileParams` construction (1611–1616): add `name: this.selectedFile.name`.
|
||||
3. Surface the backend "file already in library" hard error as a toast instead of only the batch-summary modal (R10/F12 nit; reuse existing error message field).
|
||||
4. No changes to `updateNextButtonState` / `proceedToLocation` in phase 1; no template or CSS changes.
|
||||
|
||||
**Known phase-1 UX limitations (acknowledged, fixed in phase 2):** with all files downloaded the badge still renders and re-picking a downloaded file fails late (backend error after the location step); `localPath` may point at a sibling file; batch-preview "In Library" badge stays version-level and gives no hint of remaining files.
|
||||
|
||||
**Estimated:** ~10–30 LOC (confirmed realistic by review).
|
||||
|
||||
### 5.3 Phase 1 tests
|
||||
|
||||
Backend — extend `tests/services/test_download_manager_basic.py` (1694 lines; all fixture patterns exist):
|
||||
|
||||
- **Fixture gaps to add (F10):** `DummyScanner.get_cached_data()`/`raw_data` stub (~10 lines); `hashes.SHA256` in the metadata-provider payload's `files`.
|
||||
- Cases: same version + different SHA256 in library + `file_params` → proceeds; same SHA256 → hard error; `file_params=None` + version in library → hard error (unchanged); history-skip on + `file_params` → not skipped; without → skipped (unchanged); empty-dict `file_params` normalized → version-level behavior; `model_id`-only + `file_params` → gate and selection resolve the same file; legacy metadata (empty local sha256) matched by name; target file with empty SHA256 → name fallback, no `""==""` false positive.
|
||||
- Queue retry: `file_params` survives retry.
|
||||
- Assert proceed/abort via the existing `_execute_download` mock pattern.
|
||||
|
||||
Frontend (`tests/frontend/`): badge renders for multi-file version with `existsLocally=true` (pattern from `downloadManager.history.test.js`).
|
||||
|
||||
**Estimated:** ~150–250 LOC (confirmed realistic).
|
||||
|
||||
## 6. Implementation — Phase 2 (per-file status + multi-select + index hardening)
|
||||
|
||||
### 6.1 Backend
|
||||
|
||||
1. **`py/services/model_cache.py`** (D3): add `version_files_index`; maintain in `add_to_version_index` / `remove_from_version_index` / `rebuild_version_index`; removal re-points `version_index[version_id]` (and the `model_id_index` descriptor) to a surviving sibling instead of dropping the key.
|
||||
2. **`py/services/model_scanner.py`**: expose `get_files_for_version(version_id) -> List[dict]`.
|
||||
3. **`py/routes/handlers/model_handlers.py` `get_civitai_versions`**: annotate each version with `downloadedFiles: [{fileId, fileName, filePath}]` via `version_files_index` + D2 matching against `version.files`.
|
||||
4. **`py/services/downloaded_version_history_service.py`** (D4): new child table `downloaded_version_files`; `mark_downloaded` also upserts the child row when `file_id` known; `mark_as_deleted` clears the version's child rows only when no sibling remains in the library; new `get_downloaded_file_ids(model_type, version_id) -> set[int]`. `_record_downloaded_version_history` passes `file_info` through.
|
||||
5. **`delete_model_version`** (`misc_handlers.py:2410–2487`, R5): resolve **all** local files of the version via `version_files_index`; delete all (current endpoint semantics are version-level) or — if kept per-file — only `mark_as_deleted` when no sibling remains. Decide at implementation time; minimum is documenting current behavior.
|
||||
6. **`ModelVersionsTab` backend support**: none needed beyond item 3 (`downloadedFiles`); the tab consumes the same versions payload.
|
||||
|
||||
### 6.2 Frontend
|
||||
|
||||
1. **File dialog multi-select** — change surface (F8): option markup (`DownloadManager.js:712–724`), the single-select click handler (`727–734`), the `input[type="radio"]:checked` selector in `confirmFileSelection` (`738`); template `templates/components/modals/download_modal.html:48–60` (confirm-button label only); CSS `download-modal.css` — checkbox variant of `.file-option-radio input` (595–604) and a **new** `.file-option.disabled` style (does not exist). Files whose id ∈ `downloadedFiles` render disabled with an "In Library" tag.
|
||||
2. **Mixed-type guard (F8):** multi-select is restricted to files sharing the same routing target (`_isDiffusionModel` is computed once from a single `selectedFile` at 798–803; e.g. "Model" + "UNet" files route to different roots). Disallow mixed-type multi-select (simplest, predictable); single-file selection unchanged.
|
||||
3. **Multi-file download loop (D5/F7):** extract from `executeDownloadWithProgress` a reload-deferred, no-toast inner function; iterate per selected file with per-file progress; one `resetAndReload(true)` + one aggregated success/failure summary at the end (reuse `showDownloadBatchSummary`).
|
||||
4. **`updateNextButtonState` / `proceedToLocation`:** for multi-file versions, Next routes into the file dialog; hard block only when *every* weight file is downloaded.
|
||||
5. **`ModelVersionsTab.js` (F6):** the Download action (`:576` hidden when `isInLibrary`) — for multi-file versions with remaining files, show it and route into the download modal's file dialog; keep hidden when all files present.
|
||||
6. **Batch preview (F5):** `batch-preview-local-badge` (`:1320`) gains a "partially downloaded" hint for multi-file versions with remaining files.
|
||||
7. New i18n keys (`modals.download.fileSelection.inLibrary`, `downloadSelected`, partial-download tooltip, etc.) → run `python scripts/sync_translation_keys.py`.
|
||||
|
||||
### 6.3 Phase 2 tests
|
||||
|
||||
- `model_cache` (`tests/services/test_model_cache.py` already covers add/remove at 44–55): multi-valued index; sibling re-point on removal; rebuild.
|
||||
- `get_civitai_versions`: `downloadedFiles` correctness (hash match, name fallback, no match, CivArchive no-hash payload).
|
||||
- History service (`tests/services/test_downloaded_version_history_service.py` uses real SQLite on tmp_path): child-table creation on a legacy DB; per-file record/query; `mark_as_deleted` sibling semantics.
|
||||
- Frontend: dialog checkbox rendering/disabled state and multi-file confirm — **greenfield behavior coverage** (F10: no existing test exercises `showFileSelectionStep`/`confirmFileSelection`; infra exists, patterns must be built).
|
||||
|
||||
## 7. Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| History-gate bypass (D1) causes unwanted re-downloads in automated flows | Large checkpoint files re-downloaded | Bypass only with normalized, resolvable `file_params` (D1a); all such senders are user-initiated dialog flows (2.6, verified); tests pin batch/recipe/bulk behavior. |
|
||||
| Empty-hash matching edge cases (R6) | Duplicate download of the same file, or false block | D2 rule: hash only when both non-empty; name otherwise; never `""==""`. Residual risk documented (2.4). |
|
||||
| Phase-1 late-failure UX (F12) | User picks a downloaded file, fails only after location step | Toast surfacing (5.2.3); phase 2 disables downloaded files up front. |
|
||||
| Phase-2 index change corrupts existing behavior | Recipe matching, delete flows | Additive index + re-point only; `version_index` read semantics unchanged; `remove_models`/`update_single_model_cache` already rebuild (F3); tests. |
|
||||
| `delete_model_version` marks whole version deleted while sibling remains (R5) | History wrongly suppresses re-download of the surviving sibling's version | Phase 2 item 6.1.5; documented until then. |
|
||||
| History child-table migration failure on user installs | Service init crash | `CREATE TABLE IF NOT EXISTS` in `_initialize_schema`; failure degrades to version-level behavior (per-file queries return empty). |
|
||||
| Batch-preview badge misleading for partial versions (F5) | Minor UX confusion | Acknowledged in phase 1; fixed in phase 2 item 6.2.6. |
|
||||
| UI confusion: version shows "In Library" while files remain downloadable | Support burden | Phase 2: per-file disabled state + partial-download tooltip. |
|
||||
| Hash-identical sibling files (repacked content) | Second file blocked | Acceptable: scanner hash dedup already collapses them. |
|
||||
|
||||
## 8. Rollout
|
||||
|
||||
1. **Commit 1** — `fix(download): allow downloading additional files of an in-library model version (#1058)` → Phase 1 (5.1–5.3).
|
||||
2. **Commit 2** — `feat(download): per-file download status and multi-file selection (#1058)` → Phase 2 (6.1–6.3).
|
||||
|
||||
Phase 1 alone resolves the issue as reported; phase 2 can ship in a later release if review prefers smaller increments.
|
||||
|
||||
## 9. Effort Estimate (revised after review)
|
||||
|
||||
| Phase | Backend | Frontend | Tests | Risk |
|
||||
|---|---|---|---|---|
|
||||
| 1 | ~150–220 LOC (+ queue-retry fix ~30) | ~10–30 LOC | ~150–250 LOC | Low |
|
||||
| 2 | ~250–350 LOC | ~250–350 LOC (multi-file loop refactor + ModelVersionsTab + batch badge) | ~250–350 LOC (dialog tests greenfield) | Medium |
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "Modellname",
|
||||
"tags": "Tags",
|
||||
"creator": "Ersteller",
|
||||
"hash": "Hash",
|
||||
"title": "Rezept-Titel",
|
||||
"loraName": "LoRA-Dateiname",
|
||||
"loraModel": "LoRA-Modellname",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "Beliebig",
|
||||
"all": "Alle",
|
||||
"tagLogicAny": "Jedes Tag abgleichen (ODER)",
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)"
|
||||
"tagLogicAll": "Alle Tags abgleichen (UND)",
|
||||
"loraAvailability": "LoRA-Verfügbarkeit",
|
||||
"availabilityReady": "Einsatzbereit",
|
||||
"availabilityMissing": "Mit fehlenden LoRAs",
|
||||
"availabilityDeleted": "Mit gelöschten LoRAs"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Theme wechseln",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "Nur Early-Access-Updates"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "Bezahlte Updates ausblenden",
|
||||
"help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRA-Rezepte",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Rezept löschen (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Rezeptnavigation",
|
||||
"previousWithShortcut": "Vorheriges Rezept (←)",
|
||||
"nextWithShortcut": "Nächstes Rezept (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Workflow an ComfyUI senden",
|
||||
"sent": "Workflow an ComfyUI gesendet",
|
||||
"sendFailed": "Fehler beim Senden des Workflows an ComfyUI",
|
||||
"noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Importieren",
|
||||
"title": "Ein Rezept aus Bild oder URL importieren",
|
||||
"urlLocalPath": "URL / Lokaler Pfad",
|
||||
"uploadImage": "Bild hochladen",
|
||||
"urlSectionDescription": "Geben Sie eine Civitai-Bild-URL oder einen lokalen Dateipfad ein, um es als Rezept zu importieren.",
|
||||
"dropZoneLabel": "Bild hochladen",
|
||||
"dropZoneHint": "Bild hierher ziehen, aus der Zwischenablage einfügen oder klicken zum Durchsuchen",
|
||||
"orDivider": "oder Bild per Drag & Drop / Einfügen hinzufügen",
|
||||
"imageUrlOrPath": "Bild-URL oder Dateipfad:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... oder C:/pfad/zu/bild.png",
|
||||
"fetchImage": "Bild abrufen",
|
||||
"uploadSectionDescription": "Laden Sie ein Bild mit LoRA-Metadaten hoch, um es als Rezept zu importieren.",
|
||||
"selectImage": "Bild auswählen",
|
||||
"recipeName": "Rezeptname",
|
||||
"recipeNamePlaceholder": "Rezeptname eingeben",
|
||||
"tagsOptional": "Tags (optional)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "Bitte wählen Sie eine Bilddatei aus",
|
||||
"enterUrlOrPath": "Bitte geben Sie eine URL oder einen Dateipfad ein",
|
||||
"invalidUrl": "Bitte geben Sie eine gültige URL ein",
|
||||
"invalidInputFormat": "Bitte geben Sie eine Bild-URL oder einen lokalen Bilddateipfad ein",
|
||||
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "Suche nach doppelten Rezepten...",
|
||||
"found": "{count} Duplikat-Gruppen gefunden",
|
||||
"noGroups": "Keine Duplikat-Gruppen mit dem aktuellen Abgleichskriterium gefunden",
|
||||
"keepLatest": "Neueste Versionen behalten",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "Heruntergeladen",
|
||||
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
|
||||
"alreadyInLibrary": "Bereits in Bibliothek",
|
||||
"partiallyDownloaded": "Teilweise heruntergeladen",
|
||||
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
|
||||
"fileSelection": {
|
||||
"title": "Dateiformat auswählen",
|
||||
"files": "Dateien",
|
||||
"select": "Datei auswählen"
|
||||
"select": "Datei auswählen",
|
||||
"inLibrary": "In Bibliothek"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Ungültiges Civitai URL-Format",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "Fahren Sie nur fort, wenn Sie sicher sind, dass Sie das wollen.",
|
||||
"urlLabel": "Civitai-Modell-URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "Fügen Sie eine beliebige Civitai-Modell-URL ein. Unterstützte Formate:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "Hinweis: Wenn keine modelVersionId angegeben ist, wird die neueste Version verwendet."
|
||||
"title": "Fügen Sie eine beliebige Civitai- oder CivitArchive-Modell-URL ein. Unterstützte Formate:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "Hinweis: Wenn keine modelVersionId angegeben ist, wird die neueste Version verwendet.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "Neu-Verknüpfung bestätigen"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "Ersteller-Profil anzeigen",
|
||||
"openFileLocation": "Dateispeicherort öffnen",
|
||||
"sendToWorkflow": "An ComfyUI senden",
|
||||
"sendToWorkflowText": "An ComfyUI senden"
|
||||
"sendToWorkflowText": "An ComfyUI senden",
|
||||
"copyHash": "Hash kopieren",
|
||||
"deleteModelWithShortcut": "Modell löschen (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Dateispeicherort erfolgreich geöffnet",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "Speicherort",
|
||||
"baseModel": "Basis-Modell",
|
||||
"size": "Größe",
|
||||
"hashes": "Hashes",
|
||||
"unknown": "Unbekannt",
|
||||
"usageTips": "Nutzungstipps",
|
||||
"additionalNotes": "Zusätzliche Notizen",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "Beispiele werden geladen...",
|
||||
"versions": "Versionen werden geladen..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} durch Nur-SFW-Einstellung ausgeblendet",
|
||||
"showExamples": "Beispiele anzeigen",
|
||||
"showCount": "Beispiele anzeigen ({count})",
|
||||
"hideExamples": "Beispiele ausblenden",
|
||||
"addExamples": "Beispiele hinzufügen",
|
||||
"previousExample": "Vorheriges Beispiel",
|
||||
"nextExample": "Nächstes Beispiel",
|
||||
"noExamples": "Keine Beispielbilder verfügbar",
|
||||
"addMoreExamples": "Weitere Beispiele hinzufügen",
|
||||
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
|
||||
"or": "oder",
|
||||
"selectFiles": "Dateien auswählen",
|
||||
"supportedFormats": "Unterstützte Formate: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Dateien werden importiert...",
|
||||
"noSupportedFiles": "Keine unterstützten Dateien ausgewählt. Bitte wählen Sie Bild- oder Videodateien aus.",
|
||||
"allFiltered": "Alle Beispielbilder wurden aufgrund der NSFW-Inhaltseinstellungen herausgefiltert",
|
||||
"sfwOnlyEnabled": "Ihre Einstellungen zeigen derzeit nur jugendfreie Inhalte an",
|
||||
"changeInSettings": "Sie können dies in den Einstellungen ändern",
|
||||
"nsfwMature": "Nicht jugendfreie Inhalte",
|
||||
"nsfwR": "Inhalte ab 18 (R)",
|
||||
"nsfwX": "Inhalte mit X-Einstufung",
|
||||
"nsfwXxx": "Inhalte mit XXX-Einstufung"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Modellversionen",
|
||||
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||
"earlyAccess": "Früher Zugriff",
|
||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "Bezahlt",
|
||||
"paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
|
||||
"ignored": "Ignoriert",
|
||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||
"onSiteOnly": "Nur On-Site",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "Herunterladen",
|
||||
"downloadTooltip": "Diese Version herunterladen",
|
||||
"downloadChooseFilesTooltip": "Auswählen, welche Dateien heruntergeladen werden sollen",
|
||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "Diese bezahlte Version von Civitai herunterladen",
|
||||
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||
"delete": "Löschen",
|
||||
"deleteTooltip": "Diese lokale Version löschen",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "Keine kompatiblen Prompt-Ziele im Workflow.\nKlicken Sie mit der rechten Maustaste auf einen Knoten in ComfyUI → Markieren als → Prompt-Ziel festlegen",
|
||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen",
|
||||
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
|
||||
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
|
||||
"pleaseSelectFile": "Bitte wählen Sie mindestens eine Datei aus",
|
||||
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
|
||||
"downloadCompleted": "Download erfolgreich abgeschlossen",
|
||||
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||
"created": "Rezept erfolgreich erstellt",
|
||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
|
||||
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
|
||||
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
|
||||
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest.",
|
||||
"workflowSent": "Workflow an ComfyUI gesendet",
|
||||
"workflowSendFailed": "Fehler beim Senden des Workflows an ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Keine Modelle ausgewählt",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "Fehler: {message}",
|
||||
"linkHfSuccess": "Modell erfolgreich mit HuggingFace verknüpft",
|
||||
"linkHfFailed": "Fehler: {message}",
|
||||
"linkCivArchSuccess": "Modell erfolgreich über CivitArchive neu verknüpft",
|
||||
"fetchMetadataFirst": "Bitte rufen Sie zuerst Metadaten von CivitAI ab",
|
||||
"noCivitaiInfo": "Keine CivitAI-Informationen verfügbar",
|
||||
"missingHash": "Modell-Hash nicht verfügbar"
|
||||
|
||||
+74
-17
@@ -222,6 +222,7 @@
|
||||
"modelname": "Model Name",
|
||||
"tags": "Tags",
|
||||
"creator": "Creator",
|
||||
"hash": "Hash",
|
||||
"title": "Recipe Title",
|
||||
"loraName": "LoRA Filename",
|
||||
"loraModel": "LoRA Model Name",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"tagLogicAny": "Match any tag (OR)",
|
||||
"tagLogicAll": "Match all tags (AND)"
|
||||
"tagLogicAll": "Match all tags (AND)",
|
||||
"loraAvailability": "Lora Availability",
|
||||
"availabilityReady": "Ready to use",
|
||||
"availabilityMissing": "Has missing",
|
||||
"availabilityDeleted": "Has deleted"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Delete recipe (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Recipe navigation",
|
||||
"previousWithShortcut": "Previous recipe (←)",
|
||||
"nextWithShortcut": "Next recipe (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Send Workflow to ComfyUI",
|
||||
"sent": "Workflow sent to ComfyUI",
|
||||
"sendFailed": "Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "No embedded workflow found in this recipe"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Import",
|
||||
"title": "Import a recipe from image or URL",
|
||||
"urlLocalPath": "URL / Local Path",
|
||||
"uploadImage": "Upload Image",
|
||||
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
|
||||
"dropZoneLabel": "Upload image",
|
||||
"dropZoneHint": "Drag & drop an image here, paste from clipboard, or click to browse",
|
||||
"orDivider": "or drag & drop / paste an image",
|
||||
"imageUrlOrPath": "Image URL or File Path:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
|
||||
"fetchImage": "Fetch Image",
|
||||
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
||||
"selectImage": "Select Image",
|
||||
"recipeName": "Recipe Name",
|
||||
"recipeNamePlaceholder": "Enter recipe name",
|
||||
"tagsOptional": "Tags (optional)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "Please select an image file",
|
||||
"enterUrlOrPath": "Please enter a URL or file path",
|
||||
"invalidUrl": "Please enter a valid URL",
|
||||
"invalidInputFormat": "Please enter an image URL or a local image file path",
|
||||
"selectLoraRoot": "Please select a LoRA root directory"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "Scanning for duplicate recipes...",
|
||||
"found": "Found {count} duplicate groups",
|
||||
"noGroups": "No duplicate groups found with the current matching basis",
|
||||
"keepLatest": "Keep Latest Versions",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||
"alreadyInLibrary": "Already in Library",
|
||||
"partiallyDownloaded": "Partially downloaded",
|
||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||
"fileSelection": {
|
||||
"title": "Select File Format",
|
||||
"files": "files",
|
||||
"select": "Select File"
|
||||
"select": "Select File",
|
||||
"inLibrary": "In Library"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Invalid Civitai URL format",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "Only proceed if you're sure this is what you want.",
|
||||
"urlLabel": "Civitai Model URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 or https://civitai.red/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "Note: If no modelVersionId is provided, the latest version will be used."
|
||||
"title": "Paste any Civitai or CivitArchive model URL. Supported formats:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "Note: If no modelVersionId is provided, the latest version will be used.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "Confirm Re-link"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI"
|
||||
"sendToWorkflowText": "Send to ComfyUI",
|
||||
"copyHash": "Copy hash",
|
||||
"deleteModelWithShortcut": "Delete model (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "Location",
|
||||
"baseModel": "Base Model",
|
||||
"size": "Size",
|
||||
"hashes": "Hashes",
|
||||
"unknown": "Unknown",
|
||||
"usageTips": "Usage Tips",
|
||||
"additionalNotes": "Additional Notes",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "Loading examples...",
|
||||
"versions": "Loading versions..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} hidden by SFW-only setting",
|
||||
"showExamples": "Show examples",
|
||||
"showCount": "Show examples ({count})",
|
||||
"hideExamples": "Hide examples",
|
||||
"addExamples": "Add examples",
|
||||
"previousExample": "Previous example",
|
||||
"nextExample": "Next example",
|
||||
"noExamples": "No example images available",
|
||||
"addMoreExamples": "Add more examples",
|
||||
"dragDrop": "Drag & drop images or videos here",
|
||||
"or": "or",
|
||||
"selectFiles": "Select Files",
|
||||
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Importing files...",
|
||||
"noSupportedFiles": "No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "You can change this in Settings",
|
||||
"nsfwMature": "Mature Content",
|
||||
"nsfwR": "R-rated Content",
|
||||
"nsfwX": "X-rated Content",
|
||||
"nsfwXxx": "XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
"copy": "Track and manage every version of this model in one place.",
|
||||
@@ -1569,6 +1618,7 @@
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadChooseFilesTooltip": "Choose which files to download",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"downloadPaidTooltip": "Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
|
||||
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
|
||||
"pleaseSelectVersion": "Please select a version",
|
||||
"pleaseSelectFile": "Please select at least one file",
|
||||
"versionExists": "This version already exists in your library",
|
||||
"downloadCompleted": "Download completed successfully",
|
||||
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "Missing required data to create recipe",
|
||||
"created": "Recipe created successfully",
|
||||
"noMissingLoras": "No missing LoRAs to download",
|
||||
"noPreviousRecipe": "No previous recipe available",
|
||||
"noNextRecipe": "No next recipe available",
|
||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
||||
"enterLoraName": "Please enter a LoRA name or syntax",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||
"reimportBulkFailed": "Failed to re-import some recipes",
|
||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings.",
|
||||
"workflowSent": "Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "No embedded workflow found in this recipe"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No models selected",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Model successfully linked to HuggingFace",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"linkCivArchSuccess": "Model successfully re-linked via CivitArchive",
|
||||
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
|
||||
"noCivitaiInfo": "No CivitAI information available",
|
||||
"missingHash": "Model hash not available"
|
||||
@@ -2332,4 +2389,4 @@
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "Nombre del modelo",
|
||||
"tags": "Etiquetas",
|
||||
"creator": "Creador",
|
||||
"hash": "Hash",
|
||||
"title": "Título de la receta",
|
||||
"loraName": "Nombre de archivo LoRA",
|
||||
"loraModel": "Nombre del modelo LoRA",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "Cualquiera",
|
||||
"all": "Todos",
|
||||
"tagLogicAny": "Coincidir con cualquier etiqueta (O)",
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)"
|
||||
"tagLogicAll": "Coincidir con todas las etiquetas (Y)",
|
||||
"loraAvailability": "Disponibilidad de LoRAs",
|
||||
"availabilityReady": "Listos para usar",
|
||||
"availabilityMissing": "Con LoRAs faltantes",
|
||||
"availabilityDeleted": "Con LoRAs eliminados"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Cambiar tema",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "Solo actualizaciones de acceso temprano"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "Ocultar actualizaciones de pago",
|
||||
"help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Usar iconos de licencia actualizados",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "Recetas de LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Enviar a ComfyUI"
|
||||
"sendCheckpoint": "Enviar a ComfyUI",
|
||||
"sendRecipe": "Enviar a ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Eliminar receta (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Navegación de recetas",
|
||||
"previousWithShortcut": "Receta anterior (←)",
|
||||
"nextWithShortcut": "Siguiente receta (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Enviar workflow a ComfyUI",
|
||||
"sent": "Workflow enviado a ComfyUI",
|
||||
"sendFailed": "Error al enviar el workflow a ComfyUI",
|
||||
"noWorkflow": "No se encontró ningún workflow integrado en esta receta"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Importar",
|
||||
"title": "Importar una receta desde imagen o URL",
|
||||
"urlLocalPath": "URL / Ruta local",
|
||||
"uploadImage": "Subir imagen",
|
||||
"urlSectionDescription": "Introduce una URL de imagen de Civitai o ruta de archivo local para importar como receta.",
|
||||
"dropZoneLabel": "Subir imagen",
|
||||
"dropZoneHint": "Arrastra y suelta una imagen aquí, pégala desde el portapapeles o haz clic para examinar",
|
||||
"orDivider": "o arrastra y suelta / pega una imagen",
|
||||
"imageUrlOrPath": "URL de imagen o ruta de archivo:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... o C:/ruta/a/imagen.png",
|
||||
"fetchImage": "Obtener imagen",
|
||||
"uploadSectionDescription": "Sube una imagen con metadatos de LoRA para importar como receta.",
|
||||
"selectImage": "Seleccionar imagen",
|
||||
"recipeName": "Nombre de receta",
|
||||
"recipeNamePlaceholder": "Introduce nombre de receta",
|
||||
"tagsOptional": "Etiquetas (opcional)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "Por favor selecciona un archivo de imagen",
|
||||
"enterUrlOrPath": "Por favor introduce una URL o ruta de archivo",
|
||||
"invalidUrl": "Introduce una URL válida",
|
||||
"invalidInputFormat": "Introduce la URL de una imagen o una ruta de archivo local",
|
||||
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "Buscando recetas duplicadas...",
|
||||
"found": "Se encontraron {count} grupos de duplicados",
|
||||
"noGroups": "No se encontraron grupos de duplicados con el criterio de coincidencia actual",
|
||||
"keepLatest": "Mantener versiones más recientes",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "Descargado",
|
||||
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
|
||||
"alreadyInLibrary": "Ya en la biblioteca",
|
||||
"partiallyDownloaded": "Descargado parcialmente",
|
||||
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
|
||||
"fileSelection": {
|
||||
"title": "Seleccionar formato de archivo",
|
||||
"files": "archivos",
|
||||
"select": "Seleccionar archivo"
|
||||
"select": "Seleccionar archivo",
|
||||
"inLibrary": "En la biblioteca"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Formato de URL de Civitai inválido",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "Solo procede si estás seguro de que esto es lo que quieres.",
|
||||
"urlLabel": "URL del modelo de Civitai:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "Pega cualquier URL de modelo de Civitai. Formatos soportados:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "Nota: Si no se proporciona modelVersionId, se usará la versión más reciente."
|
||||
"title": "Pega cualquier URL de modelo de Civitai o CivitArchive. Formatos soportados:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "Nota: Si no se proporciona modelVersionId, se usará la versión más reciente.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "Confirmar re-vinculación"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "Ver perfil del creador",
|
||||
"openFileLocation": "Abrir ubicación del archivo",
|
||||
"sendToWorkflow": "Enviar a ComfyUI",
|
||||
"sendToWorkflowText": "Enviar a ComfyUI"
|
||||
"sendToWorkflowText": "Enviar a ComfyUI",
|
||||
"copyHash": "Copiar hash",
|
||||
"deleteModelWithShortcut": "Eliminar modelo (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Ubicación del archivo abierta exitosamente",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "Ubicación",
|
||||
"baseModel": "Modelo base",
|
||||
"size": "Tamaño",
|
||||
"hashes": "Hashes",
|
||||
"unknown": "Desconocido",
|
||||
"usageTips": "Consejos de uso",
|
||||
"additionalNotes": "Notas adicionales",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "Cargando ejemplos...",
|
||||
"versions": "Cargando versiones..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} ocultas por el ajuste de solo contenido SFW",
|
||||
"showExamples": "Mostrar ejemplos",
|
||||
"showCount": "Mostrar ejemplos ({count})",
|
||||
"hideExamples": "Ocultar ejemplos",
|
||||
"addExamples": "Añadir ejemplos",
|
||||
"previousExample": "Ejemplo anterior",
|
||||
"nextExample": "Ejemplo siguiente",
|
||||
"noExamples": "No hay imágenes de ejemplo disponibles",
|
||||
"addMoreExamples": "Añadir más ejemplos",
|
||||
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
|
||||
"or": "o",
|
||||
"selectFiles": "Seleccionar archivos",
|
||||
"supportedFormats": "Formatos compatibles: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Importando archivos...",
|
||||
"noSupportedFiles": "No se seleccionaron archivos compatibles. Selecciona archivos de imagen o video.",
|
||||
"allFiltered": "Todas las imágenes de ejemplo están filtradas por los ajustes de contenido NSFW",
|
||||
"sfwOnlyEnabled": "Tus ajustes están configurados actualmente para mostrar solo contenido apto para todo público",
|
||||
"changeInSettings": "Puedes cambiarlo en Configuración",
|
||||
"nsfwMature": "Contenido para adultos",
|
||||
"nsfwR": "Contenido clasificación R",
|
||||
"nsfwX": "Contenido clasificación X",
|
||||
"nsfwXxx": "Contenido clasificación XXX"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versiones del modelo",
|
||||
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||
"earlyAccess": "Acceso temprano",
|
||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "De pago",
|
||||
"paidTooltip": "Esta versión requiere pago para descargarse",
|
||||
"ignored": "Ignorada",
|
||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||
"onSiteOnly": "Solo en Sitio",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "Descargar",
|
||||
"downloadTooltip": "Descargar esta versión",
|
||||
"downloadChooseFilesTooltip": "Elegir qué archivos descargar",
|
||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "Descargar esta versión de pago desde Civitai",
|
||||
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||
"delete": "Eliminar",
|
||||
"deleteTooltip": "Eliminar esta versión local",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "No hay destinos de prompt compatibles en el workflow.\nHaz clic derecho en un nodo de ComfyUI → Marcar como → Destino de envío de prompt",
|
||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||
"modelFailed": "Error al actualizar nodo de modelo",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs",
|
||||
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
|
||||
"pleaseSelectVersion": "Por favor selecciona una versión",
|
||||
"pleaseSelectFile": "Por favor selecciona al menos un archivo",
|
||||
"versionExists": "Esta versión ya existe en tu biblioteca",
|
||||
"downloadCompleted": "Descarga completada exitosamente",
|
||||
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||
"created": "Receta creada exitosamente",
|
||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||
"noPreviousRecipe": "No hay receta anterior disponible",
|
||||
"noNextRecipe": "No hay siguiente receta disponible",
|
||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
"preparingForDownloadFailed": "Error preparando LoRAs para descarga",
|
||||
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
|
||||
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración.",
|
||||
"workflowSent": "Workflow enviado a ComfyUI",
|
||||
"workflowSendFailed": "Error al enviar el workflow a ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "No se encontró ningún workflow integrado en esta receta"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No hay modelos seleccionados",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "Error: {message}",
|
||||
"linkHfSuccess": "Modelo vinculado a HuggingFace exitosamente",
|
||||
"linkHfFailed": "Error: {message}",
|
||||
"linkCivArchSuccess": "Modelo re-vinculado exitosamente mediante CivitArchive",
|
||||
"fetchMetadataFirst": "Por favor obtén metadatos de CivitAI primero",
|
||||
"noCivitaiInfo": "No hay información de CivitAI disponible",
|
||||
"missingHash": "Hash del modelo no disponible"
|
||||
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "Nom du modèle",
|
||||
"tags": "Tags",
|
||||
"creator": "Créateur",
|
||||
"hash": "Hash",
|
||||
"title": "Titre de la recipe",
|
||||
"loraName": "Nom de fichier LoRA",
|
||||
"loraModel": "Nom du modèle LoRA",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "N'importe quel",
|
||||
"all": "Tous",
|
||||
"tagLogicAny": "Correspondre à n'importe quel tag (OU)",
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)"
|
||||
"tagLogicAll": "Correspondre à tous les tags (ET)",
|
||||
"loraAvailability": "Disponibilité des LoRAs",
|
||||
"availabilityReady": "Prêts à l'emploi",
|
||||
"availabilityMissing": "Avec LoRAs manquants",
|
||||
"availabilityDeleted": "Avec LoRAs supprimés"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Basculer le thème",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "Seulement les mises à jour en accès anticipé"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "Masquer les mises à jour payantes",
|
||||
"help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI"
|
||||
"sendCheckpoint": "Envoyer vers ComfyUI",
|
||||
"sendRecipe": "Envoyer vers ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Supprimer la recette (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Navigation des recettes",
|
||||
"previousWithShortcut": "Recette précédente (←)",
|
||||
"nextWithShortcut": "Recette suivante (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
|
||||
"sent": "Workflow envoyé vers ComfyUI",
|
||||
"sendFailed": "Échec de l'envoi du workflow vers ComfyUI",
|
||||
"noWorkflow": "Aucun workflow intégré trouvé dans cette recette"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Importer",
|
||||
"title": "Importer une recipe depuis une image ou une URL",
|
||||
"urlLocalPath": "URL / Chemin local",
|
||||
"uploadImage": "Téléverser une image",
|
||||
"urlSectionDescription": "Saisissez une URL d'image Civitai ou un chemin de fichier local pour l'importer comme recipe.",
|
||||
"dropZoneLabel": "Téléverser une image",
|
||||
"dropZoneHint": "Glissez-déposez une image ici, collez-la depuis le presse-papiers ou cliquez pour parcourir",
|
||||
"orDivider": "ou glissez-déposez / collez une image",
|
||||
"imageUrlOrPath": "URL d'image ou chemin de fichier :",
|
||||
"urlPlaceholder": "https://civitai.com/images/... ou C:/chemin/vers/image.png",
|
||||
"fetchImage": "Récupérer l'image",
|
||||
"uploadSectionDescription": "Téléversez une image avec des métadonnées LoRA pour l'importer comme recipe.",
|
||||
"selectImage": "Sélectionner une image",
|
||||
"recipeName": "Nom de la recipe",
|
||||
"recipeNamePlaceholder": "Entrez le nom de la recipe",
|
||||
"tagsOptional": "Tags (optionnel)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "Veuillez sélectionner un fichier image",
|
||||
"enterUrlOrPath": "Veuillez entrer une URL ou un chemin de fichier",
|
||||
"invalidUrl": "Veuillez saisir une URL valide",
|
||||
"invalidInputFormat": "Veuillez saisir l'URL d'une image ou un chemin de fichier local",
|
||||
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "Recherche de recettes en doublon...",
|
||||
"found": "Trouvé {count} groupes de doublons",
|
||||
"noGroups": "Aucun groupe de doublons trouvé avec le critère de correspondance actuel",
|
||||
"keepLatest": "Garder les dernières versions",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "Téléchargé",
|
||||
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
|
||||
"alreadyInLibrary": "Déjà dans la bibliothèque",
|
||||
"partiallyDownloaded": "Téléchargé partiellement",
|
||||
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
|
||||
"fileSelection": {
|
||||
"title": "Choisir le format de fichier",
|
||||
"files": "fichiers",
|
||||
"select": "Choisir le fichier"
|
||||
"select": "Choisir le fichier",
|
||||
"inLibrary": "Dans la bibliothèque"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Format d'URL Civitai invalide",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "Ne procédez que si vous êtes sûr que c'est ce que vous voulez.",
|
||||
"urlLabel": "URL du modèle Civitai :",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "Collez n'importe quelle URL de modèle Civitai. Formats supportés :",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "Note : Si aucun modelVersionId n'est fourni, la dernière version sera utilisée."
|
||||
"title": "Collez n'importe quelle URL de modèle Civitai ou CivitArchive. Formats supportés :",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "Note : Si aucun modelVersionId n'est fourni, la dernière version sera utilisée.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "Confirmer la re-liaison"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "Voir le profil du créateur",
|
||||
"openFileLocation": "Ouvrir l'emplacement du fichier",
|
||||
"sendToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI"
|
||||
"sendToWorkflowText": "Envoyer vers ComfyUI",
|
||||
"copyHash": "Copier le hash",
|
||||
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Emplacement du fichier ouvert avec succès",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "Emplacement",
|
||||
"baseModel": "Modèle de base",
|
||||
"size": "Taille",
|
||||
"hashes": "Hashes",
|
||||
"unknown": "Inconnu",
|
||||
"usageTips": "Conseils d'utilisation",
|
||||
"additionalNotes": "Notes supplémentaires",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "Chargement des exemples...",
|
||||
"versions": "Chargement des versions..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} masqué(s) par le paramètre « Contenu SFW uniquement »",
|
||||
"showExamples": "Afficher les exemples",
|
||||
"showCount": "Afficher les exemples ({count})",
|
||||
"hideExamples": "Masquer les exemples",
|
||||
"addExamples": "Ajouter des exemples",
|
||||
"previousExample": "Exemple précédent",
|
||||
"nextExample": "Exemple suivant",
|
||||
"noExamples": "Aucune image d'exemple disponible",
|
||||
"addMoreExamples": "Ajouter d'autres exemples",
|
||||
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
|
||||
"or": "ou",
|
||||
"selectFiles": "Sélectionner des fichiers",
|
||||
"supportedFormats": "Formats pris en charge : jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Importation des fichiers...",
|
||||
"noSupportedFiles": "Aucun fichier pris en charge sélectionné. Veuillez sélectionner des fichiers image ou vidéo.",
|
||||
"allFiltered": "Toutes les images d'exemple sont filtrées en raison des paramètres de contenu NSFW",
|
||||
"sfwOnlyEnabled": "Vos paramètres sont actuellement configurés pour n'afficher que du contenu tout public",
|
||||
"changeInSettings": "Vous pouvez modifier cela dans les paramètres",
|
||||
"nsfwMature": "Contenu pour adultes",
|
||||
"nsfwR": "Contenu classé R",
|
||||
"nsfwX": "Contenu classé X",
|
||||
"nsfwXxx": "Contenu classé XXX"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Versions du modèle",
|
||||
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||
"earlyAccess": "Accès anticipé",
|
||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "Payant",
|
||||
"paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
|
||||
"ignored": "Ignorée",
|
||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||
"onSiteOnly": "Uniquement sur Site",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "Télécharger",
|
||||
"downloadTooltip": "Télécharger cette version",
|
||||
"downloadChooseFilesTooltip": "Choisir les fichiers à télécharger",
|
||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "Télécharger cette version payante depuis Civitai",
|
||||
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||
"delete": "Supprimer",
|
||||
"deleteTooltip": "Supprimer cette version locale",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "Aucune cible de prompt compatible dans le workflow.\nFaites un clic droit sur un nœud dans ComfyUI → Marquer comme → Cible d'envoi du prompt",
|
||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés",
|
||||
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
|
||||
"pleaseSelectVersion": "Veuillez sélectionner une version",
|
||||
"pleaseSelectFile": "Veuillez sélectionner au moins un fichier",
|
||||
"versionExists": "Cette version existe déjà dans votre bibliothèque",
|
||||
"downloadCompleted": "Téléchargement terminé avec succès",
|
||||
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||
"created": "Recipe créé avec succès",
|
||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||
"noPreviousRecipe": "Aucune recette précédente",
|
||||
"noNextRecipe": "Aucune recette suivante",
|
||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
|
||||
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||
"reimportBulkFailed": "Échec du ré-import de certaines recettes",
|
||||
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
|
||||
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres.",
|
||||
"workflowSent": "Workflow envoyé vers ComfyUI",
|
||||
"workflowSendFailed": "Échec de l'envoi du workflow vers ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "Aucun workflow intégré trouvé dans cette recette"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Aucun modèle sélectionné",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "Erreur : {message}",
|
||||
"linkHfSuccess": "Modèle lié à HuggingFace avec succès",
|
||||
"linkHfFailed": "Erreur : {message}",
|
||||
"linkCivArchSuccess": "Modèle relié via CivitArchive avec succès",
|
||||
"fetchMetadataFirst": "Veuillez d'abord récupérer les métadonnées depuis CivitAI",
|
||||
"noCivitaiInfo": "Aucune information CivitAI disponible",
|
||||
"missingHash": "Hash du modèle non disponible"
|
||||
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "שם מודל",
|
||||
"tags": "תגיות",
|
||||
"creator": "יוצר",
|
||||
"hash": "האש",
|
||||
"title": "כותרת מתכון",
|
||||
"loraName": "שם קובץ LoRA",
|
||||
"loraModel": "שם מודל LoRA",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "כלשהו",
|
||||
"all": "כל התגים",
|
||||
"tagLogicAny": "התאם כל תג (או)",
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)"
|
||||
"tagLogicAll": "התאם את כל התגים (וגם)",
|
||||
"loraAvailability": "זמינות LoRA",
|
||||
"availabilityReady": "מוכנים לשימוש",
|
||||
"availabilityMissing": "עם LoRAs חסרים",
|
||||
"availabilityDeleted": "עם LoRAs שנמחקו"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "החלף ערכת נושא",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "רק עדכוני גישה מוקדמת"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "הסתר עדכונים בתשלום",
|
||||
"help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "מתכוני LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "שלח ל-ComfyUI"
|
||||
"sendCheckpoint": "שלח ל-ComfyUI",
|
||||
"sendRecipe": "שלח ל-ComfyUI",
|
||||
"deleteRecipeWithShortcut": "מחק מתכון (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "ניווט מתכונים",
|
||||
"previousWithShortcut": "המתכון הקודם (←)",
|
||||
"nextWithShortcut": "המתכון הבא (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "שלח workflow ל-ComfyUI",
|
||||
"sent": "ה-workflow נשלח ל-ComfyUI",
|
||||
"sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה",
|
||||
"noWorkflow": "לא נמצא workflow מוטבע במתכון זה"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "ייבא",
|
||||
"title": "ייבא מתכון מתמונה או כתובת URL",
|
||||
"urlLocalPath": "URL / נתיב מקומי",
|
||||
"uploadImage": "העלה תמונה",
|
||||
"urlSectionDescription": "הזן כתובת URL של תמונה מ-Civitai או נתיב קובץ מקומי לייבוא כמתכון.",
|
||||
"dropZoneLabel": "העלאת תמונה",
|
||||
"dropZoneHint": "גררו ושחררו תמונה כאן, הדביקו מהלוח או לחצו לעיון",
|
||||
"orDivider": "או גררו ושחררו / הדביקו תמונה",
|
||||
"imageUrlOrPath": "URL של תמונה או נתיב קובץ:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... או C:/path/to/image.png",
|
||||
"fetchImage": "אחזר תמונה",
|
||||
"uploadSectionDescription": "העלה תמונה עם מטא-דאטה של LoRA לייבוא כמתכון.",
|
||||
"selectImage": "בחר תמונה",
|
||||
"recipeName": "שם המתכון",
|
||||
"recipeNamePlaceholder": "הזן שם מתכון",
|
||||
"tagsOptional": "תגיות (אופציונלי)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "אנא בחר קובץ תמונה",
|
||||
"enterUrlOrPath": "אנא הזן URL או נתיב קובץ",
|
||||
"invalidUrl": "נא להזין כתובת URL תקינה",
|
||||
"invalidInputFormat": "נא להזין כתובת URL של תמונה או נתיב קובץ מקומי",
|
||||
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "סורק למציאת מתכונים כפולים...",
|
||||
"found": "נמצאו {count} קבוצות כפולות",
|
||||
"noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי",
|
||||
"keepLatest": "שמור גרסאות אחרונות",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "הורד",
|
||||
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
|
||||
"alreadyInLibrary": "כבר בספרייה",
|
||||
"partiallyDownloaded": "הורד חלקית",
|
||||
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
|
||||
"fileSelection": {
|
||||
"title": "בחר פורמט קובץ",
|
||||
"files": "קבצים",
|
||||
"select": "בחר קובץ"
|
||||
"select": "בחר קובץ",
|
||||
"inLibrary": "בספרייה"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "פורמט URL של Civitai לא חוקי",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "המשך רק אם אתה בטוח שזה מה שאתה רוצה.",
|
||||
"urlLabel": "כתובת URL של מודל ב-Civitai:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "הדבק כל כתובת URL של מודל מ-Civitai. פורמטים נתמכים:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "הערה: אם לא סופק modelVersionId, תילקח הגרסה האחרונה."
|
||||
"title": "הדבק כל כתובת URL של מודל מ-Civitai או מ-CivitArchive. פורמטים נתמכים:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "הערה: אם לא סופק modelVersionId, תילקח הגרסה האחרונה.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "אשר קישור מחדש"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "הצג פרופיל יוצר",
|
||||
"openFileLocation": "פתח מיקום קובץ",
|
||||
"sendToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI"
|
||||
"sendToWorkflowText": "שלח ל-ComfyUI",
|
||||
"copyHash": "העתק האש",
|
||||
"deleteModelWithShortcut": "מחק מודל (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "מיקום הקובץ נפתח בהצלחה",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "מיקום",
|
||||
"baseModel": "מודל בסיס",
|
||||
"size": "גודל",
|
||||
"hashes": "האשים",
|
||||
"unknown": "לא ידוע",
|
||||
"usageTips": "טיפים לשימוש",
|
||||
"additionalNotes": "הערות נוספות",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "טוען דוגמאות...",
|
||||
"versions": "טוען גרסאות..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} הוסתרו עקב הגדרת SFW בלבד",
|
||||
"showExamples": "הצג דוגמאות",
|
||||
"showCount": "הצג דוגמאות ({count})",
|
||||
"hideExamples": "הסתר דוגמאות",
|
||||
"addExamples": "הוסף דוגמאות",
|
||||
"previousExample": "דוגמה קודמת",
|
||||
"nextExample": "דוגמה הבאה",
|
||||
"noExamples": "אין תמונות דוגמה זמינות",
|
||||
"addMoreExamples": "הוסף עוד דוגמאות",
|
||||
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
|
||||
"or": "או",
|
||||
"selectFiles": "בחר קבצים",
|
||||
"supportedFormats": "פורמטים נתמכים: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "מייבא קבצים...",
|
||||
"noSupportedFiles": "לא נבחרו קבצים נתמכים. בחר קבצי תמונה או וידאו.",
|
||||
"allFiltered": "כל תמונות הדוגמה מסוננות עקב הגדרות תוכן NSFW",
|
||||
"sfwOnlyEnabled": "ההגדרות שלך מוגדרות כעת להציג רק תוכן SFW",
|
||||
"changeInSettings": "ניתן לשנות זאת בהגדרות",
|
||||
"nsfwMature": "תוכן למבוגרים",
|
||||
"nsfwR": "תוכן בדירוג R",
|
||||
"nsfwX": "תוכן בדירוג X",
|
||||
"nsfwXxx": "תוכן בדירוג XXX"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "גרסאות המודל",
|
||||
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||
"earlyAccess": "גישה מוקדמת",
|
||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "בתשלום",
|
||||
"paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
|
||||
"ignored": "התעלם",
|
||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||
"onSiteOnly": "רק באתר",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "הורדה",
|
||||
"downloadTooltip": "הורד את הגרסה הזו",
|
||||
"downloadChooseFilesTooltip": "בחר אילו קבצים להוריד",
|
||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "הורד את הגרסה בתשלום הזו מ-Civitai",
|
||||
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||
"delete": "מחיקה",
|
||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "אין יעדי הנחיה תואמים ב-workflow.\nלחץ לחיצה ימנית על צומת ב-ComfyUI → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||
"modelUpdated": "מודל עודכן ב-workflow",
|
||||
"modelFailed": "עדכון צומת המודל נכשל",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
|
||||
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
|
||||
"pleaseSelectVersion": "אנא בחר גרסה",
|
||||
"pleaseSelectFile": "אנא בחר לפחות קובץ אחד",
|
||||
"versionExists": "גרסה זו כבר קיימת בספרייה שלך",
|
||||
"downloadCompleted": "ההורדה הושלמה בהצלחה",
|
||||
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||
"created": "המתכון נוצר בהצלחה",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
|
||||
"enterLoraName": "אנא הזן שם LoRA או תחביר",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
|
||||
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות.",
|
||||
"workflowSent": "ה-workflow נשלח ל-ComfyUI",
|
||||
"workflowSendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה: {error}",
|
||||
"workflowNoWorkflow": "לא נמצא workflow מוטבע במתכון זה"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "לא נבחרו מודלים",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "שגיאה: {message}",
|
||||
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
|
||||
"linkHfFailed": "שגיאה: {message}",
|
||||
"linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה",
|
||||
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||
"missingHash": "ה-hash של המודל אינו זמין"
|
||||
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "モデル名",
|
||||
"tags": "タグ",
|
||||
"creator": "作成者",
|
||||
"hash": "ハッシュ",
|
||||
"title": "レシピタイトル",
|
||||
"loraName": "LoRAファイル名",
|
||||
"loraModel": "LoRAモデル名",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
"tagLogicAny": "いずれかのタグに一致 (OR)",
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)"
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)",
|
||||
"loraAvailability": "LoRA の利用状況",
|
||||
"availabilityReady": "使用可能",
|
||||
"availabilityMissing": "不足 LoRA あり",
|
||||
"availabilityDeleted": "削除済み LoRA あり"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマの切り替え",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "早期アクセスのみの更新"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "有料更新を非表示",
|
||||
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRAレシピ",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUIへ送信"
|
||||
"sendCheckpoint": "ComfyUIへ送信",
|
||||
"sendRecipe": "ComfyUIへ送信",
|
||||
"deleteRecipeWithShortcut": "レシピを削除(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "レシピナビゲーション",
|
||||
"previousWithShortcut": "前のレシピ(←)",
|
||||
"nextWithShortcut": "次のレシピ(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
||||
"sent": "ワークフローをComfyUIへ送信しました",
|
||||
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
|
||||
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "インポート",
|
||||
"title": "画像またはURLからレシピをインポート",
|
||||
"urlLocalPath": "URL / ローカルパス",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"urlSectionDescription": "Civitai画像URLまたはローカルファイルパスを入力してレシピとしてインポートします。",
|
||||
"dropZoneLabel": "画像をアップロード",
|
||||
"dropZoneHint": "画像をここにドラッグ&ドロップ、クリップボードから貼り付け、またはクリックして参照",
|
||||
"orDivider": "または画像をドラッグ&ドロップ / 貼り付け",
|
||||
"imageUrlOrPath": "画像URLまたはファイルパス:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... または C:/path/to/image.png",
|
||||
"fetchImage": "画像を取得",
|
||||
"uploadSectionDescription": "LoRAメタデータを含む画像をアップロードしてレシピとしてインポートします。",
|
||||
"selectImage": "画像を選択",
|
||||
"recipeName": "レシピ名",
|
||||
"recipeNamePlaceholder": "レシピ名を入力",
|
||||
"tagsOptional": "タグ(任意)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "画像ファイルを選択してください",
|
||||
"enterUrlOrPath": "URLまたはファイルパスを入力してください",
|
||||
"invalidUrl": "有効なURLを入力してください",
|
||||
"invalidInputFormat": "画像のURLまたはローカルの画像ファイルパスを入力してください",
|
||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "重複レシピをスキャンしています...",
|
||||
"found": "{count} 個の重複グループが見つかりました",
|
||||
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
|
||||
"keepLatest": "最新バージョンを保持",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||
"alreadyInLibrary": "既にライブラリ内",
|
||||
"partiallyDownloaded": "一部ダウンロード済み",
|
||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||
"fileSelection": {
|
||||
"title": "ファイル形式を選択",
|
||||
"files": "ファイル",
|
||||
"select": "ファイルを選択"
|
||||
"select": "ファイルを選択",
|
||||
"inLibrary": "ライブラリ内"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "無効なCivitai URL形式",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "これが本当に必要な場合のみ続行してください。",
|
||||
"urlLabel": "CivitaiモデルURL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "CivitaiモデルURLを貼り付けてください。対応形式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。"
|
||||
"title": "CivitaiまたはCivitArchiveのモデルURLを貼り付けてください。対応形式:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "再リンクを確認"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信"
|
||||
"sendToWorkflowText": "ComfyUI に送信",
|
||||
"copyHash": "ハッシュをコピー",
|
||||
"deleteModelWithShortcut": "モデルを削除(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "場所",
|
||||
"baseModel": "ベースモデル",
|
||||
"size": "サイズ",
|
||||
"hashes": "ハッシュ",
|
||||
"unknown": "不明",
|
||||
"usageTips": "使用のヒント",
|
||||
"additionalNotes": "追加メモ",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "例を読み込み中...",
|
||||
"versions": "バージョンを読み込み中..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "SFWのみ設定により{count}件非表示",
|
||||
"showExamples": "例を表示",
|
||||
"showCount": "例を表示({count})",
|
||||
"hideExamples": "例を非表示",
|
||||
"addExamples": "例を追加",
|
||||
"previousExample": "前の例",
|
||||
"nextExample": "次の例",
|
||||
"noExamples": "利用可能な例画像がありません",
|
||||
"addMoreExamples": "さらに例を追加",
|
||||
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||
"or": "または",
|
||||
"selectFiles": "ファイルを選択",
|
||||
"supportedFormats": "対応形式:jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "ファイルをインポート中...",
|
||||
"noSupportedFiles": "対応ファイルが選択されていません。画像または動画ファイルを選択してください。",
|
||||
"allFiltered": "NSFWコンテンツ設定により、すべての例画像がフィルタリングされています",
|
||||
"sfwOnlyEnabled": "現在の設定ではSFWコンテンツのみが表示されます",
|
||||
"changeInSettings": "設定から変更できます",
|
||||
"nsfwMature": "成人向けコンテンツ",
|
||||
"nsfwR": "R指定コンテンツ",
|
||||
"nsfwX": "X指定コンテンツ",
|
||||
"nsfwXxx": "XXX指定コンテンツ"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||
"earlyAccess": "早期アクセス",
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "有料",
|
||||
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||
"onSiteOnly": "サイト内のみ",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadChooseFilesTooltip": "ダウンロードするファイルを選択",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "Civitai からこの有料バージョンをダウンロード",
|
||||
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "ワークフロー内に互換性のあるプロンプトターゲットがありません。\nComfyUIでノードを右クリック → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
|
||||
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
|
||||
"pleaseSelectVersion": "バージョンを選択してください",
|
||||
"pleaseSelectFile": "ファイルを1つ以上選択してください",
|
||||
"versionExists": "このバージョンは既にライブラリに存在します",
|
||||
"downloadCompleted": "ダウンロードが正常に完了しました",
|
||||
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"noPreviousRecipe": "前のレシピがありません",
|
||||
"noNextRecipe": "次のレシピがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||
"enterLoraName": "LoRA名または構文を入力してください",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。",
|
||||
"workflowSent": "ワークフローをComfyUIへ送信しました",
|
||||
"workflowSendFailed": "ワークフローをComfyUIへ送信できませんでした: {error}",
|
||||
"workflowNoWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "モデルが選択されていません",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "エラー:{message}",
|
||||
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||
"linkHfFailed": "エラー:{message}",
|
||||
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "모델명",
|
||||
"tags": "태그",
|
||||
"creator": "제작자",
|
||||
"hash": "해시",
|
||||
"title": "레시피 제목",
|
||||
"loraName": "LoRA 파일명",
|
||||
"loraModel": "LoRA 모델명",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
"tagLogicAny": "모든 태그 일치 (OR)",
|
||||
"tagLogicAll": "모든 태그 일치 (AND)"
|
||||
"tagLogicAll": "모든 태그 일치 (AND)",
|
||||
"loraAvailability": "LoRA 가용성",
|
||||
"availabilityReady": "바로 사용 가능",
|
||||
"availabilityMissing": "누락된 LoRA 있음",
|
||||
"availabilityDeleted": "삭제된 LoRA 있음"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 토글",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "유료 업데이트 숨기기",
|
||||
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 레시피",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUI로 보내기"
|
||||
"sendCheckpoint": "ComfyUI로 보내기",
|
||||
"sendRecipe": "ComfyUI로 보내기",
|
||||
"deleteRecipeWithShortcut": "레시피 삭제(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "레시피 탐색",
|
||||
"previousWithShortcut": "이전 레시피(←)",
|
||||
"nextWithShortcut": "다음 레시피(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
||||
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
|
||||
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "가져오기",
|
||||
"title": "이미지 또는 URL에서 레시피 가져오기",
|
||||
"urlLocalPath": "URL / 로컬 경로",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"urlSectionDescription": "Civitai 이미지 URL 또는 로컬 파일 경로를 입력하여 레시피로 가져옵니다.",
|
||||
"dropZoneLabel": "이미지 업로드",
|
||||
"dropZoneHint": "이미지를 여기에 끌어다 놓거나, 클립보드에서 붙여넣거나, 클릭하여 찾아보세요",
|
||||
"orDivider": "또는 이미지를 끌어다 놓기 / 붙여넣기",
|
||||
"imageUrlOrPath": "이미지 URL 또는 파일 경로:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 또는 C:/path/to/image.png",
|
||||
"fetchImage": "이미지 가져오기",
|
||||
"uploadSectionDescription": "LoRA 메타데이터가 포함된 이미지를 업로드하여 레시피로 가져옵니다.",
|
||||
"selectImage": "이미지 선택",
|
||||
"recipeName": "레시피 이름",
|
||||
"recipeNamePlaceholder": "레시피 이름을 입력하세요",
|
||||
"tagsOptional": "태그 (선택사항)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "이미지 파일을 선택해주세요",
|
||||
"enterUrlOrPath": "URL 또는 파일 경로를 입력해주세요",
|
||||
"invalidUrl": "유효한 URL을 입력하세요",
|
||||
"invalidInputFormat": "이미지 URL 또는 로컬 이미지 파일 경로를 입력하세요",
|
||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "중복 레시피를 스캔하는 중...",
|
||||
"found": "{count}개의 중복 그룹 발견",
|
||||
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
|
||||
"keepLatest": "최신 버전 유지",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||
"partiallyDownloaded": "부분적으로 다운로드됨",
|
||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||
"fileSelection": {
|
||||
"title": "파일 형식 선택",
|
||||
"files": "개 파일",
|
||||
"select": "파일 선택"
|
||||
"select": "파일 선택",
|
||||
"inLibrary": "라이브러리에 있음"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "원하는 작업이 확실한 경우에만 진행하세요.",
|
||||
"urlLabel": "Civitai 모델 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "모든 Civitai 모델 URL을 붙여넣으세요. 지원되는 형식:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "참고: modelVersionId가 제공되지 않으면 최신 버전이 사용됩니다."
|
||||
"title": "Civitai 또는 CivitArchive 모델 URL을 붙여넣으세요. 지원되는 형식:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "참고: modelVersionId가 제공되지 않으면 최신 버전이 사용됩니다.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "다시 연결 확인"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기"
|
||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||
"copyHash": "해시 복사",
|
||||
"deleteModelWithShortcut": "모델 삭제(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "위치",
|
||||
"baseModel": "베이스 모델",
|
||||
"size": "크기",
|
||||
"hashes": "해시",
|
||||
"unknown": "알 수 없음",
|
||||
"usageTips": "사용 팁",
|
||||
"additionalNotes": "추가 메모",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "예시 로딩 중...",
|
||||
"versions": "버전 로딩 중..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "SFW 전용 설정으로 {count}개 숨겨짐",
|
||||
"showExamples": "예시 보기",
|
||||
"showCount": "예시 보기 ({count})",
|
||||
"hideExamples": "예시 숨기기",
|
||||
"addExamples": "예시 추가",
|
||||
"previousExample": "이전 예시",
|
||||
"nextExample": "다음 예시",
|
||||
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||
"addMoreExamples": "예시 더 추가",
|
||||
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||
"or": "또는",
|
||||
"selectFiles": "파일 선택",
|
||||
"supportedFormats": "지원되는 형식: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "파일을 가져오는 중...",
|
||||
"noSupportedFiles": "지원되는 파일이 선택되지 않았습니다. 이미지 또는 비디오 파일을 선택하세요.",
|
||||
"allFiltered": "NSFW 콘텐츠 설정으로 인해 모든 예시 이미지가 필터링되었습니다",
|
||||
"sfwOnlyEnabled": "현재 설정이 안전한(SFW) 콘텐츠만 표시하도록 설정되어 있습니다",
|
||||
"changeInSettings": "설정에서 변경할 수 있습니다",
|
||||
"nsfwMature": "성인 콘텐츠",
|
||||
"nsfwR": "R등급 콘텐츠",
|
||||
"nsfwX": "X등급 콘텐츠",
|
||||
"nsfwXxx": "XXX등급 콘텐츠"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "유료",
|
||||
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||
"onSiteOnly": "사이트 내 전용",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadChooseFilesTooltip": "다운로드할 파일 선택",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "Civitai에서 이 유료 버전 다운로드",
|
||||
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "워크플로우에 호환되는 프롬프트 타겟이 없습니다.\nComfyUI에서 노드를 우클릭 → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
|
||||
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
|
||||
"pleaseSelectVersion": "버전을 선택해주세요",
|
||||
"pleaseSelectFile": "파일을 하나 이상 선택해주세요",
|
||||
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
|
||||
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
|
||||
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||
"noNextRecipe": "다음 레시피가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요.",
|
||||
"workflowSent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"workflowSendFailed": "워크플로를 ComfyUI로 보내지 못했습니다: {error}",
|
||||
"workflowNoWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "선택된 모델이 없습니다",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "오류: {message}",
|
||||
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||
"linkHfFailed": "오류: {message}",
|
||||
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
|
||||
+79
-22
@@ -222,6 +222,7 @@
|
||||
"modelname": "Название модели",
|
||||
"tags": "Теги",
|
||||
"creator": "Автор",
|
||||
"hash": "Хэш",
|
||||
"title": "Название рецепта",
|
||||
"loraName": "Имя файла LoRA",
|
||||
"loraModel": "Название модели LoRA",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "Любой",
|
||||
"all": "Все",
|
||||
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)"
|
||||
"tagLogicAll": "Совпадение со всеми тегами (И)",
|
||||
"loraAvailability": "Доступность LoRAs",
|
||||
"availabilityReady": "Готовы к использованию",
|
||||
"availabilityMissing": "Есть отсутствующие",
|
||||
"availabilityDeleted": "Есть удалённые"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Переключить тему",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "Только обновления раннего доступа"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "Скрывать платные обновления",
|
||||
"help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "Рецепты LoRA",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Отправить в ComfyUI"
|
||||
"sendCheckpoint": "Отправить в ComfyUI",
|
||||
"sendRecipe": "Отправить в ComfyUI",
|
||||
"deleteRecipeWithShortcut": "Удалить рецепт (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Навигация по рецептам",
|
||||
"previousWithShortcut": "Предыдущий рецепт (←)",
|
||||
"nextWithShortcut": "Следующий рецепт (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Отправить workflow в ComfyUI",
|
||||
"sent": "Workflow отправлен в ComfyUI",
|
||||
"sendFailed": "Не удалось отправить workflow в ComfyUI",
|
||||
"noWorkflow": "В этом рецепте не найден встроенный workflow"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Импортировать",
|
||||
"title": "Импортировать рецепт из изображения или URL",
|
||||
"urlLocalPath": "URL / Локальный путь",
|
||||
"uploadImage": "Загрузить изображение",
|
||||
"urlSectionDescription": "Введите URL изображения Civitai или локальный путь к файлу для импорта в качестве рецепта.",
|
||||
"dropZoneLabel": "Загрузить изображение",
|
||||
"dropZoneHint": "Перетащите изображение сюда, вставьте из буфера обмена или нажмите для выбора",
|
||||
"orDivider": "или перетащите / вставьте изображение",
|
||||
"imageUrlOrPath": "URL изображения или путь к файлу:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... или C:/path/to/image.png",
|
||||
"fetchImage": "Получить изображение",
|
||||
"uploadSectionDescription": "Загрузите изображение с метаданными LoRA для импорта в качестве рецепта.",
|
||||
"selectImage": "Выбрать изображение",
|
||||
"recipeName": "Название рецепта",
|
||||
"recipeNamePlaceholder": "Введите название рецепта",
|
||||
"tagsOptional": "Теги (необязательно)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "Пожалуйста, выберите файл изображения",
|
||||
"enterUrlOrPath": "Пожалуйста, введите URL или путь к файлу",
|
||||
"invalidUrl": "Введите корректный URL",
|
||||
"invalidInputFormat": "Введите URL изображения или путь к локальному файлу изображения",
|
||||
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "Поиск дублирующихся рецептов...",
|
||||
"found": "Найдено {count} групп дубликатов",
|
||||
"noGroups": "Дубликатов с текущим критерием не найдено",
|
||||
"keepLatest": "Оставить последние версии",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "Загружено",
|
||||
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
|
||||
"alreadyInLibrary": "Уже в библиотеке",
|
||||
"partiallyDownloaded": "Загружено частично",
|
||||
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
|
||||
"fileSelection": {
|
||||
"title": "Выбрать формат файла",
|
||||
"files": "файлов",
|
||||
"select": "Выбрать файл"
|
||||
"select": "Выбрать файл",
|
||||
"inLibrary": "В библиотеке"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Неверный формат URL Civitai",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "Продолжайте только если вы уверены, что это то, что вам нужно.",
|
||||
"urlLabel": "URL модели Civitai:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "Вставьте любой URL модели Civitai. Поддерживаемые форматы:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "Примечание: Если modelVersionId не указан, будет использована последняя версия."
|
||||
"title": "Вставьте любой URL модели Civitai или CivitArchive. Поддерживаемые форматы:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "Примечание: Если modelVersionId не указан, будет использована последняя версия.",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "Подтвердить пересвязывание"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "Посмотреть профиль создателя",
|
||||
"openFileLocation": "Открыть расположение файла",
|
||||
"sendToWorkflow": "Отправить в ComfyUI",
|
||||
"sendToWorkflowText": "Отправить в ComfyUI"
|
||||
"sendToWorkflowText": "Отправить в ComfyUI",
|
||||
"copyHash": "Копировать хэш",
|
||||
"deleteModelWithShortcut": "Удалить модель (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "Расположение файла успешно открыто",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "Расположение",
|
||||
"baseModel": "Базовая модель",
|
||||
"size": "Размер",
|
||||
"hashes": "Хэши",
|
||||
"unknown": "Неизвестно",
|
||||
"usageTips": "Советы по использованию",
|
||||
"additionalNotes": "Дополнительные заметки",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "Загрузка примеров...",
|
||||
"versions": "Загрузка версий..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} скрыто настройкой «только SFW»",
|
||||
"showExamples": "Показать примеры",
|
||||
"showCount": "Показать примеры ({count})",
|
||||
"hideExamples": "Скрыть примеры",
|
||||
"addExamples": "Добавить примеры",
|
||||
"previousExample": "Предыдущий пример",
|
||||
"nextExample": "Следующий пример",
|
||||
"noExamples": "Примеры изображений недоступны",
|
||||
"addMoreExamples": "Добавить ещё примеры",
|
||||
"dragDrop": "Перетащите изображения или видео сюда",
|
||||
"or": "или",
|
||||
"selectFiles": "Выбрать файлы",
|
||||
"supportedFormats": "Поддерживаемые форматы: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Импорт файлов...",
|
||||
"noSupportedFiles": "Не выбрано поддерживаемых файлов. Пожалуйста, выберите файлы изображений или видео.",
|
||||
"allFiltered": "Все примеры изображений отфильтрованы из-за настроек NSFW-контента",
|
||||
"sfwOnlyEnabled": "В настройках сейчас включён показ только безопасного для работы (SFW) контента",
|
||||
"changeInSettings": "Вы можете изменить это в Настройках",
|
||||
"nsfwMature": "Контент для взрослых",
|
||||
"nsfwR": "Контент с рейтингом R",
|
||||
"nsfwX": "Контент с рейтингом X",
|
||||
"nsfwXxx": "Контент с рейтингом XXX"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Версии модели",
|
||||
"copy": "Управляйте всеми версиями этой модели в одном месте.",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||
"earlyAccess": "Ранний доступ",
|
||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "Платная",
|
||||
"paidTooltip": "Скачивание этой версии платное",
|
||||
"ignored": "Игнорируется",
|
||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||
"onSiteOnly": "Только на Сайте",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "Скачать",
|
||||
"downloadTooltip": "Скачать эту версию",
|
||||
"downloadChooseFilesTooltip": "Выбрать файлы для скачивания",
|
||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "Скачать эту платную версию с Civitai",
|
||||
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||
"delete": "Удалить",
|
||||
"deleteTooltip": "Удалить эту локальную версию",
|
||||
@@ -1740,7 +1790,7 @@
|
||||
"recipeReplaced": "Рецепт заменён в workflow",
|
||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "В рабочем процессе нет совместимых целей для промпта.\nЩёлкните правой кнопкой мыши по узлу в ComfyUI → Отметить как → Send Prompt Target",
|
||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||
"modelUpdated": "Модель обновлена в workflow",
|
||||
"modelFailed": "Не удалось обновить узел модели",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
|
||||
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
|
||||
"pleaseSelectVersion": "Пожалуйста, выберите версию",
|
||||
"pleaseSelectFile": "Пожалуйста, выберите хотя бы один файл",
|
||||
"versionExists": "Эта версия уже существует в вашей библиотеке",
|
||||
"downloadCompleted": "Загрузка успешно завершена",
|
||||
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||
"created": "Рецепт успешно создан",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
|
||||
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
|
||||
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках.",
|
||||
"workflowSent": "Workflow отправлен в ComfyUI",
|
||||
"workflowSendFailed": "Не удалось отправить workflow в ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "В этом рецепте не найден встроенный workflow"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "Модели не выбраны",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "Ошибка: {message}",
|
||||
"linkHfSuccess": "Модель успешно связана с HuggingFace",
|
||||
"linkHfFailed": "Ошибка: {message}",
|
||||
"linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive",
|
||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||
"missingHash": "Хеш модели недоступен"
|
||||
|
||||
+78
-21
@@ -222,6 +222,7 @@
|
||||
"modelname": "模型名称",
|
||||
"tags": "标签",
|
||||
"creator": "创作者",
|
||||
"hash": "哈希",
|
||||
"title": "配方标题",
|
||||
"loraName": "LoRA 文件名",
|
||||
"loraModel": "LoRA 模型名称",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "匹配任一标签 (或)",
|
||||
"tagLogicAll": "匹配所有标签 (与)"
|
||||
"tagLogicAll": "匹配所有标签 (与)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺失 LoRA",
|
||||
"availabilityDeleted": "包含已删除 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "抢先体验更新"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "隐藏付费更新",
|
||||
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "发送到 ComfyUI"
|
||||
"sendCheckpoint": "发送到 ComfyUI",
|
||||
"sendRecipe": "发送到 ComfyUI",
|
||||
"deleteRecipeWithShortcut": "删除配方(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "配方导航",
|
||||
"previousWithShortcut": "上一个配方(←)",
|
||||
"nextWithShortcut": "下一个配方(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "发送工作流到 ComfyUI",
|
||||
"sent": "工作流已发送到 ComfyUI",
|
||||
"sendFailed": "发送工作流到 ComfyUI 失败",
|
||||
"noWorkflow": "此配方中未找到内嵌工作流"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "导入",
|
||||
"title": "从图片或 URL 导入配方",
|
||||
"urlLocalPath": "URL / 本地路径",
|
||||
"uploadImage": "上传图片",
|
||||
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
|
||||
"dropZoneLabel": "上传图片",
|
||||
"dropZoneHint": "将图片拖拽到此处、从剪贴板粘贴,或点击浏览",
|
||||
"orDivider": "或拖拽 / 粘贴图片",
|
||||
"imageUrlOrPath": "图片 URL 或文件路径:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "获取图片",
|
||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||
"selectImage": "选择图片",
|
||||
"recipeName": "配方名称",
|
||||
"recipeNamePlaceholder": "输入配方名称",
|
||||
"tagsOptional": "标签(可选)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "请选择一个图像文件",
|
||||
"enterUrlOrPath": "请输入 URL 或文件路径",
|
||||
"invalidUrl": "请输入有效的 URL",
|
||||
"invalidInputFormat": "请输入图片 URL 或本地图片文件路径",
|
||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "正在扫描重复配方...",
|
||||
"found": "发现 {count} 个重复组",
|
||||
"noGroups": "按当前判重依据未找到重复组",
|
||||
"keepLatest": "保留最新版本",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||
"alreadyInLibrary": "已存在于库中",
|
||||
"partiallyDownloaded": "部分已下载",
|
||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||
"fileSelection": {
|
||||
"title": "选择文件格式",
|
||||
"files": "个文件",
|
||||
"select": "选择文件"
|
||||
"select": "选择文件",
|
||||
"inLibrary": "已在库中"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "无效的 Civitai URL 格式",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "仅在你确定需要此操作时继续。",
|
||||
"urlLabel": "Civitai 模型 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "注意:如果未提供 modelVersionId,将使用最新版本。"
|
||||
"title": "粘贴任意 Civitai 或 CivitArchive 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "注意:如果未提供 modelVersionId,将使用最新版本。",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "确认重新关联"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI"
|
||||
"sendToWorkflowText": "发送到 ComfyUI",
|
||||
"copyHash": "复制哈希值",
|
||||
"deleteModelWithShortcut": "删除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "位置",
|
||||
"baseModel": "基础模型",
|
||||
"size": "大小",
|
||||
"hashes": "哈希值",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加备注",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "正在加载示例...",
|
||||
"versions": "正在加载版本..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} 张因仅显示 SFW 设置而被隐藏",
|
||||
"showExamples": "显示示例",
|
||||
"showCount": "显示示例({count})",
|
||||
"hideExamples": "隐藏示例",
|
||||
"addExamples": "添加示例",
|
||||
"previousExample": "上一个示例",
|
||||
"nextExample": "下一个示例",
|
||||
"noExamples": "暂无示例图片",
|
||||
"addMoreExamples": "添加更多示例",
|
||||
"dragDrop": "将图片或视频拖放到此处",
|
||||
"or": "或",
|
||||
"selectFiles": "选择文件",
|
||||
"supportedFormats": "支持的格式:jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "正在导入文件...",
|
||||
"noSupportedFiles": "未选择受支持的文件。请选择图片或视频文件。",
|
||||
"allFiltered": "所有示例图片均因 NSFW 内容设置而被过滤",
|
||||
"sfwOnlyEnabled": "你当前的设置为仅显示 SFW 内容",
|
||||
"changeInSettings": "你可以在设置中更改此选项",
|
||||
"nsfwMature": "成熟内容",
|
||||
"nsfwR": "R 级内容",
|
||||
"nsfwX": "X 级内容",
|
||||
"nsfwXxx": "XXX 级内容"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在一个位置管理该模型的所有版本。",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "抢先体验",
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "付费",
|
||||
"paidTooltip": "此版本需要付费后才能下载",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知",
|
||||
"onSiteOnly": "仅站内生成",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadChooseFilesTooltip": "选择要下载的文件",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "从 Civitai 下载此付费版本",
|
||||
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
|
||||
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
|
||||
"pleaseSelectVersion": "请选择版本",
|
||||
"pleaseSelectFile": "请至少选择一个文件",
|
||||
"versionExists": "该版本已存在于你的库中",
|
||||
"downloadCompleted": "下载成功完成",
|
||||
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"noPreviousRecipe": "没有上一个配方",
|
||||
"noNextRecipe": "没有下一个配方",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||
"enterLoraName": "请输入 LoRA 名称或语法",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。",
|
||||
"workflowSent": "工作流已发送到 ComfyUI",
|
||||
"workflowSendFailed": "发送工作流到 ComfyUI 失败: {error}",
|
||||
"workflowNoWorkflow": "此配方中未找到内嵌工作流"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未选中模型",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "错误:{message}",
|
||||
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||
"linkHfFailed": "错误:{message}",
|
||||
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
|
||||
+78
-21
@@ -222,6 +222,7 @@
|
||||
"modelname": "模型名稱",
|
||||
"tags": "標籤",
|
||||
"creator": "創作者",
|
||||
"hash": "雜湊",
|
||||
"title": "配方標題",
|
||||
"loraName": "LoRA 檔案名稱",
|
||||
"loraModel": "LoRA 模型名稱",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "符合任一票籤 (或)",
|
||||
"tagLogicAll": "符合所有標籤 (與)"
|
||||
"tagLogicAll": "符合所有標籤 (與)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺少的 LoRA",
|
||||
"availabilityDeleted": "包含已刪除的 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切換主題",
|
||||
@@ -623,8 +628,8 @@
|
||||
"help": "搶先體驗更新"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "隱藏付費更新",
|
||||
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
@@ -853,20 +858,31 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "傳送到 ComfyUI"
|
||||
"sendCheckpoint": "傳送到 ComfyUI",
|
||||
"sendRecipe": "傳送到 ComfyUI",
|
||||
"deleteRecipeWithShortcut": "刪除配方(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "配方導覽",
|
||||
"previousWithShortcut": "上一個配方(←)",
|
||||
"nextWithShortcut": "下一個配方(→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "傳送工作流到 ComfyUI",
|
||||
"sent": "工作流已傳送到 ComfyUI",
|
||||
"sendFailed": "傳送工作流到 ComfyUI 失敗",
|
||||
"noWorkflow": "此配方中未找到內嵌工作流"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "匯入",
|
||||
"title": "從圖片或網址匯入配方",
|
||||
"urlLocalPath": "網址 / 本機路徑",
|
||||
"uploadImage": "上傳圖片",
|
||||
"urlSectionDescription": "輸入 Civitai 圖片網址或本機檔案路徑以匯入配方。",
|
||||
"dropZoneLabel": "上傳圖片",
|
||||
"dropZoneHint": "將圖片拖曳至此處、從剪貼簿貼上,或點擊瀏覽",
|
||||
"orDivider": "或拖曳 / 貼上圖片",
|
||||
"imageUrlOrPath": "圖片網址或檔案路徑:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "取得圖片",
|
||||
"uploadSectionDescription": "上傳含 LoRA metadata 的圖片以匯入配方。",
|
||||
"selectImage": "選擇圖片",
|
||||
"recipeName": "配方名稱",
|
||||
"recipeNamePlaceholder": "輸入配方名稱",
|
||||
"tagsOptional": "標籤(選填)",
|
||||
@@ -911,6 +927,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "請選擇圖片檔案",
|
||||
"enterUrlOrPath": "請輸入網址或檔案路徑",
|
||||
"invalidUrl": "請輸入有效的 URL",
|
||||
"invalidInputFormat": "請輸入圖片 URL 或本機圖片檔案路徑",
|
||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "正在掃描重複配方...",
|
||||
"found": "發現 {count} 組重複項",
|
||||
"noGroups": "按目前判重依據未找到重複組",
|
||||
"keepLatest": "保留最新版本",
|
||||
@@ -1243,11 +1262,13 @@
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||
"alreadyInLibrary": "已在庫存",
|
||||
"partiallyDownloaded": "部分已下載",
|
||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||
"fileSelection": {
|
||||
"title": "選擇檔案格式",
|
||||
"files": "個檔案",
|
||||
"select": "選擇檔案"
|
||||
"select": "選擇檔案",
|
||||
"inLibrary": "已在庫中"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Civitai 網址格式無效",
|
||||
@@ -1401,13 +1422,14 @@
|
||||
},
|
||||
"proceedText": "僅在確定需要執行時才繼續。",
|
||||
"urlLabel": "Civitai 模型網址:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "貼上任意 Civitai 模型網址。支援格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "注意:若未提供 modelVersionId,將使用最新版本。"
|
||||
"title": "貼上任意 Civitai 或 CivitArchive 模型網址。支援格式:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "注意:若未提供 modelVersionId,將使用最新版本。",
|
||||
"format4": "https://civarchive.com/models/12345 (CivitArchive)"
|
||||
},
|
||||
"confirmAction": "確認重新連結"
|
||||
},
|
||||
@@ -1424,7 +1446,9 @@
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI"
|
||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||
"copyHash": "複製雜湊值",
|
||||
"deleteModelWithShortcut": "刪除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
@@ -1441,6 +1465,7 @@
|
||||
"location": "位置",
|
||||
"baseModel": "基礎模型",
|
||||
"size": "大小",
|
||||
"hashes": "雜湊值",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加備註",
|
||||
@@ -1532,6 +1557,30 @@
|
||||
"examples": "載入範例中...",
|
||||
"versions": "載入版本中..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "因僅顯示 SFW 設定而隱藏 {count} 張",
|
||||
"showExamples": "顯示範例",
|
||||
"showCount": "顯示範例({count})",
|
||||
"hideExamples": "隱藏範例",
|
||||
"addExamples": "新增範例",
|
||||
"previousExample": "上一個範例",
|
||||
"nextExample": "下一個範例",
|
||||
"noExamples": "沒有可用的範例圖片",
|
||||
"addMoreExamples": "新增更多範例",
|
||||
"dragDrop": "拖放圖片或影片到此處",
|
||||
"or": "或",
|
||||
"selectFiles": "選擇檔案",
|
||||
"supportedFormats": "支援的格式:jpg、png、gif、webp、avif、jxl、mp4、webm",
|
||||
"importing": "正在匯入檔案...",
|
||||
"noSupportedFiles": "未選擇支援的檔案。請選擇圖片或影片檔案。",
|
||||
"allFiltered": "所有範例圖片都因 NSFW 內容設定而被過濾",
|
||||
"sfwOnlyEnabled": "你目前的設定為僅顯示安全(SFW)內容",
|
||||
"changeInSettings": "你可以在設定中變更此選項",
|
||||
"nsfwMature": "成熟內容",
|
||||
"nsfwR": "R 級內容",
|
||||
"nsfwX": "X 級內容",
|
||||
"nsfwXxx": "XXX 級內容"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||
@@ -1559,8 +1608,8 @@
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "搶先體驗",
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"paid": "付費",
|
||||
"paidTooltip": "此版本需要付費才能下載",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知",
|
||||
"onSiteOnly": "僅站內生成",
|
||||
@@ -1569,8 +1618,9 @@
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadChooseFilesTooltip": "選擇要下載的檔案",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadPaidTooltip": "從 Civitai 下載此付費版本",
|
||||
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
@@ -1917,6 +1967,7 @@
|
||||
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
|
||||
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
|
||||
"pleaseSelectVersion": "請選擇一個版本",
|
||||
"pleaseSelectFile": "請至少選擇一個檔案",
|
||||
"versionExists": "此版本已存在於您的庫中",
|
||||
"downloadCompleted": "下載成功完成",
|
||||
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
|
||||
@@ -1950,6 +2001,8 @@
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"noPreviousRecipe": "沒有上一個配方",
|
||||
"noNextRecipe": "沒有下一個配方",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
||||
@@ -2002,7 +2055,10 @@
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。",
|
||||
"workflowSent": "工作流已傳送到 ComfyUI",
|
||||
"workflowSendFailed": "傳送工作流到 ComfyUI 失敗: {error}",
|
||||
"workflowNoWorkflow": "此配方中未找到內嵌工作流"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未選擇模型",
|
||||
@@ -2170,6 +2226,7 @@
|
||||
"relinkFailed": "錯誤:{message}",
|
||||
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||
"linkHfFailed": "錯誤:{message}",
|
||||
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
|
||||
@@ -46,6 +46,16 @@ async def api_json_error(
|
||||
if request.path.startswith("/api/lm/previews") and exc.status == 404:
|
||||
logger_method = logger.debug
|
||||
|
||||
# Download-progress 404 is routine too: in-memory tracking is removed
|
||||
# once a download finishes/fails, so the extension's final polls 404.
|
||||
# The extension relies on the 404 status itself (failure detection),
|
||||
# so only the log level is lowered.
|
||||
if (
|
||||
request.path.startswith("/api/lm/download-progress/")
|
||||
and exc.status == 404
|
||||
):
|
||||
logger_method = logger.debug
|
||||
|
||||
logger_method(
|
||||
"API %s %s returned HTTP %d: %s",
|
||||
request.method,
|
||||
|
||||
@@ -13,6 +13,10 @@ class CheckpointLoaderLM:
|
||||
|
||||
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
||||
extra folder paths, providing a unified interface for checkpoint loading.
|
||||
The ckpt_name combo supports ComfyUI's control_after_generate, letting
|
||||
users pick a random checkpoint on every run; the base_model input narrows
|
||||
the random pool through a front-end extension that filters the combo
|
||||
options.
|
||||
"""
|
||||
|
||||
NAME = "Checkpoint Loader (LoraManager)"
|
||||
@@ -22,11 +26,29 @@ class CheckpointLoaderLM:
|
||||
def INPUT_TYPES(cls):
|
||||
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||
checkpoint_names = cls._get_checkpoint_names()
|
||||
base_models = cls._get_available_base_models()
|
||||
return {
|
||||
"required": {
|
||||
"ckpt_name": (
|
||||
checkpoint_names,
|
||||
{"tooltip": "The name of the checkpoint (model) to load."},
|
||||
{
|
||||
"tooltip": (
|
||||
"The name of the checkpoint (model) to load. Use "
|
||||
"control_after_generate to pick a random model on "
|
||||
"every run."
|
||||
),
|
||||
"control_after_generate": "fixed",
|
||||
},
|
||||
),
|
||||
"base_model": (
|
||||
base_models,
|
||||
{
|
||||
"default": "Any",
|
||||
"tooltip": (
|
||||
"Restrict the random selection pool to this base "
|
||||
"model. 'Any' uses the full pool."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -93,15 +115,68 @@ class CheckpointLoaderLM:
|
||||
logger.error(f"Error getting checkpoint names: {e}")
|
||||
return []
|
||||
|
||||
def load_checkpoint(self, ckpt_name: str) -> Tuple[Any, Any, Any]:
|
||||
@classmethod
|
||||
def _get_available_base_models(cls) -> List[str]:
|
||||
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_base_models():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "checkpoint":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
file_path = item.get("file_path", "")
|
||||
if base_model and file_path and os.path.exists(file_path):
|
||||
base_models.add(base_model)
|
||||
|
||||
return sorted(base_models)
|
||||
|
||||
return ["Any"] + cls._run_async(_get_base_models)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available base models: {e}")
|
||||
return ["Any"]
|
||||
|
||||
@staticmethod
|
||||
def _run_async(coro_fn):
|
||||
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro_fn())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_fn())
|
||||
|
||||
def load_checkpoint(
|
||||
self, ckpt_name: str, base_model: str = "Any"
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""Load a checkpoint by name, supporting extra folder paths
|
||||
|
||||
Args:
|
||||
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
||||
base_model: Only used by the front-end to filter the random pool
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL, CLIP, VAE)
|
||||
"""
|
||||
del base_model
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class CreateHookLoraLM:
|
||||
),
|
||||
},
|
||||
),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -52,7 +53,7 @@ class CreateHookLoraLM:
|
||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||
FUNCTION = "create_hook"
|
||||
|
||||
def create_hook(self, text: str, **kwargs):
|
||||
def create_hook(self, text: str, loras, **kwargs):
|
||||
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
|
||||
|
||||
Each active LoRA from the widget is loaded and wrapped in a WeightHook
|
||||
@@ -73,7 +74,7 @@ class CreateHookLoraLM:
|
||||
all_trigger_words: list[str] = []
|
||||
active_loras: list[tuple[str, float, float]] = []
|
||||
|
||||
for lora in get_loras_list(kwargs):
|
||||
for lora in get_loras_list({"loras": loras}):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@ def _collect_stack_entries(lora_stack):
|
||||
return entries
|
||||
|
||||
|
||||
def _collect_widget_entries(kwargs):
|
||||
def _collect_widget_entries(loras):
|
||||
entries = []
|
||||
for lora in get_loras_list(kwargs):
|
||||
for lora in get_loras_list({"loras": loras}):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
@@ -139,6 +139,7 @@ class LoraLoaderLM:
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -152,12 +153,12 @@ class LoraLoaderLM:
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
def load_loras(self, model, text, **kwargs):
|
||||
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
||||
def load_loras(self, model, text, loras, **kwargs):
|
||||
"""Loads multiple LoRAs based on the widget input and lora_stack."""
|
||||
del text
|
||||
clip = kwargs.get("clip", None)
|
||||
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
|
||||
lora_entries.extend(_collect_widget_entries(kwargs))
|
||||
lora_entries.extend(_collect_widget_entries(loras))
|
||||
|
||||
nunchaku_model_kind = detect_nunchaku_model_kind(model)
|
||||
if nunchaku_model_kind == "flux":
|
||||
|
||||
@@ -18,6 +18,7 @@ class LoraStackerLM:
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -31,8 +32,8 @@ class LoraStackerLM:
|
||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||
FUNCTION = "stack_loras"
|
||||
|
||||
def stack_loras(self, text, **kwargs):
|
||||
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
|
||||
def stack_loras(self, text, loras, **kwargs):
|
||||
"""Stacks multiple LoRAs based on the widget input without loading them."""
|
||||
stack = []
|
||||
active_loras = []
|
||||
all_trigger_words = []
|
||||
@@ -47,8 +48,8 @@ class LoraStackerLM:
|
||||
_, trigger_words = get_lora_info(lora_name)
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Process loras from kwargs with support for both old and new formats
|
||||
loras_list = get_loras_list(kwargs)
|
||||
# Process loras from the widget with support for both old and new formats
|
||||
loras_list = get_loras_list({"loras": loras})
|
||||
for lora in loras_list:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
@@ -778,6 +778,14 @@ class SaveImageLM:
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
|
||||
# The recipe image is the WebP produced above from the output file;
|
||||
# reuse the same metadata extraction to record workflow presence.
|
||||
try:
|
||||
metadata = ExifUtils._load_structured_metadata(image_path)
|
||||
recipe_data["has_workflow"] = bool(metadata.get("workflow"))
|
||||
except Exception:
|
||||
recipe_data["has_workflow"] = False
|
||||
|
||||
json_path = os.path.normpath(
|
||||
os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
)
|
||||
|
||||
+77
-2
@@ -28,6 +28,10 @@ class UNETLoaderLM:
|
||||
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
||||
extra folder paths, providing a unified interface for UNET loading.
|
||||
Supports both regular diffusion models and GGUF format models.
|
||||
The unet_name combo supports ComfyUI's control_after_generate, letting
|
||||
users pick a random diffusion model on every run; the base_model input
|
||||
narrows the random pool through a front-end extension that filters the
|
||||
combo options.
|
||||
"""
|
||||
|
||||
NAME = "Unet Loader (LoraManager)"
|
||||
@@ -37,16 +41,34 @@ class UNETLoaderLM:
|
||||
def INPUT_TYPES(cls):
|
||||
# Get list of unet names from scanner (includes extra folder paths)
|
||||
unet_names = cls._get_unet_names()
|
||||
base_models = cls._get_available_base_models()
|
||||
return {
|
||||
"required": {
|
||||
"unet_name": (
|
||||
unet_names,
|
||||
{"tooltip": "The name of the diffusion model to load."},
|
||||
{
|
||||
"tooltip": (
|
||||
"The name of the diffusion model to load. Use "
|
||||
"control_after_generate to pick a random model on "
|
||||
"every run."
|
||||
),
|
||||
"control_after_generate": "fixed",
|
||||
},
|
||||
),
|
||||
"weight_dtype": (
|
||||
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
||||
{"tooltip": "The dtype to use for the model weights."},
|
||||
),
|
||||
"base_model": (
|
||||
base_models,
|
||||
{
|
||||
"default": "Any",
|
||||
"tooltip": (
|
||||
"Restrict the random selection pool to this base "
|
||||
"model. 'Any' uses the full pool."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,16 +130,69 @@ class UNETLoaderLM:
|
||||
logger.error(f"Error getting unet names: {e}")
|
||||
return []
|
||||
|
||||
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple[Any, ...]:
|
||||
@classmethod
|
||||
def _get_available_base_models(cls) -> List[str]:
|
||||
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_base_models():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "diffusion_model":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
file_path = item.get("file_path", "")
|
||||
if base_model and file_path and os.path.exists(file_path):
|
||||
base_models.add(base_model)
|
||||
|
||||
return sorted(base_models)
|
||||
|
||||
return ["Any"] + cls._run_async(_get_base_models)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available base models: {e}")
|
||||
return ["Any"]
|
||||
|
||||
@staticmethod
|
||||
def _run_async(coro_fn):
|
||||
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro_fn())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_fn())
|
||||
|
||||
def load_unet(
|
||||
self, unet_name: str, weight_dtype: str, base_model: str = "Any"
|
||||
) -> Tuple[Any, ...]:
|
||||
"""Load a diffusion model by name, supporting extra folder paths
|
||||
|
||||
Args:
|
||||
unet_name: The name of the diffusion model to load (relative path with extension)
|
||||
weight_dtype: The dtype to use for model weights
|
||||
base_model: Only used by the front-end to filter the random pool
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL,)
|
||||
"""
|
||||
del base_model
|
||||
import torch
|
||||
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
|
||||
@@ -31,6 +31,7 @@ class WanVideoLoraSelectLM:
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -44,7 +45,7 @@ class WanVideoLoraSelectLM:
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
FUNCTION = "process_loras"
|
||||
|
||||
def process_loras(self, text, low_mem_load=False, merge_loras=True, **kwargs):
|
||||
def process_loras(self, text, loras, low_mem_load=False, merge_loras=True, **kwargs):
|
||||
loras_list = []
|
||||
all_trigger_words = []
|
||||
active_loras = []
|
||||
@@ -62,8 +63,8 @@ class WanVideoLoraSelectLM:
|
||||
selected_blocks = blocks.get("selected_blocks", {})
|
||||
layer_filter = blocks.get("layer_filter", "")
|
||||
|
||||
# Process loras from kwargs with support for both old and new formats
|
||||
loras_from_widget = get_loras_list(kwargs)
|
||||
# Process loras from the widget with support for both old and new formats
|
||||
loras_from_widget = get_loras_list({"loras": loras})
|
||||
for lora in loras_from_widget:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
@@ -41,6 +41,40 @@ class RecipeMetadataParser(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def populate_lora_from_local(lora_entry: Dict[str, Any], local_lora: Dict[str, Any], base_model_counts=None) -> Dict[str, Any]:
|
||||
"""Populate a recipe LoRA entry from the local scanner cache."""
|
||||
local_path = local_lora.get('file_path') or ''
|
||||
file_name = local_lora.get('file_name') or os.path.splitext(os.path.basename(local_path))[0]
|
||||
base_model = local_lora.get('base_model') or ''
|
||||
|
||||
lora_entry['name'] = local_lora.get('model_name') or file_name or lora_entry.get('name', '')
|
||||
lora_entry['file_name'] = file_name
|
||||
lora_entry['hash'] = (local_lora.get('sha256') or lora_entry.get('hash') or '').lower()
|
||||
lora_entry['localPath'] = local_path or None
|
||||
lora_entry['size'] = local_lora.get('size', 0) or 0
|
||||
lora_entry['baseModel'] = base_model
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['isDeleted'] = False
|
||||
|
||||
preview_url = local_lora.get('preview_url')
|
||||
if preview_url:
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
|
||||
|
||||
civitai_info = local_lora.get('civitai') or {}
|
||||
if isinstance(civitai_info, dict):
|
||||
if civitai_info.get('id') is not None:
|
||||
lora_entry['id'] = civitai_info['id']
|
||||
if civitai_info.get('modelId') is not None:
|
||||
lora_entry['modelId'] = civitai_info['modelId']
|
||||
if civitai_info.get('name'):
|
||||
lora_entry['version'] = civitai_info['name']
|
||||
|
||||
if base_model_counts is not None and base_model:
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
|
||||
return lora_entry
|
||||
|
||||
@staticmethod
|
||||
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
|
||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+201
-61
@@ -362,68 +362,208 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
||||
if not loras or len(loras) == 0:
|
||||
# Extract lora weights from extranet tags in prompt (for later use)
|
||||
lora_weights = {}
|
||||
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt)
|
||||
for lora_type, lora_name, lora_weight in lora_matches:
|
||||
key = f"{lora_type}:{lora_name}"
|
||||
lora_weights[key] = round(float(lora_weight), 2)
|
||||
|
||||
# Use hashes from metadata as the primary source
|
||||
if metadata.get("hashes"):
|
||||
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
def normalize_lora_name(name, basename=False):
|
||||
normalized = str(name or '').replace('\\', '/')
|
||||
if normalized.casefold().endswith('.safetensors'):
|
||||
normalized = normalized[:-12]
|
||||
if basename:
|
||||
normalized = normalized.rsplit('/', 1)[-1]
|
||||
return normalized.casefold()
|
||||
|
||||
def get_version_id(lora):
|
||||
version_id = lora.get('id')
|
||||
if version_id in (None, '', 0, '0'):
|
||||
version_id = lora.get('modelVersionId')
|
||||
if version_id in (None, '', 0, '0'):
|
||||
return None
|
||||
return str(version_id)
|
||||
|
||||
prompt_loras = {}
|
||||
for match in re.findall(self.EXTRANETS_REGEX, prompt):
|
||||
lora_type, lora_name, _ = match
|
||||
prompt_loras[(lora_type, normalize_lora_name(lora_name))] = match
|
||||
|
||||
prompt_by_basename = {}
|
||||
for lora_type, lora_name, lora_weight in prompt_loras.values():
|
||||
key = (lora_type, normalize_lora_name(lora_name, True))
|
||||
prompt_by_basename.setdefault(key, []).append((lora_name, round(float(lora_weight), 2)))
|
||||
|
||||
hash_basenames = {
|
||||
(hash_key.split(':', 1)[0], normalize_lora_name(hash_key.split(':', 1)[1], True))
|
||||
for hash_key, hash_value in metadata.get("hashes", {}).items()
|
||||
if hash_value and hash_key.startswith(("lora:", "hypernet:"))
|
||||
}
|
||||
recipe_base_model = checkpoint.get("baseModel") if checkpoint else None
|
||||
if not recipe_base_model and len(base_model_counts) == 1:
|
||||
recipe_base_model = next(iter(base_model_counts))
|
||||
|
||||
resource_lora_count = len(loras)
|
||||
|
||||
def make_lora_entry(lora_type, lora_name, weight, lora_hash=''):
|
||||
return {
|
||||
'name': lora_name,
|
||||
'type': lora_type,
|
||||
'weight': weight,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
def merge_or_append_civitai(civitai_entry, preserve_existing_weight=False):
|
||||
civitai_id = get_version_id(civitai_entry)
|
||||
civitai_hash = (civitai_entry.get('hash') or '').lower()
|
||||
for index, existing in enumerate(loras):
|
||||
existing_id = get_version_id(existing)
|
||||
existing_hash = (existing.get('hash') or '').lower()
|
||||
if not (
|
||||
(civitai_id and existing_id == civitai_id)
|
||||
or (civitai_hash and existing_hash == civitai_hash)
|
||||
):
|
||||
continue
|
||||
|
||||
if preserve_existing_weight:
|
||||
civitai_entry['weight'] = existing.get('weight', civitai_entry['weight'])
|
||||
existing_base = existing.get('baseModel')
|
||||
if not civitai_entry.get('baseModel'):
|
||||
civitai_entry['baseModel'] = existing_base or ''
|
||||
elif existing_base:
|
||||
remaining = base_model_counts.get(existing_base, 0) - 1
|
||||
if remaining > 0:
|
||||
base_model_counts[existing_base] = remaining
|
||||
else:
|
||||
base_model_counts.pop(existing_base, None)
|
||||
loras[index] = civitai_entry
|
||||
return
|
||||
loras.append(civitai_entry)
|
||||
|
||||
def merge_or_append_local(local_entry):
|
||||
local_id = get_version_id(local_entry)
|
||||
local_hash = (local_entry.get('hash') or '').lower()
|
||||
for existing in loras:
|
||||
existing_id = get_version_id(existing)
|
||||
existing_hash = (existing.get('hash') or '').lower()
|
||||
if not (
|
||||
(local_id and existing_id == local_id)
|
||||
or (local_hash and existing_hash == local_hash)
|
||||
):
|
||||
continue
|
||||
|
||||
existing['weight'] = local_entry['weight']
|
||||
existing['hash'] = local_entry['hash']
|
||||
existing['file_name'] = local_entry['file_name']
|
||||
existing['existsLocally'] = True
|
||||
existing['localPath'] = local_entry['localPath']
|
||||
existing['size'] = local_entry['size']
|
||||
existing['isDeleted'] = False
|
||||
if not existing.get('modelId') and local_entry.get('modelId'):
|
||||
existing['modelId'] = local_entry['modelId']
|
||||
if not existing.get('baseModel') and local_entry.get('baseModel'):
|
||||
existing['baseModel'] = local_entry['baseModel']
|
||||
base_model_counts[local_entry['baseModel']] = base_model_counts.get(local_entry['baseModel'], 0) + 1
|
||||
thumbnail_url = local_entry.get('thumbnailUrl')
|
||||
if thumbnail_url and not thumbnail_url.endswith('/images/no-preview.png'):
|
||||
existing['thumbnailUrl'] = thumbnail_url
|
||||
return
|
||||
|
||||
if local_entry.get('baseModel'):
|
||||
base_model = local_entry['baseModel']
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
loras.append(local_entry)
|
||||
|
||||
resolved_prompt_basenames = set()
|
||||
queried_local_basenames = set()
|
||||
for lora_type, lora_name, lora_weight in prompt_loras.values():
|
||||
weight = round(float(lora_weight), 2)
|
||||
basename_key = (lora_type, normalize_lora_name(lora_name, True))
|
||||
matching_resources = [
|
||||
lora
|
||||
for lora in loras[:resource_lora_count]
|
||||
if lora.get('file_name')
|
||||
and normalize_lora_name(lora['file_name'], True) == basename_key[1]
|
||||
and (
|
||||
(lora_type == 'hypernet' and str(lora.get('type', '')).casefold() in ('hypernet', 'hypernetwork'))
|
||||
or (lora_type == 'lora' and str(lora.get('type', '')).casefold() not in ('hypernet', 'hypernetwork'))
|
||||
)
|
||||
]
|
||||
if len(prompt_by_basename[basename_key]) == 1 and len(matching_resources) == 1:
|
||||
matching_resources[0]['weight'] = weight
|
||||
if basename_key not in hash_basenames:
|
||||
resolved_prompt_basenames.add(basename_key)
|
||||
continue
|
||||
|
||||
if basename_key in hash_basenames:
|
||||
continue
|
||||
|
||||
if not recipe_scanner or lora_type != 'lora':
|
||||
continue
|
||||
queried_local_basenames.add(basename_key)
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if not local_lora:
|
||||
continue
|
||||
|
||||
local_entry = self.populate_lora_from_local(
|
||||
make_lora_entry(lora_type, lora_name, weight),
|
||||
local_lora,
|
||||
)
|
||||
merge_or_append_local(local_entry)
|
||||
resolved_prompt_basenames.add(basename_key)
|
||||
|
||||
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
basename_key = (lora_type, normalize_lora_name(lora_name, True))
|
||||
if basename_key in resolved_prompt_basenames:
|
||||
continue
|
||||
|
||||
prompt_entries = prompt_by_basename.get(basename_key, [])
|
||||
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
|
||||
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
|
||||
|
||||
if lora_hash and recipe_scanner and lora_type == 'lora':
|
||||
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
|
||||
if local_lora:
|
||||
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
merge_or_append_local(local_entry)
|
||||
continue
|
||||
|
||||
hash_resolved = False
|
||||
if lora_hash and metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash,
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue
|
||||
|
||||
# Skip entries without a hash value — they can't be
|
||||
# resolved via CivitAI and would only produce a
|
||||
# useless "Deleted" entry in the recipe.
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
# Get weight from extranet tags if available, else default to 1.0
|
||||
weight = lora_weights.get(hash_key, 1.0)
|
||||
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': lora_type, # 'lora' or 'hypernet'
|
||||
'weight': weight,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
|
||||
|
||||
loras.append(lora_entry)
|
||||
lora_entry = populated_entry
|
||||
hash_resolved = not lora_entry.get('isDeleted')
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
|
||||
|
||||
if hash_resolved:
|
||||
merge_or_append_civitai(lora_entry, preserve_existing_weight=not prompt_entries)
|
||||
continue
|
||||
|
||||
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if local_lora:
|
||||
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
merge_or_append_local(local_entry)
|
||||
continue
|
||||
|
||||
if lora_hash and not resource_lora_count:
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Try to get base model from resources or make educated guess
|
||||
base_model = None
|
||||
|
||||
+95
-68
@@ -31,79 +31,15 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
|
||||
data = json.loads(user_comment)
|
||||
loras = []
|
||||
|
||||
# Find all LoraLoader nodes
|
||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||
|
||||
# Process each LoraLoader node
|
||||
for node_id, node in lora_nodes.items():
|
||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||
continue
|
||||
|
||||
lora_name = node['inputs'].get('lora_name', '')
|
||||
|
||||
# Parse the URN to extract model ID and version ID
|
||||
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
|
||||
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
|
||||
if not lora_id_match:
|
||||
continue
|
||||
|
||||
model_id = lora_id_match.group(1)
|
||||
model_version_id = lora_id_match.group(2)
|
||||
|
||||
# Get strength from node inputs
|
||||
weight = node['inputs'].get('strength_model', 1.0)
|
||||
|
||||
# Initialize lora entry with default values
|
||||
lora_entry = {
|
||||
'id': model_version_id,
|
||||
'modelId': model_id,
|
||||
'name': f"Lora {model_id}", # Default name
|
||||
'version': '',
|
||||
'type': 'lora',
|
||||
'weight': weight,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': '',
|
||||
'hash': '',
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Get additional info from Civitai if metadata provider is available
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Find checkpoint info
|
||||
|
||||
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
|
||||
checkpoint = None
|
||||
checkpoint_id = None
|
||||
checkpoint_version_id = None
|
||||
|
||||
if checkpoint_nodes:
|
||||
# Get the first checkpoint node
|
||||
checkpoint_node = next(iter(checkpoint_nodes.values()))
|
||||
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
|
||||
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
|
||||
# Parse checkpoint URN
|
||||
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
|
||||
if checkpoint_match:
|
||||
checkpoint_id = checkpoint_match.group(1)
|
||||
@@ -115,16 +51,107 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
||||
'version': '',
|
||||
'type': 'checkpoint'
|
||||
}
|
||||
|
||||
# Get additional checkpoint info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
|
||||
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||
# Populate checkpoint with Civitai info
|
||||
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
|
||||
|
||||
recipe_base_model = checkpoint.get('baseModel') if checkpoint else None
|
||||
loras = []
|
||||
lora_candidates = []
|
||||
for node in data.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
inputs = node.get('inputs')
|
||||
if not isinstance(inputs, dict):
|
||||
continue
|
||||
|
||||
if node.get('class_type') == 'LoraLoader':
|
||||
lora_name = inputs.get('lora_name', '')
|
||||
if isinstance(lora_name, str) and lora_name:
|
||||
lora_candidates.append((lora_name, inputs.get('strength_model', 1.0)))
|
||||
continue
|
||||
|
||||
if node.get('class_type') != 'LoraLoaderLM':
|
||||
continue
|
||||
|
||||
loras_data = inputs.get('loras', [])
|
||||
if isinstance(loras_data, dict):
|
||||
loras_data = loras_data.get('__value__', [])
|
||||
if isinstance(loras_data, list) and len(loras_data) == 1 and isinstance(loras_data[0], list):
|
||||
loras_data = loras_data[0]
|
||||
if not isinstance(loras_data, list):
|
||||
continue
|
||||
|
||||
for lora in loras_data:
|
||||
if not isinstance(lora, dict) or not lora.get('active', False) or lora.get('_isDummy', False):
|
||||
continue
|
||||
lora_name = lora.get('name', '')
|
||||
if isinstance(lora_name, str) and lora_name:
|
||||
lora_candidates.append((lora_name, lora.get('strength', 1.0)))
|
||||
|
||||
for lora_name, weight in lora_candidates:
|
||||
if isinstance(weight, str):
|
||||
try:
|
||||
weight = float(weight)
|
||||
except ValueError:
|
||||
weight = 1.0
|
||||
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
|
||||
if lora_id_match:
|
||||
model_id = lora_id_match.group(1)
|
||||
model_version_id = lora_id_match.group(2)
|
||||
entry_name = f"Lora {model_id}"
|
||||
else:
|
||||
model_id = 0
|
||||
model_version_id = 0
|
||||
entry_name = re.split(r'[\\/]', lora_name)[-1]
|
||||
entry_name = re.sub(r'\.[^.]+$', '', entry_name)
|
||||
|
||||
lora_entry = {
|
||||
'id': model_version_id,
|
||||
'modelId': model_id,
|
||||
'name': entry_name,
|
||||
'version': '',
|
||||
'type': 'lora',
|
||||
'weight': weight,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': entry_name,
|
||||
'hash': '',
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
if lora_id_match:
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
else:
|
||||
if not recipe_scanner:
|
||||
continue
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if not local_lora:
|
||||
continue
|
||||
lora_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Extract generation parameters
|
||||
gen_params = {}
|
||||
|
||||
@@ -32,6 +32,7 @@ from .handlers.recipe_handlers import (
|
||||
RecipePageView,
|
||||
RecipeQueryHandler,
|
||||
RecipeSharingHandler,
|
||||
RecipeWorkflowHandler,
|
||||
)
|
||||
from .recipe_route_registrar import ROUTE_DEFINITIONS
|
||||
|
||||
@@ -200,6 +201,18 @@ class BaseRecipeRoutes:
|
||||
sharing_service=sharing_service,
|
||||
)
|
||||
|
||||
# Lazy import: standalone mode replaces the ``server`` module with a
|
||||
# mock, so resolve PromptServer at handler-set build time instead of
|
||||
# module import time. The handler's standalone check guards UX.
|
||||
from server import PromptServer # pyright: ignore[reportMissingImports]
|
||||
|
||||
workflow = RecipeWorkflowHandler(
|
||||
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||
recipe_scanner_getter=recipe_scanner_getter,
|
||||
prompt_server=PromptServer,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
batch_import_service = BatchImportService(
|
||||
@@ -224,4 +237,5 @@ class BaseRecipeRoutes:
|
||||
analysis=analysis,
|
||||
sharing=sharing,
|
||||
batch_import=batch_import,
|
||||
workflow=workflow,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Set
|
||||
from aiohttp import web
|
||||
|
||||
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
|
||||
from ..services.checkpoint_service import CheckpointService
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
from ..utils.utils import _format_model_name_for_comfyui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,7 +46,45 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
# Checkpoint roots and Unet roots
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
||||
|
||||
# Name/base_model pool for the Random Checkpoint/Unet Loader nodes
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
|
||||
|
||||
async def get_loader_pool(self, request: web.Request) -> web.Response:
|
||||
"""Return ComfyUI-formatted model names with their base_model.
|
||||
|
||||
Backing data for the Random Checkpoint/Unet Loader nodes: the front-end
|
||||
filters the ckpt_name/unet_name combo options by base_model using this
|
||||
pool, so control_after_generate randomizes within the narrowed set.
|
||||
"""
|
||||
try:
|
||||
sub_type = request.query.get("sub_type", "checkpoint")
|
||||
if sub_type not in ("checkpoint", "diffusion_model"):
|
||||
return web.json_response({"error": "invalid sub_type"}, status=400)
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
model_roots = scanner.get_model_roots()
|
||||
items: List[Dict[str, str]] = []
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != sub_type:
|
||||
continue
|
||||
file_path = item.get("file_path", "")
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
continue
|
||||
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||
if formatted_name:
|
||||
items.append(
|
||||
{
|
||||
"name": formatted_name,
|
||||
"base_model": item.get("base_model", "") or "",
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda x: x["name"])
|
||||
return web.json_response({"items": items})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting loader pool: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||
"""Validate CivitAI model type for Checkpoint"""
|
||||
return model_type.lower() == 'checkpoint'
|
||||
|
||||
@@ -56,6 +56,7 @@ from ...utils.constants import (
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
from .model_handlers import ModelCivitaiHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import (
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
@@ -648,9 +649,60 @@ class NodeRegistry:
|
||||
|
||||
|
||||
class HealthCheckHandler:
|
||||
def __init__(
|
||||
self,
|
||||
scanner_getters: Mapping[str, Callable[[], Awaitable[Any]]] | None = None,
|
||||
) -> None:
|
||||
self._scanner_getters = scanner_getters or {
|
||||
"lora": ServiceRegistry.get_lora_scanner,
|
||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||
}
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
async def get_init_status(self, request: web.Request) -> web.Response:
|
||||
"""Report aggregate scanner initialization status.
|
||||
|
||||
Used by the initialization page's polling fallback when the
|
||||
/ws/init-progress WebSocket is unavailable. Omits pageType so every
|
||||
page accepts the update and only reloads once all scanners are done.
|
||||
"""
|
||||
pending: list[str] = []
|
||||
for name, getter in self._scanner_getters.items():
|
||||
try:
|
||||
scanner = await getter()
|
||||
except Exception:
|
||||
pending.append(name)
|
||||
continue
|
||||
cache_ready = getattr(scanner, "_cache", None) is not None
|
||||
is_initializing = getattr(scanner, "is_initializing", None)
|
||||
busy = (
|
||||
is_initializing()
|
||||
if callable(is_initializing)
|
||||
else bool(getattr(scanner, "_is_initializing", False))
|
||||
)
|
||||
if busy or not cache_ready:
|
||||
pending.append(name)
|
||||
|
||||
if pending:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "initializing",
|
||||
"stage": "processing",
|
||||
"details": "Initializing: " + ", ".join(pending),
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "complete",
|
||||
"progress": 100,
|
||||
"details": "Initialization complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SupportersHandler:
|
||||
"""Handler for supporters data."""
|
||||
@@ -2061,6 +2113,63 @@ class ModelLibraryHandler:
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
@staticmethod
|
||||
async def _get_downloaded_files(
|
||||
scanner: Any, model_version_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return per-file downloaded state for a version in the library.
|
||||
|
||||
This handler has no CivitAI version payload, so the remote file list
|
||||
is taken from the local entries' cached ``civitai`` metadata (the
|
||||
full version payload persisted at download time, see
|
||||
``BaseModelMetadata.from_civitai_info``) and matched with the same
|
||||
D2 rule used by ``get_civitai_versions`` (#1058). Local entries that
|
||||
cannot be matched to a known remote file (e.g. missing metadata or
|
||||
renamed files) are still reported with ``fileId`` set to None.
|
||||
Returns ``[{fileId, fileName, filePath}]``.
|
||||
"""
|
||||
try:
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
logger.debug(
|
||||
"Failed to read cache for downloaded files of version %s",
|
||||
model_version_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
local_entries = files_getter(model_version_id) if files_getter else []
|
||||
if not local_entries:
|
||||
return []
|
||||
|
||||
version_payload: Mapping[str, Any] = {}
|
||||
for entry in local_entries:
|
||||
civitai = entry.get("civitai") if isinstance(entry, Mapping) else None
|
||||
if isinstance(civitai, Mapping) and isinstance(civitai.get("files"), list):
|
||||
version_payload = civitai
|
||||
break
|
||||
|
||||
downloaded = ModelCivitaiHandler._match_downloaded_files(
|
||||
version_payload, local_entries
|
||||
)
|
||||
|
||||
# Surface local files that D2 could not map to a known remote file
|
||||
matched_paths = {item.get("filePath") for item in downloaded}
|
||||
for entry in local_entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
if entry.get("file_path") in matched_paths:
|
||||
continue
|
||||
downloaded.append(
|
||||
{
|
||||
"fileId": None,
|
||||
"fileName": entry.get("file_name"),
|
||||
"filePath": entry.get("file_path"),
|
||||
}
|
||||
)
|
||||
return downloaded
|
||||
|
||||
async def check_model_exists(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_id_str = request.query.get("modelId")
|
||||
@@ -2096,9 +2205,11 @@ class ModelLibraryHandler:
|
||||
|
||||
exists = False
|
||||
model_type = None
|
||||
matched_scanner = None
|
||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||
exists = True
|
||||
model_type = "lora"
|
||||
matched_scanner = lora_scanner
|
||||
elif (
|
||||
checkpoint_scanner
|
||||
and await checkpoint_scanner.check_model_version_exists(
|
||||
@@ -2107,6 +2218,7 @@ class ModelLibraryHandler:
|
||||
):
|
||||
exists = True
|
||||
model_type = "checkpoint"
|
||||
matched_scanner = checkpoint_scanner
|
||||
elif (
|
||||
embedding_scanner
|
||||
and await embedding_scanner.check_model_version_exists(
|
||||
@@ -2115,6 +2227,7 @@ class ModelLibraryHandler:
|
||||
):
|
||||
exists = True
|
||||
model_type = "embedding"
|
||||
matched_scanner = embedding_scanner
|
||||
|
||||
if exists:
|
||||
return web.json_response(
|
||||
@@ -2123,6 +2236,9 @@ class ModelLibraryHandler:
|
||||
"exists": True,
|
||||
"modelType": model_type,
|
||||
"hasBeenDownloaded": False,
|
||||
"downloadedFiles": await self._get_downloaded_files(
|
||||
matched_scanner, model_version_id
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2144,6 +2260,7 @@ class ModelLibraryHandler:
|
||||
"exists": False,
|
||||
"modelType": history_type,
|
||||
"hasBeenDownloaded": has_been_downloaded,
|
||||
"downloadedFiles": [],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2428,8 +2545,8 @@ class ModelLibraryHandler:
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
|
||||
found_type = None
|
||||
file_path = None
|
||||
found_cache = None
|
||||
entries: list = []
|
||||
|
||||
for model_type, scanner in (
|
||||
("lora", lora_scanner),
|
||||
@@ -2440,27 +2557,43 @@ class ModelLibraryHandler:
|
||||
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")
|
||||
# A version can have several local files (#1058); collect
|
||||
# them all so the delete below covers every file.
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if files_getter is not None:
|
||||
entries = files_getter(model_version_id)
|
||||
else:
|
||||
entries = [cache.version_index[model_version_id]]
|
||||
break
|
||||
|
||||
if not file_path:
|
||||
file_paths = [
|
||||
entry.get("file_path")
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and entry.get("file_path")
|
||||
]
|
||||
|
||||
if not file_paths:
|
||||
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)
|
||||
for file_path in file_paths:
|
||||
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:
|
||||
removed_paths = set(file_paths)
|
||||
found_cache.raw_data = [
|
||||
item
|
||||
for item in found_cache.raw_data
|
||||
if item.get("file_path") != file_path
|
||||
if item.get("file_path") not in removed_paths
|
||||
]
|
||||
rebuild = getattr(found_cache, "rebuild_version_index", None)
|
||||
if rebuild is not None:
|
||||
rebuild()
|
||||
await found_cache.resort()
|
||||
|
||||
scanner_map = {
|
||||
@@ -2483,6 +2616,7 @@ class ModelLibraryHandler:
|
||||
"success": True,
|
||||
"modelType": found_type,
|
||||
"modelVersionId": model_version_id,
|
||||
"deletedFiles": len(file_paths),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -3776,6 +3910,7 @@ class MiscHandlerSet:
|
||||
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
return {
|
||||
"health_check": self.health.health_check,
|
||||
"get_init_status": self.health.get_init_status,
|
||||
"get_settings": self.settings.get_settings,
|
||||
"update_settings": self.settings.update_settings,
|
||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||
|
||||
@@ -364,6 +364,7 @@ class ModelListingHandler:
|
||||
== "true",
|
||||
"tags": request.query.get("search_tags", "false").lower() == "true",
|
||||
"creator": request.query.get("search_creator", "false").lower() == "true",
|
||||
"hash": request.query.get("search_hash", "false").lower() == "true",
|
||||
"recursive": request.query.get("recursive", "true").lower() == "true",
|
||||
}
|
||||
|
||||
@@ -633,6 +634,16 @@ class ModelManagementHandler:
|
||||
file_path = data.get("file_path")
|
||||
model_id = data.get("model_id")
|
||||
model_version_id = data.get("model_version_id")
|
||||
source = data.get("source")
|
||||
|
||||
if source not in (None, "", "civarchive"):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Unsupported relink source: {source}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not file_path or model_id is None:
|
||||
return web.json_response(
|
||||
@@ -648,20 +659,33 @@ class ModelManagementHandler:
|
||||
metadata_path
|
||||
)
|
||||
|
||||
relink_kwargs = {
|
||||
"file_path": file_path,
|
||||
"metadata": local_metadata,
|
||||
"model_id": int(model_id),
|
||||
"model_version_id": int(model_version_id) if model_version_id else None,
|
||||
}
|
||||
if source == "civarchive":
|
||||
relink_kwargs["provider_name"] = "civarchive_api"
|
||||
|
||||
updated_metadata = await self._metadata_sync.relink_metadata(
|
||||
file_path=file_path,
|
||||
metadata=local_metadata,
|
||||
model_id=int(model_id),
|
||||
model_version_id=int(model_version_id) if model_version_id else None,
|
||||
**relink_kwargs
|
||||
)
|
||||
|
||||
await self._service.scanner.update_single_model_cache(
|
||||
file_path, file_path, updated_metadata
|
||||
)
|
||||
|
||||
message = f"Model successfully re-linked to Civitai model {model_id}" + (
|
||||
f" version {model_version_id}" if model_version_id else ""
|
||||
)
|
||||
if source == "civarchive":
|
||||
message = (
|
||||
f"Model successfully re-linked to CivArchive model {model_id}"
|
||||
+ (f" version {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Model successfully re-linked to Civitai model {model_id}"
|
||||
+ (f" version {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
@@ -669,6 +693,8 @@ class ModelManagementHandler:
|
||||
"hash": updated_metadata.get("sha256", ""),
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
@@ -1029,6 +1055,11 @@ class ModelQueryHandler:
|
||||
self._service = service
|
||||
self._logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _parse_include_empty(request: web.Request) -> bool:
|
||||
"""Parse the include_empty query flag (``1``/``true``)."""
|
||||
return request.query.get("include_empty", "").lower() in ("1", "true")
|
||||
|
||||
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
@@ -1123,8 +1154,14 @@ class ModelQueryHandler:
|
||||
|
||||
async def get_folders(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
return web.json_response({"folders": cache.folders})
|
||||
include_empty = self._parse_include_empty(request)
|
||||
if include_empty:
|
||||
# Live enumeration includes empty OS-created directories.
|
||||
folders = await self._service.scanner.get_all_folders()
|
||||
else:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
folders = cache.folders
|
||||
return web.json_response({"folders": folders})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting folders: %s", exc)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -1149,7 +1186,9 @@ class ModelQueryHandler:
|
||||
{"success": False, "error": "model_root parameter is required"},
|
||||
status=400,
|
||||
)
|
||||
folder_tree = await self._service.get_folder_tree(model_root)
|
||||
folder_tree = await self._service.get_folder_tree(
|
||||
model_root, include_empty=self._parse_include_empty(request)
|
||||
)
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting folder tree: %s", exc)
|
||||
@@ -1157,7 +1196,9 @@ class ModelQueryHandler:
|
||||
|
||||
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
unified_tree = await self._service.get_unified_folder_tree()
|
||||
unified_tree = await self._service.get_unified_folder_tree(
|
||||
include_empty=self._parse_include_empty(request)
|
||||
)
|
||||
return web.json_response({"success": True, "tree": unified_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting unified folder tree: %s", exc)
|
||||
@@ -1659,7 +1700,8 @@ class ModelDownloadHandler:
|
||||
import json
|
||||
|
||||
try:
|
||||
data["file_params"] = json.loads(file_params_json)
|
||||
# Normalize falsy payloads (e.g. {}) to None (#1058)
|
||||
data["file_params"] = json.loads(file_params_json) or None
|
||||
except json.JSONDecodeError:
|
||||
self._logger.warning(
|
||||
"Invalid file_params JSON: %s", file_params_json
|
||||
@@ -1811,7 +1853,8 @@ class ModelDownloadHandler:
|
||||
|
||||
model_id = int(model_id_str) if model_id_str else None
|
||||
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
||||
file_params = json.loads(file_params_json) if file_params_json else None
|
||||
# Normalize falsy payloads (e.g. {}) to None (#1058)
|
||||
file_params = (json.loads(file_params_json) if file_params_json else None) or None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.add_to_queue(
|
||||
@@ -1886,8 +1929,18 @@ class ModelDownloadHandler:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_queue(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
cleared_ids = await service.clear_queue(status_filter=status_filter)
|
||||
# Clearing the queue rows alone would orphan any in-memory tasks
|
||||
# and persisted aria2 state for those downloads, leaving them
|
||||
# polling the daemon invisibly. Tear that tracking down too.
|
||||
try:
|
||||
await self._download_coordinator.discard_cleared_downloads(cleared_ids)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"Failed to discard in-memory state for cleared downloads",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"success": True, "cleared": len(cleared_ids)})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download queue: %s", exc, exc_info=True
|
||||
@@ -1970,9 +2023,11 @@ class ModelDownloadHandler:
|
||||
item_id=item_id, download_id=download_id
|
||||
)
|
||||
if item is None:
|
||||
# Missing or non-retryable history entry is a business
|
||||
# outcome, not a routing error: 200 lets the extension's
|
||||
# apiFetch 404-fallback and error middleware stay quiet.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "History item not found or not retryable"},
|
||||
status=404,
|
||||
{"success": False, "error": "History item not found or not retryable"}
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
@@ -2023,8 +2078,12 @@ class ModelDownloadHandler:
|
||||
completed_at=completed_at,
|
||||
)
|
||||
if item is None:
|
||||
# A missing queue item (already completed, or never queued) is
|
||||
# a normal business outcome, not a routing error. Return 200
|
||||
# so the browser extension's apiFetch 404-fallback and the
|
||||
# error middleware stay quiet.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"}, status=404
|
||||
{"success": False, "error": "Download not found in queue"}
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
@@ -2066,9 +2125,10 @@ class ModelDownloadHandler:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
updated = await service.update_status(download_id, status)
|
||||
if not updated:
|
||||
# Same rationale as complete_download_in_queue: a missing
|
||||
# queue item is a business outcome, not a routing error.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"},
|
||||
status=404,
|
||||
{"success": False, "error": "Download not found in queue"}
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
@@ -2187,6 +2247,19 @@ class ModelCivitaiHandler:
|
||||
else:
|
||||
version.pop("localPath", None)
|
||||
|
||||
# Per-file downloaded state so multi-file versions can show
|
||||
# which individual files are already in the library (#1058)
|
||||
local_entries: List[Any] = []
|
||||
if version_id is not None and cache:
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if files_getter is not None:
|
||||
local_entries = files_getter(version_id)
|
||||
elif cache_entry is not None:
|
||||
local_entries = [cache_entry]
|
||||
version["downloadedFiles"] = self._match_downloaded_files(
|
||||
version, local_entries
|
||||
)
|
||||
|
||||
model_file = (
|
||||
self._find_model_file(version.get("files", []))
|
||||
if isinstance(version.get("files"), Iterable)
|
||||
@@ -2201,6 +2274,64 @@ class ModelCivitaiHandler:
|
||||
)
|
||||
return web.Response(status=500, text=str(exc))
|
||||
|
||||
@staticmethod
|
||||
def _match_downloaded_files(
|
||||
version: Mapping[str, Any], local_entries: List[Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Map local library entries back to individual files of a version.
|
||||
|
||||
Matching follows rule D2 (#1058): SHA256 is authoritative when the
|
||||
local entry carries one; otherwise fall back to extension-less file
|
||||
name equality. Returns ``[{fileId, fileName, filePath}]``.
|
||||
"""
|
||||
files = version.get("files")
|
||||
if not isinstance(files, list) or not local_entries:
|
||||
return []
|
||||
|
||||
by_hash: Dict[str, Mapping[str, Any]] = {}
|
||||
by_name: Dict[str, Mapping[str, Any]] = {}
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, Mapping):
|
||||
continue
|
||||
sha = str(
|
||||
(file_info.get("hashes") or {}).get("SHA256") or ""
|
||||
).strip().lower()
|
||||
if sha:
|
||||
by_hash.setdefault(sha, file_info)
|
||||
name = str(file_info.get("name") or "").strip()
|
||||
if name:
|
||||
by_name.setdefault(os.path.splitext(name)[0], file_info)
|
||||
|
||||
downloaded: List[Dict[str, Any]] = []
|
||||
seen_keys: set = set()
|
||||
for entry in local_entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
matched: Optional[Mapping[str, Any]] = None
|
||||
local_hash = str(entry.get("sha256") or "").strip().lower()
|
||||
if local_hash:
|
||||
matched = by_hash.get(local_hash)
|
||||
if matched is None:
|
||||
local_name = str(entry.get("file_name") or "").strip()
|
||||
if local_name:
|
||||
matched = by_name.get(local_name)
|
||||
if matched is None:
|
||||
continue
|
||||
|
||||
file_id = matched.get("id")
|
||||
dedupe_key = file_id if file_id is not None else matched.get("name")
|
||||
if dedupe_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(dedupe_key)
|
||||
downloaded.append(
|
||||
{
|
||||
"fileId": file_id,
|
||||
"fileName": matched.get("name"),
|
||||
"filePath": entry.get("file_path"),
|
||||
}
|
||||
)
|
||||
return downloaded
|
||||
|
||||
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_version_id = request.match_info.get("modelVersionId")
|
||||
@@ -2548,10 +2679,20 @@ class ModelUpdateHandler:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
same_base_scope = self._uses_same_base_update_scope()
|
||||
|
||||
serialized_records = []
|
||||
for record in records.values():
|
||||
has_update_fn = getattr(record, "has_update", None)
|
||||
if callable(has_update_fn) and has_update_fn(
|
||||
if not callable(has_update_fn):
|
||||
continue
|
||||
scoped_fn = (
|
||||
getattr(record, "has_update_for_local_bases", None)
|
||||
if same_base_scope
|
||||
else None
|
||||
)
|
||||
qualifies_fn = scoped_fn if callable(scoped_fn) else has_update_fn
|
||||
if qualifies_fn(
|
||||
hide_early_access=hide_early_access,
|
||||
hide_paid=hide_paid,
|
||||
):
|
||||
@@ -2564,6 +2705,26 @@ class ModelUpdateHandler:
|
||||
}
|
||||
)
|
||||
|
||||
def _uses_same_base_update_scope(self) -> bool:
|
||||
"""Return True when update reporting must honor same-base scoping.
|
||||
|
||||
Mirrors ``BaseModelService._annotate_update_flags``: the Updates filter
|
||||
evaluates updates per local base model when ``version_grouping`` is
|
||||
``same_base`` (its default). The refresh summary counts with the same
|
||||
scope so the "Found N update(s)" toast matches what the filter
|
||||
displays. See issue #1083.
|
||||
"""
|
||||
|
||||
if self._settings is None:
|
||||
return True
|
||||
try:
|
||||
strategy_value = self._settings.get("version_grouping")
|
||||
except Exception:
|
||||
return True
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
return strategy_value.strip().lower() == "same_base"
|
||||
return True
|
||||
|
||||
async def set_model_update_ignore(self, request: web.Request) -> web.Response:
|
||||
payload = await self._read_json(request)
|
||||
model_id = self._normalize_model_id(payload.get("modelId"))
|
||||
@@ -3031,6 +3192,9 @@ class ModelUpdateHandler:
|
||||
"paidAccess": paid_access_payload,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
# Weight-file variant count (None when unknown); lets the UI hide
|
||||
# the download affordance for single-file in-library versions.
|
||||
"fileCount": getattr(version, "file_count", None),
|
||||
}
|
||||
|
||||
async def _build_version_context(
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -45,6 +45,17 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
RecipeScannerGetter = Callable[[], Any]
|
||||
CivitaiClientGetter = Callable[[], Any]
|
||||
|
||||
|
||||
class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the recipe workflow handler."""
|
||||
|
||||
instance: "PromptServerProtocol"
|
||||
|
||||
def send_sync(
|
||||
self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None
|
||||
) -> None: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
# Cap concurrent preview-dimension reads across requests. With a cold LRU
|
||||
# cache one page can touch up to page_size image files; 16 balances SSD and
|
||||
# HDD throughput without starving the event loop.
|
||||
@@ -73,6 +84,7 @@ class RecipeHandlerSet:
|
||||
analysis: "RecipeAnalysisHandler"
|
||||
sharing: "RecipeSharingHandler"
|
||||
batch_import: "BatchImportHandler"
|
||||
workflow: "RecipeWorkflowHandler"
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -128,6 +140,7 @@ class RecipeHandlerSet:
|
||||
"import_from_url": self.management.import_from_url,
|
||||
"create_from_example": self.management.create_from_example,
|
||||
"reimport_recipe": self.management.reimport_recipe,
|
||||
"send_recipe_workflow": self.workflow.send_recipe_workflow,
|
||||
}
|
||||
|
||||
|
||||
@@ -163,11 +176,19 @@ class RecipePageView:
|
||||
user_language = self._settings.get("language", "en")
|
||||
self._server_i18n.set_locale(user_language)
|
||||
|
||||
# While the initial scan is running, show the initialization
|
||||
# screen (same as the model pages) instead of an empty grid; the
|
||||
# page reloads itself when the scanner broadcasts completion.
|
||||
is_initializing = (
|
||||
recipe_scanner._cache is None or recipe_scanner.is_initializing()
|
||||
)
|
||||
|
||||
try:
|
||||
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||
if not is_initializing:
|
||||
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||
rendered = self._template_env.get_template(self._template_name).render(
|
||||
recipes=[],
|
||||
is_initializing=False,
|
||||
is_initializing=is_initializing,
|
||||
settings=self._settings,
|
||||
request=request,
|
||||
t=self._server_i18n.get_translation,
|
||||
@@ -253,6 +274,14 @@ class RecipeListingHandler:
|
||||
if tag_filters:
|
||||
filters["tags"] = tag_filters
|
||||
|
||||
lora_availability = {
|
||||
status.strip()
|
||||
for status in request.query.get("lora_availability", "").split(",")
|
||||
if status.strip() in ("ready", "missing", "deleted")
|
||||
}
|
||||
if lora_availability:
|
||||
filters["lora_availability"] = lora_availability
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||
|
||||
@@ -589,16 +618,31 @@ class RecipeQueryHandler:
|
||||
include_prompt=include_prompt
|
||||
)
|
||||
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
|
||||
|
||||
# Assemble the response directly from the cached recipe summaries.
|
||||
# Resolving each id via get_recipe_by_id would re-read every recipe
|
||||
# JSON from disk — thousands of blocking reads on the event loop
|
||||
# for large libraries — while all required fields already live in
|
||||
# the cache.
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
recipes_by_id = {
|
||||
str(recipe.get("id", "")): recipe for recipe in cache.raw_data
|
||||
}
|
||||
|
||||
response_data = []
|
||||
|
||||
for fingerprint, recipe_ids in fingerprint_groups.items():
|
||||
if len(recipe_ids) <= 1:
|
||||
continue
|
||||
def append_groups(
|
||||
groups: Dict[str, List[Any]], group_type: str
|
||||
) -> None:
|
||||
for group_key, recipe_ids in groups.items():
|
||||
if len(recipe_ids) <= 1:
|
||||
continue
|
||||
|
||||
recipes = []
|
||||
for recipe_id in recipe_ids:
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if recipe:
|
||||
recipes = []
|
||||
for recipe_id in recipe_ids:
|
||||
recipe = recipes_by_id.get(str(recipe_id))
|
||||
if recipe is None:
|
||||
continue
|
||||
recipes.append(
|
||||
{
|
||||
"id": recipe.get("id"),
|
||||
@@ -613,55 +657,23 @@ class RecipeQueryHandler:
|
||||
}
|
||||
)
|
||||
|
||||
if len(recipes) >= 2:
|
||||
recipes.sort(
|
||||
key=lambda entry: entry.get("modified", 0), reverse=True
|
||||
)
|
||||
response_data.append(
|
||||
{
|
||||
"type": "fingerprint",
|
||||
"key": f"g-{len(response_data) + 1}",
|
||||
"fingerprint": fingerprint,
|
||||
"count": len(recipes),
|
||||
"recipes": recipes,
|
||||
}
|
||||
)
|
||||
|
||||
for url, recipe_ids in url_groups.items():
|
||||
if len(recipe_ids) <= 1:
|
||||
continue
|
||||
|
||||
recipes = []
|
||||
for recipe_id in recipe_ids:
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if recipe:
|
||||
recipes.append(
|
||||
if len(recipes) >= 2:
|
||||
recipes.sort(
|
||||
key=lambda entry: entry.get("modified") or 0,
|
||||
reverse=True,
|
||||
)
|
||||
response_data.append(
|
||||
{
|
||||
"id": recipe.get("id"),
|
||||
"title": recipe.get("title"),
|
||||
"file_url": recipe.get("file_url")
|
||||
or self._format_recipe_file_url(
|
||||
recipe.get("file_path", "")
|
||||
),
|
||||
"modified": recipe.get("modified"),
|
||||
"created_date": recipe.get("created_date"),
|
||||
"lora_count": len(recipe.get("loras", [])),
|
||||
"type": group_type,
|
||||
"key": f"g-{len(response_data) + 1}",
|
||||
"fingerprint": group_key,
|
||||
"count": len(recipes),
|
||||
"recipes": recipes,
|
||||
}
|
||||
)
|
||||
|
||||
if len(recipes) >= 2:
|
||||
recipes.sort(
|
||||
key=lambda entry: entry.get("modified", 0), reverse=True
|
||||
)
|
||||
response_data.append(
|
||||
{
|
||||
"type": "source_path",
|
||||
"key": f"g-{len(response_data) + 1}",
|
||||
"fingerprint": url,
|
||||
"count": len(recipes),
|
||||
"recipes": recipes,
|
||||
}
|
||||
)
|
||||
append_groups(fingerprint_groups, "fingerprint")
|
||||
append_groups(url_groups, "source_path")
|
||||
|
||||
response_data.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
return web.json_response(
|
||||
@@ -2755,6 +2767,91 @@ class RecipeSharingHandler:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class RecipeWorkflowHandler:
|
||||
"""Extract an embedded workflow from a recipe image and broadcast it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||
recipe_scanner_getter: RecipeScannerGetter,
|
||||
prompt_server: type[PromptServerProtocol],
|
||||
logger: Logger,
|
||||
) -> None:
|
||||
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||
self._recipe_scanner_getter = recipe_scanner_getter
|
||||
self._prompt_server = prompt_server
|
||||
self._logger = logger
|
||||
|
||||
async def send_recipe_workflow(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if not recipe:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
if os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1":
|
||||
return web.json_response(
|
||||
{"error": "Standalone Mode Active"}, status=400
|
||||
)
|
||||
|
||||
image_path = recipe.get("file_path")
|
||||
if not image_path:
|
||||
return web.json_response({"error": "no_workflow"}, status=404)
|
||||
|
||||
metadata = await asyncio.to_thread(
|
||||
ExifUtils._load_structured_metadata, image_path
|
||||
)
|
||||
workflow_raw = metadata.get("workflow")
|
||||
if not workflow_raw:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "no_workflow",
|
||||
"message": "No embedded workflow found in recipe image",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# _load_structured_metadata always yields workflow as a JSON string;
|
||||
# the frontend extension expects a parsed object for loadGraphData.
|
||||
try:
|
||||
workflow = (
|
||||
json.loads(workflow_raw)
|
||||
if isinstance(workflow_raw, str)
|
||||
else workflow_raw
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self._logger.warning(
|
||||
"Recipe %s embeds a non-JSON workflow payload; skipping send",
|
||||
recipe_id,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "no_workflow",
|
||||
"message": "Embedded workflow data is not valid JSON",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
self._prompt_server.instance.send_sync(
|
||||
"lm_load_workflow",
|
||||
{
|
||||
"workflow": workflow,
|
||||
"name": recipe.get("title") or "",
|
||||
"recipe_id": recipe_id,
|
||||
},
|
||||
)
|
||||
return web.json_response({"success": True, "sent": True})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error sending recipe workflow: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class BatchImportHandler:
|
||||
"""Handle batch import operations for recipes."""
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
RouteDefinition("GET", "/api/lm/init-status", "get_init_status"),
|
||||
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||
|
||||
@@ -90,6 +90,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -217,8 +217,9 @@ class Aria2Downloader:
|
||||
"""Call get_status with retry for transient RPC failures.
|
||||
|
||||
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||
``None`` immediately when the download_id is not tracked (a missing
|
||||
transfer is not a transient condition, so retrying is pointless).
|
||||
``None`` immediately when the transfer is not tracked or its GID is
|
||||
gone from the daemon (a missing transfer is not a transient
|
||||
condition, so retrying is pointless).
|
||||
|
||||
A single failed RPC call should not immediately fail the download,
|
||||
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||
@@ -332,7 +333,13 @@ class Aria2Downloader:
|
||||
return transfer
|
||||
|
||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the raw aria2 status payload for a known download."""
|
||||
"""Return the raw aria2 status payload for a known download.
|
||||
|
||||
Returns ``None`` when the download_id is not tracked or the daemon no
|
||||
longer knows the transfer's GID (daemon restart / forceRemove). A
|
||||
forgotten GID is permanent, not transient, so the caller's recovery
|
||||
path handles it instead of burning retry attempts on a dead GID.
|
||||
"""
|
||||
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None:
|
||||
@@ -348,8 +355,17 @@ class Aria2Downloader:
|
||||
"files",
|
||||
]
|
||||
try:
|
||||
status = await self._rpc_call("aria2.tellStatus", [transfer.gid, keys])
|
||||
status = await self._rpc_call(
|
||||
"aria2.tellStatus", [transfer.gid, keys], log_errors=False
|
||||
)
|
||||
except Exception as exc:
|
||||
if "not found" in str(exc).lower():
|
||||
logger.debug(
|
||||
"aria2 GID %s for download %s is gone; treating as lost transfer",
|
||||
transfer.gid,
|
||||
download_id,
|
||||
)
|
||||
return None
|
||||
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
|
||||
|
||||
if isinstance(status, dict):
|
||||
@@ -367,7 +383,9 @@ class Aria2Downloader:
|
||||
"files",
|
||||
]
|
||||
try:
|
||||
status = await self._rpc_call("aria2.tellStatus", [gid, keys])
|
||||
status = await self._rpc_call(
|
||||
"aria2.tellStatus", [gid, keys], log_errors=False
|
||||
)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "cannot be found" in message.lower() or "not found" in message.lower():
|
||||
@@ -434,8 +452,19 @@ class Aria2Downloader:
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [transfer.gid])
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
if "not found" not in str(exc).lower():
|
||||
return {"success": False, "error": str(exc)}
|
||||
# The daemon already forgot this GID (restart / prior removal),
|
||||
# so the transfer is effectively cancelled.
|
||||
logger.debug(
|
||||
"aria2 GID %s for download %s already gone during cancel",
|
||||
transfer.gid,
|
||||
download_id,
|
||||
)
|
||||
|
||||
# Drop the in-memory entry as well so a concurrent poll loop does
|
||||
# not mistake the removal for a lost transfer and re-register it.
|
||||
self._transfers.pop(download_id, None)
|
||||
await self._state_store.remove(download_id)
|
||||
return {"success": True, "message": "Download cancelled successfully"}
|
||||
|
||||
@@ -725,7 +754,9 @@ class Aria2Downloader:
|
||||
|
||||
return isinstance(result, dict)
|
||||
|
||||
async def _rpc_call(self, method: str, params: list[Any]) -> Any:
|
||||
async def _rpc_call(
|
||||
self, method: str, params: list[Any], *, log_errors: bool = True
|
||||
) -> Any:
|
||||
if not self._rpc_url:
|
||||
raise Aria2Error("aria2 RPC endpoint is not initialized")
|
||||
|
||||
@@ -756,7 +787,10 @@ class Aria2Downloader:
|
||||
error = body["error"] or {}
|
||||
code = error.get("code") if isinstance(error, dict) else None
|
||||
message = error.get("message") if isinstance(error, dict) else str(error)
|
||||
logger.error(
|
||||
# Probing calls (e.g. tellStatus for a GID the daemon may have
|
||||
# forgotten) pass log_errors=False: an expected "not found" must
|
||||
# not spam the log at ERROR level.
|
||||
(logger.error if log_errors else logger.debug)(
|
||||
"aria2 RPC %s failed with HTTP %s, code=%s, message=%s",
|
||||
method,
|
||||
response.status,
|
||||
@@ -771,7 +805,7 @@ class Aria2Downloader:
|
||||
raise Aria2Error(status_message or "Unknown aria2 RPC error")
|
||||
|
||||
if response.status != 200:
|
||||
logger.error(
|
||||
(logger.error if log_errors else logger.debug)(
|
||||
"aria2 RPC %s returned unexpected HTTP status %s without error payload: %s",
|
||||
method,
|
||||
response.status,
|
||||
|
||||
@@ -972,14 +972,25 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return {k: data[k] for k in fields if k in data}
|
||||
|
||||
async def get_folder_tree(self, model_root: str) -> Dict[str, Any]:
|
||||
async def _get_tree_folders(self, cache, include_empty: bool) -> List[str]:
|
||||
"""Return the folder list backing folder tree responses.
|
||||
|
||||
With ``include_empty`` the directories are enumerated live from the
|
||||
filesystem (including empty ones) via the scanner; otherwise the
|
||||
models-only ``cache.folders`` list is used unchanged.
|
||||
"""
|
||||
if include_empty:
|
||||
return await self.scanner.get_all_folders()
|
||||
return cache.folders
|
||||
|
||||
async def get_folder_tree(self, model_root: str, include_empty: bool = False) -> Dict[str, Any]:
|
||||
"""Get hierarchical folder tree for a specific model root"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
# Build tree structure from folders
|
||||
tree = {}
|
||||
|
||||
for folder in cache.folders:
|
||||
for folder in await self._get_tree_folders(cache, include_empty):
|
||||
# Check if this folder belongs to the specified model root
|
||||
folder_belongs_to_root = False
|
||||
for root in self.scanner.get_model_roots():
|
||||
@@ -1001,7 +1012,7 @@ class BaseModelService(ABC):
|
||||
|
||||
return tree
|
||||
|
||||
async def get_unified_folder_tree(self) -> Dict[str, Any]:
|
||||
async def get_unified_folder_tree(self, include_empty: bool = False) -> Dict[str, Any]:
|
||||
"""Get unified folder tree across all model roots"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
@@ -1011,7 +1022,7 @@ class BaseModelService(ABC):
|
||||
# Get all model roots for path normalization
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
for folder in cache.folders:
|
||||
for folder in await self._get_tree_folders(cache, include_empty):
|
||||
if not folder: # Skip empty folders
|
||||
continue
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ class BatchImportService:
|
||||
def cancel_import(self, operation_id: str) -> bool:
|
||||
if operation_id in self._active_operations:
|
||||
self._cancellation_flags[operation_id] = True
|
||||
self._logger.info("Cancel requested for batch import operation %s", operation_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -273,6 +274,14 @@ class BatchImportService:
|
||||
self._active_operations[operation_id] = progress
|
||||
self._cancellation_flags[operation_id] = False
|
||||
|
||||
self._logger.info(
|
||||
"Starting batch import operation %s: %d item(s) (%d URL(s), %d local path(s))",
|
||||
operation_id,
|
||||
len(import_items),
|
||||
sum(1 for it in import_items if it.item_type == ImportItemType.URL),
|
||||
sum(1 for it in import_items if it.item_type == ImportItemType.LOCAL_PATH),
|
||||
)
|
||||
|
||||
asyncio.create_task(
|
||||
self._run_batch_import(
|
||||
operation_id=operation_id,
|
||||
@@ -295,6 +304,12 @@ class BatchImportService:
|
||||
skip_duplicates: bool = False,
|
||||
) -> str:
|
||||
image_paths = await self._discover_images(directory, recursive)
|
||||
self._logger.info(
|
||||
"Batch import directory scan: %d image(s) discovered in %s (recursive=%s)",
|
||||
len(image_paths),
|
||||
directory,
|
||||
recursive,
|
||||
)
|
||||
|
||||
items = [{"source": path, "type": "local_path"} for path in image_paths]
|
||||
|
||||
@@ -403,6 +418,19 @@ class BatchImportService:
|
||||
self._concurrency_controller.record_result(item.duration, False)
|
||||
|
||||
progress.completed += 1
|
||||
self._logger.info(
|
||||
"Batch import %s: item %d/%d status=%s source=%s%s",
|
||||
operation_id,
|
||||
progress.completed,
|
||||
progress.total,
|
||||
item.status.value,
|
||||
(
|
||||
os.path.basename(item.source)
|
||||
if item.item_type == ImportItemType.LOCAL_PATH
|
||||
else item.source[:50]
|
||||
),
|
||||
(f" error={item.error_message}" if item.error_message else ""),
|
||||
)
|
||||
await self._broadcast_progress(progress)
|
||||
|
||||
tasks = [process_item(item) for item in progress.items]
|
||||
@@ -415,6 +443,15 @@ class BatchImportService:
|
||||
|
||||
progress.finished_at = time.time()
|
||||
progress.current_item = ""
|
||||
self._logger.info(
|
||||
"Batch import %s finished: status=%s total=%d success=%d failed=%d skipped=%d",
|
||||
operation_id,
|
||||
progress.status,
|
||||
progress.total,
|
||||
progress.success,
|
||||
progress.failed,
|
||||
progress.skipped,
|
||||
)
|
||||
await self._broadcast_progress(progress)
|
||||
|
||||
await asyncio.sleep(5)
|
||||
@@ -595,3 +632,6 @@ class BatchImportService:
|
||||
def _cleanup_operation(self, operation_id: str) -> None:
|
||||
if operation_id in self._cancellation_flags:
|
||||
del self._cancellation_flags[operation_id]
|
||||
if operation_id in self._active_operations:
|
||||
del self._active_operations[operation_id]
|
||||
self._logger.info("Batch import operation %s cleaned up", operation_id)
|
||||
|
||||
@@ -51,6 +51,7 @@ class CheckpointService(BaseModelService):
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
||||
|
||||
from .downloader import DownloadProgress
|
||||
|
||||
@@ -87,7 +87,9 @@ class DownloadCoordinator:
|
||||
progress_callback=progress_callback,
|
||||
download_id=download_id,
|
||||
source=payload.get("source"),
|
||||
file_params=payload.get("file_params"),
|
||||
# Normalize falsy file_params (e.g. {}) to None so download gates
|
||||
# treat it as "no explicit file selection" (#1058).
|
||||
file_params=payload.get("file_params") or None,
|
||||
)
|
||||
|
||||
result["download_id"] = download_id
|
||||
@@ -184,6 +186,14 @@ class DownloadCoordinator:
|
||||
download_manager = await self._download_manager_factory()
|
||||
return await download_manager.get_active_downloads()
|
||||
|
||||
async def discard_cleared_downloads(self, download_ids: Iterable[str]) -> int:
|
||||
"""Tear down in-memory/aria2 tracking for queue-cleared downloads."""
|
||||
|
||||
if not download_ids:
|
||||
return 0
|
||||
download_manager = await self._download_manager_factory()
|
||||
return await download_manager.discard_cleared_downloads(download_ids)
|
||||
|
||||
def _parse_optional_int(self, value: Any, field: str) -> Optional[int]:
|
||||
"""Parse an optional integer from user input."""
|
||||
|
||||
|
||||
+363
-70
@@ -2,6 +2,7 @@
|
||||
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||
# import cycles. Breaking them would require an architectural refactor.
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -12,8 +13,9 @@ import shutil
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, cast
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||
from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import (
|
||||
@@ -53,6 +55,12 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
NON_DOWNLOADABLE_PRIMARY_TYPES = ("Config", "Archive", "Workflow", "Training Data")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PathSlot:
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
refs: int = 0
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
@@ -82,6 +90,11 @@ class DownloadManager:
|
||||
self._aria2_state_store = Aria2TransferStateStore()
|
||||
self._restored_persisted_downloads = False
|
||||
self._restore_lock = asyncio.Lock()
|
||||
# Refcounted per-target-path locks: two downloads resolving to the
|
||||
# same save_path (e.g. model versions sharing one filename) must not
|
||||
# overlap, or one task's failure cleanup can delete the other's file.
|
||||
self._path_slot_guard: asyncio.Lock = asyncio.Lock()
|
||||
self._path_slots: dict[str, _PathSlot] = {}
|
||||
|
||||
@staticmethod
|
||||
def _get_model_download_backend() -> str:
|
||||
@@ -213,6 +226,162 @@ class DownloadManager:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _get_scanner_for_model_type(self, model_type: str):
|
||||
"""Return the scanner responsible for the given model type."""
|
||||
if model_type == "checkpoint":
|
||||
return await self._get_checkpoint_scanner()
|
||||
if model_type == "embedding":
|
||||
return await ServiceRegistry.get_embedding_scanner()
|
||||
return await self._get_lora_scanner()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_file(
|
||||
files: Any, file_params: Dict[str, Any] | None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve the target file within a version's file list from file_params.
|
||||
|
||||
Shared by the existence gate and the actual file selection so both
|
||||
always agree on which file a download refers to (#1058). Returns None
|
||||
when file_params is None or no file matches.
|
||||
"""
|
||||
if not file_params or not isinstance(files, list):
|
||||
return None
|
||||
|
||||
target_file_id = file_params.get("id")
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format")
|
||||
target_size = file_params.get("size")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
logger.debug(
|
||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, "
|
||||
"isPrimary=%s, total_files=%d",
|
||||
target_file_id, target_type, target_format, target_size, target_fp,
|
||||
is_primary, len(files),
|
||||
)
|
||||
|
||||
file_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
if target_file_id:
|
||||
target_id_str = str(target_file_id)
|
||||
for f in files:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
f_id = f.get("id")
|
||||
if str(f_id) == target_id_str:
|
||||
file_info = f
|
||||
logger.debug(
|
||||
"[download] MATCH by ID: id=%s name='%s'",
|
||||
f_id, f.get("name"),
|
||||
)
|
||||
break
|
||||
if not file_info:
|
||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||
|
||||
elif is_primary:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if isinstance(f, dict)
|
||||
and f.get("primary")
|
||||
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Lenient metadata match: only compare fields present on both sides
|
||||
for f in files:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
f_type = f.get("type", "")
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
f_meta = f.get("metadata", {})
|
||||
f_format = f_meta.get("format") or f.get("format")
|
||||
f_size = f_meta.get("size") or f.get("size")
|
||||
f_fp = f_meta.get("fp") or f.get("fp")
|
||||
|
||||
if target_format and f_format != target_format:
|
||||
continue
|
||||
if target_size and f_size and f_size != target_size:
|
||||
continue
|
||||
if target_fp and f_fp and f_fp != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
return file_info
|
||||
|
||||
async def _find_local_file_entry(
|
||||
self,
|
||||
model_type: str,
|
||||
model_version_id: int,
|
||||
target_file: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find a local library entry for a specific file of a model version.
|
||||
|
||||
Matches per design rule D2 (#1058): SHA256 is only compared when both
|
||||
sides carry a non-empty hash; otherwise fall back to (extension-less)
|
||||
file name equality. Never let two empty hashes compare equal.
|
||||
"""
|
||||
try:
|
||||
normalized_version_id = int(model_version_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
scanner = await self._get_scanner_for_model_type(model_type)
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to scan local entries for version %s file check: %s",
|
||||
model_version_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
raw_data = getattr(cache, "raw_data", None) if cache else None
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
target_hash = str(
|
||||
(target_file.get("hashes") or {}).get("SHA256") or ""
|
||||
).strip().lower()
|
||||
target_name = str(target_file.get("name") or "").strip()
|
||||
target_base = os.path.splitext(target_name)[0] if target_name else ""
|
||||
|
||||
for item in raw_data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
civitai_data = item.get("civitai")
|
||||
if not isinstance(civitai_data, dict):
|
||||
continue
|
||||
try:
|
||||
item_version_id = int(civitai_data.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if item_version_id != normalized_version_id:
|
||||
continue
|
||||
|
||||
local_hash = str(item.get("sha256") or "").strip().lower()
|
||||
if target_hash and local_hash:
|
||||
if local_hash == target_hash:
|
||||
return item
|
||||
# Both sides carry hashes that differ: this is a different
|
||||
# file of the same version — do not fall back to name match.
|
||||
continue
|
||||
|
||||
if target_base:
|
||||
local_name = str(item.get("file_name") or "").strip()
|
||||
if local_name == target_base:
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
async def download_from_civitai(
|
||||
self,
|
||||
model_id: int | None = None,
|
||||
@@ -242,6 +411,10 @@ class DownloadManager:
|
||||
Returns:
|
||||
Dict with download result
|
||||
"""
|
||||
# Normalize falsy file_params (e.g. an empty dict from API JSON
|
||||
# parsing) to None so gate conditions behave consistently (#1058).
|
||||
file_params = file_params or None
|
||||
|
||||
logger.debug(
|
||||
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
||||
"source=%s, file_params=%s",
|
||||
@@ -816,6 +989,7 @@ class DownloadManager:
|
||||
version_info,
|
||||
record.get("model_version_id"),
|
||||
record.get("save_path") or record.get("file_path"),
|
||||
file_info=file_info,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
@@ -939,6 +1113,11 @@ class DownloadManager:
|
||||
|
||||
save_path = self._resolve_save_path_from_persisted_record(record)
|
||||
if save_path is None:
|
||||
# No resolvable target path (e.g. a queued download whose
|
||||
# paths were never resolved before shutdown): the record
|
||||
# can never be restored, so drop it instead of letting it
|
||||
# accumulate in the state store forever.
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
continue
|
||||
|
||||
if (
|
||||
@@ -1152,9 +1331,13 @@ class DownloadManager:
|
||||
use_save_dir_as_root: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrapper for original download_from_civitai implementation"""
|
||||
file_params = file_params or None
|
||||
try:
|
||||
# Check if model version already exists in library
|
||||
if model_version_id is not None:
|
||||
# Check if model version already exists in library.
|
||||
# With an explicit file selection (file_params) the version-level
|
||||
# check is deferred until after the metadata fetch, when the target
|
||||
# file can be resolved and checked individually (#1058).
|
||||
if model_version_id is not None and file_params is None:
|
||||
# Check both scanners
|
||||
lora_scanner = await self._get_lora_scanner()
|
||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||
@@ -1235,8 +1418,26 @@ class DownloadManager:
|
||||
except (TypeError, ValueError):
|
||||
resolved_version_id = None
|
||||
|
||||
# Resolve the explicitly selected file (if any) up front so the
|
||||
# existence gates and the actual file selection below always agree
|
||||
# on the target file (#1058).
|
||||
target_file: Optional[Dict[str, Any]] = None
|
||||
if file_params is not None:
|
||||
target_file = self._resolve_target_file(
|
||||
version_info.get("files") or [], file_params
|
||||
)
|
||||
if target_file is None:
|
||||
logger.warning(
|
||||
"[download] file_params provided but no file matched; "
|
||||
"falling back to version-level checks and primary file "
|
||||
"selection (model_version_id=%s)",
|
||||
resolved_version_id,
|
||||
)
|
||||
explicit_file = target_file is not None
|
||||
|
||||
if (
|
||||
get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||
not explicit_file
|
||||
and get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||
and resolved_version_id is not None
|
||||
and await self._has_been_downloaded(model_type, resolved_version_id)
|
||||
):
|
||||
@@ -1346,9 +1547,38 @@ class DownloadManager:
|
||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||
)
|
||||
|
||||
# Case 2: model_version_id was None, check after getting version_info
|
||||
if model_version_id is None:
|
||||
version_id = version_info.get("id")
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
# - An explicit file selection only blocks when THIS file is
|
||||
# already in the library; other files of the same version
|
||||
# remain downloadable.
|
||||
# - Without file_params (or when file_params failed to resolve),
|
||||
# keep version-level protection. The case "model_version_id
|
||||
# given + no file_params" was already covered by the early
|
||||
# gate above.
|
||||
if explicit_file and resolved_version_id is not None:
|
||||
existing_entry = await self._find_local_file_entry(
|
||||
model_type, resolved_version_id, target_file
|
||||
)
|
||||
if existing_entry is not None:
|
||||
error_message = (
|
||||
f"File '{target_file.get('name')}' from model version "
|
||||
f"{resolved_version_id} already exists in {model_type} library"
|
||||
)
|
||||
logger.info("[download] %s", error_message)
|
||||
return {"success": False, "error": error_message}
|
||||
logger.info(
|
||||
"[download] File '%s' of model version %s not in %s library — "
|
||||
"download allowed (other files of this version may exist locally)",
|
||||
target_file.get("name"), resolved_version_id, model_type,
|
||||
)
|
||||
elif file_params is not None or model_version_id is None:
|
||||
# Case 2: model_version_id was None, or file_params did not
|
||||
# resolve to a concrete file — check at version level.
|
||||
version_id = (
|
||||
resolved_version_id
|
||||
if resolved_version_id is not None
|
||||
else version_info.get("id")
|
||||
)
|
||||
|
||||
if model_type == "lora":
|
||||
# Check lora scanner
|
||||
@@ -1495,73 +1725,16 @@ class DownloadManager:
|
||||
files = version_info.get("files", [])
|
||||
file_info = None
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_file_id = file_params.get("id")
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format")
|
||||
target_size = file_params.get("size")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
logger.debug(
|
||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
|
||||
model_version_id, len(files),
|
||||
)
|
||||
|
||||
if target_file_id:
|
||||
target_id_str = str(target_file_id)
|
||||
for f in files:
|
||||
f_id = f.get("id")
|
||||
if str(f_id) == target_id_str:
|
||||
file_info = f
|
||||
logger.debug(
|
||||
"[download] MATCH by ID: id=%s name='%s'",
|
||||
f_id, f.get("name"),
|
||||
)
|
||||
break
|
||||
if not file_info:
|
||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||
|
||||
elif is_primary:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary")
|
||||
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Lenient metadata match: only compare fields present on both sides
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
f_meta = f.get("metadata", {})
|
||||
f_format = f_meta.get("format") or f.get("format")
|
||||
f_size = f_meta.get("size") or f.get("size")
|
||||
f_fp = f_meta.get("fp") or f.get("fp")
|
||||
|
||||
if target_format and f_format != target_format:
|
||||
continue
|
||||
if target_size and f_size and f_size != target_size:
|
||||
continue
|
||||
if target_fp and f_fp and f_fp != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
# If file_params is provided, reuse the file resolved right after
|
||||
# the metadata fetch so the existence gate and this selection
|
||||
# always agree on the target file (#1058).
|
||||
if file_params is not None:
|
||||
file_info = target_file
|
||||
if not file_info:
|
||||
logger.debug(
|
||||
"[download] No match found via file_params — falling back to primary file lookup",
|
||||
)
|
||||
elif not file_params:
|
||||
else:
|
||||
logger.debug(
|
||||
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
@@ -1706,6 +1879,7 @@ class DownloadManager:
|
||||
version_info,
|
||||
model_version_id,
|
||||
save_path,
|
||||
file_info=file_info,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
@@ -1748,6 +1922,7 @@ class DownloadManager:
|
||||
version_info: Dict[str, Any],
|
||||
fallback_version_id=None,
|
||||
file_path: str | None = None,
|
||||
file_info: Dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
@@ -1773,6 +1948,15 @@ class DownloadManager:
|
||||
if version_id is None:
|
||||
version_id = fallback_version_id
|
||||
|
||||
# Per-file identity for multi-file versions (#1058)
|
||||
file_id = None
|
||||
file_name = None
|
||||
if isinstance(file_info, dict):
|
||||
file_id = file_info.get("id")
|
||||
raw_file_name = file_info.get("name")
|
||||
if isinstance(raw_file_name, str) and raw_file_name.strip():
|
||||
file_name = raw_file_name.strip()
|
||||
|
||||
try:
|
||||
await history_service.mark_downloaded(
|
||||
model_type,
|
||||
@@ -1780,6 +1964,8 @@ class DownloadManager:
|
||||
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
|
||||
source="download",
|
||||
file_path=file_path,
|
||||
file_id=file_id,
|
||||
file_name=file_name,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
@@ -1959,6 +2145,28 @@ class DownloadManager:
|
||||
|
||||
return formatted_path
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _exclusive_target_slot(self, target_key: str):
|
||||
async with self._path_slot_guard:
|
||||
slot = self._path_slots.get(target_key)
|
||||
if slot is None:
|
||||
slot = _PathSlot()
|
||||
self._path_slots[target_key] = slot
|
||||
slot.refs += 1
|
||||
try:
|
||||
async with slot.lock:
|
||||
yield
|
||||
finally:
|
||||
async with self._path_slot_guard:
|
||||
slot.refs -= 1
|
||||
if slot.refs <= 0:
|
||||
_ = self._path_slots.pop(target_key, None)
|
||||
|
||||
def _target_slot_key(self, save_dir: str, metadata) -> str:
|
||||
return os.path.abspath(
|
||||
os.path.join(save_dir, os.path.basename(metadata.file_path))
|
||||
)
|
||||
|
||||
async def _execute_download(
|
||||
self,
|
||||
download_urls: List[str],
|
||||
@@ -1970,6 +2178,33 @@ class DownloadManager:
|
||||
model_type: str = "lora",
|
||||
download_id: str | None = None,
|
||||
transfer_backend: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the download serialized against other downloads targeting the same path."""
|
||||
target_key = self._target_slot_key(save_dir, metadata)
|
||||
async with self._exclusive_target_slot(target_key):
|
||||
return await self._execute_download_pipeline(
|
||||
download_urls=download_urls,
|
||||
save_dir=save_dir,
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path=relative_path,
|
||||
progress_callback=progress_callback,
|
||||
model_type=model_type,
|
||||
download_id=download_id,
|
||||
transfer_backend=transfer_backend,
|
||||
)
|
||||
|
||||
async def _execute_download_pipeline(
|
||||
self,
|
||||
download_urls: List[str],
|
||||
save_dir: str,
|
||||
metadata,
|
||||
version_info: Dict[str, Any],
|
||||
relative_path: str,
|
||||
progress_callback=None,
|
||||
model_type: str = "lora",
|
||||
download_id: str | None = None,
|
||||
transfer_backend: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the actual download process including preview images and model files"""
|
||||
metadata_entries: List[Any] = []
|
||||
@@ -2729,6 +2964,64 @@ class DownloadManager:
|
||||
# Preserve aria2 state store entry so the partial download
|
||||
# info survives restarts and can be resumed later
|
||||
|
||||
async def discard_cleared_downloads(self, download_ids: Iterable[str]) -> int:
|
||||
"""Stop in-memory tracking for downloads cleared from the queue.
|
||||
|
||||
Cancels asyncio tasks, removes live aria2 transfers and drops the
|
||||
persisted aria2 state so cleared downloads cannot keep polling the
|
||||
daemon or be resurrected as ghost entries on the next restart.
|
||||
Partial files on disk are preserved; unlike ``cancel_download`` no
|
||||
files are deleted.
|
||||
|
||||
Returns the number of downloads that had any in-memory or persisted
|
||||
tracking removed.
|
||||
"""
|
||||
discarded = 0
|
||||
aria2_downloader = None
|
||||
|
||||
for download_id in download_ids:
|
||||
task = self._download_tasks.get(download_id)
|
||||
info = self._active_downloads.get(download_id)
|
||||
persisted = await self._aria2_state_store.get(download_id)
|
||||
if task is None and info is None and persisted is None:
|
||||
continue
|
||||
|
||||
discarded += 1
|
||||
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
pause_control = self._pause_events.pop(download_id, None)
|
||||
if pause_control is not None:
|
||||
pause_control.resume()
|
||||
|
||||
if task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
|
||||
self._download_tasks.pop(download_id, None)
|
||||
self._active_downloads.pop(download_id, None)
|
||||
|
||||
backend = (info or persisted or {}).get("transfer_backend") or "python"
|
||||
if backend == "aria2":
|
||||
if aria2_downloader is None:
|
||||
aria2_downloader = await get_aria2_downloader()
|
||||
if await aria2_downloader.has_transfer(download_id):
|
||||
try:
|
||||
await aria2_downloader.cancel_download(download_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove aria2 transfer for cleared download %s: %s",
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
|
||||
return discarded
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||
"""Pause an active download without losing progress."""
|
||||
|
||||
|
||||
@@ -6,12 +6,21 @@ import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SQL fragment extracting the CivitAI file id from the JSON ``file_params``
|
||||
# column (#1058). ``json_valid`` guards against NULL and legacy/unparseable
|
||||
# values, yielding NULL for rows without a file identity; NULL keys group
|
||||
# together so such rows keep the old version-level dedup behavior.
|
||||
_FILE_ID_SQL = (
|
||||
"CASE WHEN json_valid(file_params) "
|
||||
"THEN json_extract(file_params, '$.id') END"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_database_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
@@ -64,6 +73,7 @@ class DownloadQueueService:
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
file_params TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
@@ -120,6 +130,18 @@ class DownloadQueueService:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA_TABLES)
|
||||
|
||||
# Databases created by older versions lack
|
||||
# download_history.file_params; add it so retry-from-history can
|
||||
# restore the originally selected file (#1058).
|
||||
history_columns = {
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(download_history)")
|
||||
}
|
||||
if "file_params" not in history_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE download_history ADD COLUMN file_params TEXT"
|
||||
)
|
||||
|
||||
# Creating the unique index on download_history.download_id can
|
||||
# fail if pre-existing rows have duplicate values (e.g. from a
|
||||
# previous version that lacked the index). Deduplicate first so
|
||||
@@ -368,23 +390,31 @@ class DownloadQueueService:
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> List[str]:
|
||||
"""Remove items from the queue.
|
||||
|
||||
When *status_filter* is provided only items with that status are
|
||||
deleted. Returns the number of deleted rows.
|
||||
deleted. Returns the ``download_id`` values of the deleted rows so
|
||||
callers can also tear down any in-memory tracking for them.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
cursor = conn.execute(
|
||||
rows = conn.execute(
|
||||
"SELECT download_id FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchall()
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute("DELETE FROM download_queue")
|
||||
rows = conn.execute(
|
||||
"SELECT download_id FROM download_queue"
|
||||
).fetchall()
|
||||
conn.execute("DELETE FROM download_queue")
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
return [row["download_id"] for row in rows]
|
||||
|
||||
async def complete_download(
|
||||
self,
|
||||
@@ -418,6 +448,12 @@ class DownloadQueueService:
|
||||
return None
|
||||
|
||||
now = completed_at if completed_at is not None else time.time()
|
||||
# Guard against legacy databases whose download_queue table
|
||||
# predates the file_params column.
|
||||
queue_columns = set(row.keys())
|
||||
file_params_json = (
|
||||
row["file_params"] if "file_params" in queue_columns else None
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
@@ -426,9 +462,9 @@ class DownloadQueueService:
|
||||
"""
|
||||
INSERT OR IGNORE INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
version_name, thumbnail_url, file_params, status, error,
|
||||
file_path, bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["download_id"],
|
||||
@@ -437,6 +473,7 @@ class DownloadQueueService:
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
file_params_json,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
@@ -503,6 +540,7 @@ class DownloadQueueService:
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
is_already_exists: int = 0,
|
||||
file_params: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Insert a record into the download history.
|
||||
|
||||
@@ -510,6 +548,7 @@ class DownloadQueueService:
|
||||
inserted row.
|
||||
"""
|
||||
now = time.time()
|
||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
@@ -517,9 +556,10 @@ class DownloadQueueService:
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at, is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
version_name, thumbnail_url, file_params, status, error,
|
||||
file_path, bytes_downloaded, total_bytes, completed_at,
|
||||
is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
@@ -528,6 +568,7 @@ class DownloadQueueService:
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
file_params_json,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
@@ -702,7 +743,7 @@ class DownloadQueueService:
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
@@ -712,6 +753,7 @@ class DownloadQueueService:
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
row["file_params"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
@@ -755,7 +797,7 @@ class DownloadQueueService:
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
@@ -765,6 +807,7 @@ class DownloadQueueService:
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
row["file_params"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
@@ -840,33 +883,44 @@ class DownloadQueueService:
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
# 1. History: for each (model_id, model_version_id, status) triplet
|
||||
# keep only the row with the highest id (most recently inserted).
|
||||
conn.execute("""
|
||||
# 1. History: for each (model_id, model_version_id, file_id,
|
||||
# status) group keep only the row with the highest id (most
|
||||
# recently inserted). file_id comes from file_params (#1058)
|
||||
# so distinct files of the same version never collapse.
|
||||
conn.execute(f"""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id, status
|
||||
GROUP BY model_id, model_version_id, status,
|
||||
{_FILE_ID_SQL}
|
||||
)
|
||||
""")
|
||||
result["removed_history"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 2. Cross-status dedup: for each (model_id, model_version_id),
|
||||
# keep only the entry with the highest-priority terminal status.
|
||||
# 2. Cross-status dedup: for each (model_id, model_version_id,
|
||||
# file_id), keep only the entry with the highest-priority
|
||||
# terminal status.
|
||||
# Priority: completed (3) > failed (2) > canceled (1).
|
||||
# This prevents the same model version from having both a
|
||||
# 'failed' and a 'canceled' entry (or a 'completed' alongside
|
||||
# either) after the bug-created duplicates are removed.
|
||||
conn.execute("""
|
||||
# This prevents the same file of a model version from having
|
||||
# both a 'failed' and a 'canceled' entry (or a 'completed'
|
||||
# alongside either) after the bug-created duplicates are
|
||||
# removed. ``IS`` matches NULL file ids against each other so
|
||||
# rows without file identity keep the old behavior.
|
||||
conn.execute(f"""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT dh.id
|
||||
FROM download_history dh
|
||||
FROM (
|
||||
SELECT id, model_id, model_version_id, status,
|
||||
{_FILE_ID_SQL} AS file_id
|
||||
FROM download_history
|
||||
) dh
|
||||
INNER JOIN (
|
||||
SELECT model_id, model_version_id,
|
||||
{_FILE_ID_SQL} AS file_id,
|
||||
MAX(CASE status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
@@ -874,17 +928,18 @@ class DownloadQueueService:
|
||||
ELSE 0
|
||||
END) AS best_prio
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id
|
||||
GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
|
||||
) best
|
||||
ON dh.model_id = best.model_id
|
||||
AND dh.model_version_id = best.model_version_id
|
||||
AND dh.file_id IS best.file_id
|
||||
AND CASE dh.status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'canceled' THEN 1
|
||||
ELSE 0
|
||||
END = best.best_prio
|
||||
GROUP BY dh.model_id, dh.model_version_id
|
||||
GROUP BY dh.model_id, dh.model_version_id, dh.file_id
|
||||
HAVING dh.id = MAX(dh.id)
|
||||
)
|
||||
""")
|
||||
@@ -892,15 +947,17 @@ class DownloadQueueService:
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 3. Queue: for each (model_id, model_version_id) keep only the
|
||||
# row with the latest added_at (most recently enqueued).
|
||||
conn.execute("""
|
||||
# 3. Queue: for each (model_id, model_version_id, file_id) keep
|
||||
# only the row with the latest added_at (most recently
|
||||
# enqueued). file_id comes from file_params (#1058) so
|
||||
# distinct files of the same version never collapse.
|
||||
conn.execute(f"""
|
||||
DELETE FROM download_queue
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM download_queue
|
||||
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
GROUP BY model_id, model_version_id
|
||||
GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
|
||||
)
|
||||
AND status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
""")
|
||||
|
||||
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
||||
ON downloaded_model_versions(model_type, model_id);
|
||||
CREATE TABLE IF NOT EXISTS downloaded_version_files (
|
||||
model_type TEXT NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
file_name TEXT,
|
||||
downloaded_at REAL NOT NULL,
|
||||
PRIMARY KEY (model_type, version_id, file_id)
|
||||
);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
||||
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
|
||||
source: str = "manual",
|
||||
file_path: str | None = None,
|
||||
library_name: str | None = None,
|
||||
file_id: int | None = None,
|
||||
file_name: str | None = None,
|
||||
) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_model_id = _normalize_int(model_id)
|
||||
normalized_file_id = _normalize_int(file_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return
|
||||
|
||||
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
if normalized_file_id is not None:
|
||||
# Per-file history for multi-file versions (#1058)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_version_files (
|
||||
model_type, version_id, file_id, file_name, downloaded_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
|
||||
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
|
||||
downloaded_at = excluded.downloaded_at
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_file_id,
|
||||
file_name,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_downloaded_bulk(
|
||||
@@ -206,24 +236,33 @@ class DownloadedVersionHistoryService:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
# The connection is created with check_same_thread=False and all
|
||||
# access is serialized by self._lock, so the executemany upsert +
|
||||
# commit can run in the default executor without blocking the
|
||||
# event loop on large hydration payloads.
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._mark_downloaded_bulk_sync, payload)
|
||||
|
||||
def _mark_downloaded_bulk_sync(self, payload: Sequence[tuple[object, ...]]) -> None:
|
||||
"""Synchronous executemany upsert + commit; runs in a worker thread."""
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
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),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
@@ -255,8 +294,63 @@ class DownloadedVersionHistoryService:
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
# Whole-version deletion also clears the per-file records (#1058)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_file_deleted(
|
||||
self, model_type: str, version_id: int, file_id: int
|
||||
) -> None:
|
||||
"""Drop a single file record of a version, keeping siblings (#1058)."""
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_file_id = _normalize_int(file_id)
|
||||
if (
|
||||
normalized_type is None
|
||||
or normalized_version_id is None
|
||||
or normalized_file_id is None
|
||||
):
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ? AND file_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id, normalized_file_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def get_downloaded_file_ids(
|
||||
self, model_type: str, version_id: int
|
||||
) -> list[int]:
|
||||
"""Return the CivitAI file ids recorded as downloaded for a version."""
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT file_id
|
||||
FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
ORDER BY file_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchall()
|
||||
return [int(row["file_id"]) for row in rows]
|
||||
|
||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
|
||||
@@ -156,6 +156,25 @@ class DownloadStalledError(Exception):
|
||||
"""Raised when download progress stalls beyond the configured timeout."""
|
||||
|
||||
|
||||
def _disable_netrc_auth(session: aiohttp.ClientSession) -> None:
|
||||
"""Prevent the session from loading credentials from netrc files.
|
||||
|
||||
``trust_env=True`` is kept so system-level proxies still work, but aiohttp
|
||||
would also auto-apply netrc entries (e.g. ``machine civitai.red``) as
|
||||
BasicAuth. aiohttp refuses to combine those with the explicit
|
||||
``Authorization: Bearer`` header set for CivitAI requests, raising
|
||||
"Cannot combine AUTHORIZATION header with AUTH argument or credentials
|
||||
encoded in URL" before the request is even sent. Subclassing ClientSession
|
||||
is discouraged by aiohttp (emits a DeprecationWarning), so the private
|
||||
hook is patched on the instance instead.
|
||||
"""
|
||||
|
||||
def _no_netrc_auth(*args: Any, **kwargs: Any) -> Optional[aiohttp.BasicAuth]:
|
||||
return None
|
||||
|
||||
setattr(session, "_get_netrc_auth", _no_netrc_auth)
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||
|
||||
@@ -370,6 +389,7 @@ class Downloader:
|
||||
trust_env=not app_proxy_active,
|
||||
timeout=timeout,
|
||||
)
|
||||
_disable_netrc_auth(self._session)
|
||||
|
||||
# Store proxy URL for per-request use. Stays None for SOCKS because the
|
||||
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
|
||||
|
||||
@@ -51,6 +51,7 @@ class EmbeddingService(BaseModelService):
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
|
||||
@@ -58,6 +58,7 @@ class LoraService(BaseModelService):
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
|
||||
@@ -419,14 +419,37 @@ class MetadataSyncService:
|
||||
metadata: Dict[str, Any],
|
||||
model_id: int,
|
||||
model_version_id: Optional[int],
|
||||
provider_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Relink a local metadata record to a specific CivitAI model version."""
|
||||
"""Relink a local metadata record to a specific CivitAI model version.
|
||||
|
||||
When ``provider_name`` is given, the named provider is resolved via the
|
||||
metadata provider selector instead of the default fallback chain. A
|
||||
missing/disabled provider surfaces a user-friendly error instead of the
|
||||
raw selector exception.
|
||||
"""
|
||||
|
||||
if provider_name:
|
||||
try:
|
||||
provider = await self._get_provider(provider_name)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Unable to resolve metadata provider %s: %s", provider_name, exc
|
||||
)
|
||||
raise ValueError(
|
||||
"CivitArchive is not available or not enabled. "
|
||||
"Enable the CivitArchive API in settings to relink via CivArchive."
|
||||
) from exc
|
||||
else:
|
||||
provider = await self._get_default_provider()
|
||||
|
||||
provider = await self._get_default_provider()
|
||||
civitai_metadata = await provider.get_model_version(model_id, model_version_id)
|
||||
if not civitai_metadata:
|
||||
provider_label = (
|
||||
"CivitArchive" if provider_name == "civarchive_api" else "CivitAI"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Model version not found on CivitAI for ID: {model_id}"
|
||||
f"Model version not found on {provider_label} for ID: {model_id}"
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ class ModelCache:
|
||||
folders: List[str]
|
||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
# Multi-valued companion to version_index: every local file entry of a
|
||||
# CivitAI model version, so versions with several downloaded files stay
|
||||
# consistent (#1058).
|
||||
version_files_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
name_display_mode: str = "model_name"
|
||||
_lock: Any = field(init=False, repr=False, default=None)
|
||||
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||
@@ -116,6 +120,7 @@ class ModelCache:
|
||||
|
||||
self.version_index = {}
|
||||
self.model_id_index = {}
|
||||
self.version_files_index = {}
|
||||
for item in self.raw_data:
|
||||
self.add_to_version_index(item)
|
||||
|
||||
@@ -132,6 +137,17 @@ class ModelCache:
|
||||
|
||||
self.version_index[version_id] = item
|
||||
|
||||
# Register in the multi-valued index, deduplicated by file_path (#1058)
|
||||
files = self.version_files_index.setdefault(version_id, [])
|
||||
for entry in files:
|
||||
if entry is item or (
|
||||
isinstance(entry, dict)
|
||||
and entry.get('file_path') == item.get('file_path')
|
||||
):
|
||||
break
|
||||
else:
|
||||
files.append(item)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
@@ -159,12 +175,37 @@ class ModelCache:
|
||||
if version_id is None:
|
||||
return
|
||||
|
||||
# Drop only this file's entry from the multi-valued index (#1058)
|
||||
files = self.version_files_index.get(version_id)
|
||||
if files:
|
||||
remaining = [
|
||||
entry
|
||||
for entry in files
|
||||
if not (
|
||||
entry is item
|
||||
or (
|
||||
isinstance(entry, dict)
|
||||
and entry.get('file_path') == item.get('file_path')
|
||||
)
|
||||
)
|
||||
]
|
||||
if remaining:
|
||||
self.version_files_index[version_id] = remaining
|
||||
else:
|
||||
self.version_files_index.pop(version_id, None)
|
||||
|
||||
# A surviving sibling file keeps the version present in the indexes
|
||||
sibling = (self.version_files_index.get(version_id) or [None])[0]
|
||||
|
||||
existing = self.version_index.get(version_id)
|
||||
if existing is item or (
|
||||
isinstance(existing, dict)
|
||||
and existing.get('file_path') == item.get('file_path')
|
||||
):
|
||||
self.version_index.pop(version_id, None)
|
||||
if sibling is not None:
|
||||
self.version_index[version_id] = sibling
|
||||
else:
|
||||
self.version_index.pop(version_id, None)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
@@ -174,6 +215,20 @@ class ModelCache:
|
||||
if not versions:
|
||||
return
|
||||
|
||||
if sibling is not None:
|
||||
# Update the descriptor to reflect the surviving sibling file
|
||||
descriptor = self._build_version_descriptor(
|
||||
sibling,
|
||||
sibling.get('civitai') if isinstance(sibling, dict) else {},
|
||||
version_id,
|
||||
)
|
||||
for index, existing_desc in enumerate(versions):
|
||||
if existing_desc.get('versionId') == version_id:
|
||||
if descriptor is not None:
|
||||
versions[index] = descriptor
|
||||
break
|
||||
return
|
||||
|
||||
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||
if filtered:
|
||||
self.model_id_index[model_id] = filtered
|
||||
@@ -206,6 +261,15 @@ class ModelCache:
|
||||
versions = self.model_id_index.get(normalized_id, [])
|
||||
return [dict(version) for version in versions]
|
||||
|
||||
def get_files_by_version_id(self, version_id: Any) -> List[Dict[str, Any]]:
|
||||
"""Return every local file entry for a CivitAI model version (#1058)."""
|
||||
|
||||
normalized_id = self._normalize_version_id(version_id)
|
||||
if normalized_id is None:
|
||||
return []
|
||||
|
||||
return list(self.version_files_index.get(normalized_id, []))
|
||||
|
||||
async def resort(self):
|
||||
"""Resort cached data according to last sort mode if set"""
|
||||
async with self._lock:
|
||||
|
||||
@@ -432,6 +432,7 @@ class SearchStrategy:
|
||||
"tags": False,
|
||||
"recursive": True,
|
||||
"creator": False,
|
||||
"hash": False,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -494,8 +495,28 @@ class SearchStrategy:
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
# Hash search is always exact (never fuzzy): match the full
|
||||
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
|
||||
if options.get("hash", False):
|
||||
hash_query = search_lower.strip()
|
||||
if hash_query and self._matches_hash(item, hash_query):
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
def _matches_hash(self, item: Dict[str, Any], hash_query: str) -> bool:
|
||||
"""Exact-match the normalized query against the item's known hashes."""
|
||||
sha256 = item.get("sha256")
|
||||
sha256_lower = sha256.lower() if isinstance(sha256, str) else ""
|
||||
if sha256_lower and hash_query in (sha256_lower, sha256_lower[:10]):
|
||||
return True
|
||||
# autov3 is None when unchecked and "" when checked but unavailable
|
||||
autov3 = item.get("autov3")
|
||||
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _matches(
|
||||
self, candidate: str, search_term: str, search_lower: str, fuzzy: bool
|
||||
) -> bool:
|
||||
|
||||
+294
-62
@@ -5,7 +5,7 @@ import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Type, Union, cast
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
|
||||
|
||||
from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
|
||||
from ..config import config
|
||||
@@ -25,6 +25,28 @@ from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Canonical set of weight-file extensions stripped when normalizing model
|
||||
# names for matching (ModelScanner.find_matching_models and the recipe rematch
|
||||
# filename key share this set). It is the union of the LoRA scanner set
|
||||
# ({".safetensors"}) and the Checkpoint scanner set (ComfyUI's
|
||||
# supported_pt_extensions plus ".gguf") so type-blind lookups (lora +
|
||||
# checkpoint merged) cover every format either scanner indexes. ".safebin"
|
||||
# is deliberately absent — no scanner indexes it, so a recipe entry
|
||||
# "model.safebin" must not be bound to a local "model.safetensors".
|
||||
WEIGHT_FILE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".safetensors",
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_excluded_dir(name: str) -> bool:
|
||||
"""Return True when a directory entry must be skipped during model walks.
|
||||
@@ -35,6 +57,16 @@ def _is_excluded_dir(name: str) -> bool:
|
||||
return name == PENDING_DELETE_DIR_NAME
|
||||
|
||||
|
||||
def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
"""Return True when any segment of a relative path is a hidden directory."""
|
||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||
|
||||
|
||||
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
|
||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
||||
|
||||
|
||||
def _is_pending_delete_path(path: str) -> bool:
|
||||
"""Return True when any path component is the pending-delete staging dir."""
|
||||
normalized = str(path).replace(os.sep, "/")
|
||||
@@ -104,6 +136,8 @@ class ModelScanner:
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self._cancel_requested = False # Flag for cancellation
|
||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -143,6 +177,7 @@ class ModelScanner:
|
||||
self._excluded_models = []
|
||||
self._is_initializing = False
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self.invalidate_all_folders_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
try:
|
||||
@@ -500,16 +535,21 @@ class ModelScanner:
|
||||
self._is_initializing = False
|
||||
|
||||
async def _load_persisted_cache(self, page_type: str) -> bool:
|
||||
"""Attempt to hydrate the in-memory cache from the SQLite snapshot."""
|
||||
"""Attempt to hydrate the in-memory cache from the SQLite snapshot.
|
||||
|
||||
The SQLite read and the per-model rebuild (entry adjustment, tag
|
||||
counting, validation/repair, hash index reconstruction) run in the
|
||||
default executor so the event loop stays responsive; only applying
|
||||
the result to shared cache state happens on the loop.
|
||||
"""
|
||||
if not getattr(self, '_persistent_cache', None):
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
persisted = await loop.run_in_executor(
|
||||
rebuilt = await loop.run_in_executor(
|
||||
None,
|
||||
self._persistent_cache.load_cache,
|
||||
self.model_type
|
||||
self._rebuild_persisted_cache
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
@@ -517,47 +557,14 @@ class ModelScanner:
|
||||
logger.debug("%s Scanner: Could not load persisted cache: %s", self.model_type.capitalize(), exc)
|
||||
return False
|
||||
|
||||
if not persisted or not persisted.raw_data:
|
||||
if rebuilt is None:
|
||||
return False
|
||||
|
||||
hash_index = ModelHashIndex()
|
||||
for sha_value, path in persisted.hash_rows:
|
||||
if sha_value and path:
|
||||
hash_index.add_entry(sha_value.lower(), path)
|
||||
|
||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||
# cover every known autov3 -> path mapping regardless of whether a
|
||||
# sha256 row also exists for the same file.
|
||||
for autov3_value, path in persisted.autov3_hash_rows:
|
||||
if autov3_value and path:
|
||||
hash_index.add_autov3(autov3_value.lower(), path)
|
||||
|
||||
tags_count: Dict[str, int] = {}
|
||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||
for item in persisted.raw_data:
|
||||
adjusted_item = self.adjust_cached_entry(dict(item))
|
||||
adjusted_raw_data.append(adjusted_item)
|
||||
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
# Validate cache entries and check health.
|
||||
# Always use the validated/repaired entries — even when there are no
|
||||
# invalid entries, auto_repair may have filled in missing optional
|
||||
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||
# working_entry. Without this unconditional replacement the repaired
|
||||
# copies are discarded and None values propagate to format_response.
|
||||
# See issue #730.
|
||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||
adjusted_raw_data, auto_repair=True
|
||||
)
|
||||
|
||||
# Always use the validated entries (repaired copies)
|
||||
adjusted_raw_data = valid_entries
|
||||
scan_result, invalid_entries = rebuilt
|
||||
|
||||
if invalid_entries:
|
||||
monitor = CacheHealthMonitor()
|
||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||
report = monitor.check_health(scan_result.raw_data, auto_repair=True)
|
||||
|
||||
if report.status != CacheHealthStatus.HEALTHY:
|
||||
# Broadcast health warning to frontend
|
||||
@@ -567,31 +574,22 @@ class ModelScanner:
|
||||
f"{report.invalid_entries} invalid entries, {report.repaired_entries} repaired"
|
||||
)
|
||||
|
||||
# Use only valid entries
|
||||
adjusted_raw_data = valid_entries
|
||||
|
||||
# Rebuild tags count from valid entries only
|
||||
tags_count = {}
|
||||
for item in adjusted_raw_data:
|
||||
for item in scan_result.raw_data:
|
||||
for tag in item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
scan_result.tags_count = tags_count
|
||||
|
||||
# Remove invalid entries from hash index
|
||||
for invalid_entry in invalid_entries:
|
||||
file_path = CacheEntryValidator.get_file_path_safe(invalid_entry)
|
||||
sha256 = CacheEntryValidator.get_sha256_safe(invalid_entry)
|
||||
if file_path:
|
||||
hash_index.remove_by_path(file_path, sha256)
|
||||
|
||||
scan_result = CacheBuildResult(
|
||||
raw_data=adjusted_raw_data,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
)
|
||||
scan_result.hash_index.remove_by_path(file_path, sha256)
|
||||
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._sync_download_history(adjusted_raw_data, source='scan')
|
||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'loading_cache',
|
||||
@@ -616,6 +614,63 @@ class ModelScanner:
|
||||
|
||||
return True
|
||||
|
||||
def _rebuild_persisted_cache(self) -> Optional[Tuple[CacheBuildResult, List[Dict[str, Any]]]]:
|
||||
"""Load the SQLite snapshot and rebuild a ready-to-apply scan result.
|
||||
|
||||
Runs entirely in a worker thread: it must not touch ``self._cache``,
|
||||
the websocket manager, or any asyncio primitives. Returns ``None``
|
||||
when no usable snapshot exists, otherwise a tuple of the scan result
|
||||
(built from validated/repaired entries) and the invalid entries.
|
||||
"""
|
||||
persisted = self._persistent_cache.load_cache(self.model_type)
|
||||
|
||||
if not persisted or not persisted.raw_data:
|
||||
return None
|
||||
|
||||
hash_index = ModelHashIndex()
|
||||
for sha_value, path in persisted.hash_rows:
|
||||
if sha_value and path:
|
||||
hash_index.add_entry(sha_value.lower(), path)
|
||||
|
||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||
# cover every known autov3 -> path mapping regardless of whether a
|
||||
# sha256 row also exists for the same file.
|
||||
for autov3_value, path in persisted.autov3_hash_rows:
|
||||
if autov3_value and path:
|
||||
hash_index.add_autov3(autov3_value.lower(), path)
|
||||
|
||||
tags_count: Dict[str, int] = {}
|
||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||
for item in persisted.raw_data:
|
||||
# load_cache builds a fresh dict per row, and validate_batch below
|
||||
# works on its own per-entry copy when auto_repair=True, so no
|
||||
# additional dict copy is needed here.
|
||||
adjusted_item = self.adjust_cached_entry(item)
|
||||
adjusted_raw_data.append(adjusted_item)
|
||||
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
# Validate cache entries and check health.
|
||||
# Always use the validated/repaired entries — even when there are no
|
||||
# invalid entries, auto_repair may have filled in missing optional
|
||||
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||
# working_entry. Without this unconditional replacement the repaired
|
||||
# copies are discarded and None values propagate to format_response.
|
||||
# See issue #730.
|
||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||
adjusted_raw_data, auto_repair=True
|
||||
)
|
||||
|
||||
# Always use the validated entries (repaired copies)
|
||||
scan_result = CacheBuildResult(
|
||||
raw_data=valid_entries,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
)
|
||||
return scan_result, invalid_entries
|
||||
|
||||
async def _run_autov3_backfill(self) -> None:
|
||||
"""Backfill autov3 for entries loaded from the persisted cache that lack it."""
|
||||
try:
|
||||
@@ -875,12 +930,12 @@ class ModelScanner:
|
||||
new_files = []
|
||||
visited_real_paths = set()
|
||||
discovered_real_files = set()
|
||||
|
||||
|
||||
# Scan all model roots
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
|
||||
# Recursively scan directory
|
||||
for root, dirnames, files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
@@ -888,7 +943,7 @@ class ModelScanner:
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
@@ -933,7 +988,7 @@ class ModelScanner:
|
||||
if self.is_cancelled():
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
|
||||
return
|
||||
|
||||
|
||||
# Process new files in batches
|
||||
total_added = 0
|
||||
if new_files:
|
||||
@@ -1092,6 +1147,56 @@ class ModelScanner:
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get model root directories"""
|
||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||
|
||||
async def get_all_folders(self) -> List[str]:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Unlike the models-only ``cache.folders``, this includes empty
|
||||
directories, so it stays accurate even when the in-memory cache was
|
||||
hydrated from a persisted snapshot without a filesystem walk. Hidden
|
||||
directories (any segment starting with '.') and the pending-delete
|
||||
staging dir are excluded. The result is unioned with the model-derived
|
||||
folders so it is always a superset of ``cache.folders``, and cached
|
||||
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._all_folders_ttl_cache is not None:
|
||||
cached_at, cached_folders = self._all_folders_ttl_cache
|
||||
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
|
||||
return cached_folders
|
||||
|
||||
discovered: Set[str] = set()
|
||||
visited_real_paths: Set[str] = set()
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for root, dirnames, _files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
# realpath is used only for symlink dedup, never for the
|
||||
# recorded path (business paths stay unresolved).
|
||||
real_root = os.path.realpath(root)
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
rel_dir = os.path.relpath(os.path.abspath(root), os.path.abspath(root_path))
|
||||
rel_dir = rel_dir.replace(os.path.sep, "/")
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered.add(rel_dir)
|
||||
|
||||
folders = set(discovered)
|
||||
if self._cache is not None:
|
||||
folders |= {item.get('folder', '') for item in self._cache.raw_data}
|
||||
|
||||
result = sorted(folders, key=lambda x: x.lower())
|
||||
self._all_folders_ttl_cache = (now, result)
|
||||
return result
|
||||
|
||||
def invalidate_all_folders_cache(self) -> None:
|
||||
"""Drop the cached get_all_folders() result (e.g. after a move)."""
|
||||
self._all_folders_ttl_cache = None
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||
"""Get model file info and metadata (extensible for different model types)"""
|
||||
@@ -1307,8 +1412,8 @@ class ModelScanner:
|
||||
else:
|
||||
self._cache.raw_data = list(scan_result.raw_data)
|
||||
|
||||
self._cache.rebuild_version_index()
|
||||
|
||||
# resort() rebuilds folders and the version index on every path, so a
|
||||
# separate rebuild_version_index() call here would be redundant.
|
||||
await self._cache.resort()
|
||||
|
||||
self._log_duplicate_filename_summary()
|
||||
@@ -1751,6 +1856,10 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# A move may have created new directories; drop the cached live-walk
|
||||
# result so the next include_empty request sees them.
|
||||
self.invalidate_all_folders_cache()
|
||||
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
@@ -2140,8 +2249,98 @@ class ModelScanner:
|
||||
return sorted_models
|
||||
return sorted_models[:limit]
|
||||
|
||||
async def get_model_info_by_name(self, name):
|
||||
"""Get model information by name"""
|
||||
@staticmethod
|
||||
def find_matching_models(
|
||||
raw_data: List[Dict[str, Any]],
|
||||
name: str,
|
||||
*,
|
||||
base_model: Optional[str] = None,
|
||||
extensions: Optional[Set[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return all cached models matching ``name`` (case-insensitive).
|
||||
|
||||
A name containing a path separator must equal the model's
|
||||
folder-relative path; a bare name matches on basename. When
|
||||
``base_model`` is given, confident mismatches are rejected while
|
||||
unknowns on either side stay eligible (lenient guard).
|
||||
``extensions`` should be the scanner's own ``file_extensions`` so
|
||||
suffix stripping only covers formats the scanner actually indexes;
|
||||
when omitted, the shared :data:`WEIGHT_FILE_EXTENSIONS` set is used.
|
||||
"""
|
||||
# Longest first so overlapping suffixes strip correctly.
|
||||
exts = sorted(extensions or WEIGHT_FILE_EXTENSIONS, key=len, reverse=True)
|
||||
|
||||
normalized_name = str(name).replace("\\", "/").casefold()
|
||||
for ext in exts:
|
||||
if normalized_name.endswith(ext):
|
||||
normalized_name = normalized_name[: -len(ext)]
|
||||
break
|
||||
has_path = "/" in normalized_name
|
||||
basename = normalized_name.rsplit("/", 1)[-1]
|
||||
|
||||
matches = []
|
||||
for model in raw_data:
|
||||
file_name = str(model.get("file_name") or "").replace("\\", "/")
|
||||
folder = str(model.get("folder") or "").replace("\\", "/").strip("/")
|
||||
model_path = f"{folder}/{file_name}" if folder else file_name
|
||||
for ext in exts:
|
||||
if model_path.casefold().endswith(ext):
|
||||
model_path = model_path[: -len(ext)]
|
||||
break
|
||||
if (has_path and model_path.casefold() == normalized_name) or (
|
||||
not has_path and model_path.rsplit("/", 1)[-1].casefold() == basename
|
||||
):
|
||||
matches.append(model)
|
||||
|
||||
expected_base = str(base_model or "").strip().casefold()
|
||||
if expected_base and expected_base != "unknown":
|
||||
matches = [
|
||||
model
|
||||
for model in matches
|
||||
if str(model.get("base_model") or "").strip().casefold()
|
||||
in ("", "unknown", expected_base)
|
||||
]
|
||||
return matches
|
||||
|
||||
async def find_models_by_name(
|
||||
self, name: str, *, base_model: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return every cached model matching ``name`` (see ``find_matching_models``)."""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
return self.find_matching_models(
|
||||
cache.raw_data,
|
||||
name,
|
||||
base_model=base_model,
|
||||
extensions=self.file_extensions,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding models by name: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def get_model_info_by_name(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
require_unique: bool = False,
|
||||
base_model: Optional[str] = None,
|
||||
):
|
||||
"""Get model information by name.
|
||||
|
||||
Default mode keeps the legacy first-match/fallback semantics. With
|
||||
``require_unique`` an ambiguous name is a miss, and ``base_model``
|
||||
rejects confident base-model mismatches (unknowns stay eligible).
|
||||
"""
|
||||
if require_unique or base_model:
|
||||
try:
|
||||
matches = await self.find_models_by_name(name, base_model=base_model)
|
||||
if require_unique and len(matches) != 1:
|
||||
return None
|
||||
return matches[0] if matches else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
@@ -2446,6 +2645,39 @@ class ModelScanner:
|
||||
logger.error(f"Error checking model version existence: {e}")
|
||||
return False
|
||||
|
||||
async def get_files_for_version(self, model_version_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all local file entries for a specific model version (#1058).
|
||||
|
||||
A Civitai model version can have several weight files downloaded;
|
||||
unlike the single-valued version_index this returns every entry.
|
||||
|
||||
Args:
|
||||
model_version_id: Civitai model version ID
|
||||
|
||||
Returns:
|
||||
List[Dict]: Cache entries (may be empty)
|
||||
"""
|
||||
try:
|
||||
normalized_id = int(model_version_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
if not cache:
|
||||
return []
|
||||
|
||||
getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if getter is not None:
|
||||
return getter(normalized_id)
|
||||
|
||||
# Fallback for cache implementations without the multi-file index
|
||||
entry = cache.version_index.get(normalized_id)
|
||||
return [entry] if entry is not None else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting files for model version: {e}")
|
||||
return []
|
||||
|
||||
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all versions of a model by its ID
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence
|
||||
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
@@ -77,6 +78,10 @@ class ModelVersionRecord:
|
||||
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||
paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO
|
||||
is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate)
|
||||
# Number of downloadable weight files for the version (None when unknown,
|
||||
# e.g. records persisted before this field existed or locally-synthesized
|
||||
# entries). Mirrors the frontend isModelWeightFile() filter.
|
||||
file_count: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -245,6 +250,51 @@ class ModelUpdateRecord:
|
||||
|
||||
return False
|
||||
|
||||
def has_update_for_local_bases(
|
||||
self,
|
||||
hide_early_access: bool = False,
|
||||
hide_non_downloadable: bool = True,
|
||||
hide_paid: bool = False,
|
||||
) -> bool:
|
||||
"""Return True when any locally-held base model scope has an update.
|
||||
|
||||
Aggregates :meth:`has_update_for_base` across every distinct base model
|
||||
present among in-library versions. This mirrors the per-item evaluation
|
||||
performed by ``BaseModelService._annotate_update_flags`` when the
|
||||
``version_grouping`` setting is ``same_base``, so callers reporting
|
||||
"how many models have updates" stay aligned with what the Updates
|
||||
filter displays. Use this instead of :meth:`has_update` for such
|
||||
summaries; see issue #1083.
|
||||
|
||||
When no local base model is known (nothing held locally, or versions
|
||||
never seen in any remote listing), falls back to :meth:`has_update` so
|
||||
a model the item-level filter may still flag is not silently dropped
|
||||
from summaries.
|
||||
"""
|
||||
|
||||
bases = {
|
||||
_normalize_base_model(version.base_model)
|
||||
for version in self.versions
|
||||
if version.is_in_library
|
||||
}
|
||||
bases.discard(None)
|
||||
if not bases:
|
||||
return self.has_update(
|
||||
hide_early_access=hide_early_access,
|
||||
hide_non_downloadable=hide_non_downloadable,
|
||||
hide_paid=hide_paid,
|
||||
)
|
||||
return any(
|
||||
self.has_update_for_base(
|
||||
None,
|
||||
base,
|
||||
hide_early_access=hide_early_access,
|
||||
hide_non_downloadable=hide_non_downloadable,
|
||||
hide_paid=hide_paid,
|
||||
)
|
||||
for base in bases
|
||||
)
|
||||
|
||||
|
||||
class ModelUpdateService:
|
||||
"""Persist and query remote model version metadata."""
|
||||
@@ -273,6 +323,7 @@ class ModelUpdateService:
|
||||
usage_control TEXT,
|
||||
paid_access TEXT,
|
||||
is_paid INTEGER NOT NULL DEFAULT 0,
|
||||
file_count INTEGER,
|
||||
PRIMARY KEY (model_id, version_id),
|
||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -520,6 +571,10 @@ class ModelUpdateService:
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0"
|
||||
),
|
||||
"file_count": (
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN file_count INTEGER"
|
||||
),
|
||||
}
|
||||
|
||||
for column, statement in migrations.items():
|
||||
@@ -623,6 +678,7 @@ class ModelUpdateService:
|
||||
is_early_access INTEGER NOT NULL DEFAULT 0,
|
||||
paid_access TEXT,
|
||||
is_paid INTEGER NOT NULL DEFAULT 0,
|
||||
file_count INTEGER,
|
||||
PRIMARY KEY (model_id, version_id),
|
||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -644,6 +700,7 @@ class ModelUpdateService:
|
||||
"is_early_access",
|
||||
"paid_access",
|
||||
"is_paid",
|
||||
"file_count",
|
||||
]
|
||||
defaults = {
|
||||
"sort_index": "0",
|
||||
@@ -658,6 +715,7 @@ class ModelUpdateService:
|
||||
"is_early_access": "0",
|
||||
"paid_access": "NULL",
|
||||
"is_paid": "0",
|
||||
"file_count": "NULL",
|
||||
}
|
||||
|
||||
select_parts = []
|
||||
@@ -773,6 +831,11 @@ class ModelUpdateService:
|
||||
target_model_ids=target_filter,
|
||||
)
|
||||
|
||||
local_base_models = await self._collect_local_version_bases(
|
||||
scanner,
|
||||
target_model_ids=target_filter,
|
||||
)
|
||||
|
||||
results: Dict[int, ModelUpdateRecord] = {}
|
||||
prefetched: Dict[int, Mapping[Any, Any]] = {}
|
||||
|
||||
@@ -825,6 +888,7 @@ class ModelUpdateService:
|
||||
force_refresh=force_refresh,
|
||||
prefetched_response=prefetched.get(model_id),
|
||||
all_local_version_ids=all_vids,
|
||||
local_base_models=local_base_models,
|
||||
)
|
||||
if scanner.is_cancelled():
|
||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||
@@ -859,12 +923,14 @@ class ModelUpdateService:
|
||||
|
||||
local_versions = await self._collect_local_versions(scanner)
|
||||
version_ids = local_versions.get(model_id, [])
|
||||
local_base_models = await self._collect_local_version_bases(scanner)
|
||||
return await self._refresh_single_model(
|
||||
model_type,
|
||||
model_id,
|
||||
version_ids,
|
||||
metadata_provider,
|
||||
force_refresh=force_refresh,
|
||||
local_base_models=local_base_models,
|
||||
)
|
||||
|
||||
async def update_in_library_versions(
|
||||
@@ -1040,6 +1106,7 @@ class ModelUpdateService:
|
||||
force_refresh: bool = False,
|
||||
prefetched_response: Optional[Mapping[str, Any]] = None,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
local_base_models: Optional[Mapping[int, str]] = None,
|
||||
) -> Optional[ModelUpdateRecord]:
|
||||
normalized_local = self._normalize_sequence(local_versions)
|
||||
# When folder-filtering, this carries the cross-folder version set
|
||||
@@ -1164,6 +1231,7 @@ class ModelUpdateService:
|
||||
existing,
|
||||
now,
|
||||
all_local_version_ids=normalized_all,
|
||||
local_base_models=local_base_models,
|
||||
)
|
||||
else:
|
||||
record = self._merge_with_local_versions(
|
||||
@@ -1370,27 +1438,17 @@ class ModelUpdateService:
|
||||
await self._enrich_version_entries(metadata_provider, aggregated)
|
||||
return aggregated
|
||||
|
||||
async def _collect_local_versions(
|
||||
self,
|
||||
scanner,
|
||||
@staticmethod
|
||||
def _iter_local_civitai_items(
|
||||
cache,
|
||||
*,
|
||||
target_model_ids: Optional[Sequence[int]] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
) -> Dict[int, List[int]]:
|
||||
cache = await scanner.get_cached_data()
|
||||
mapping: Dict[int, set[int]] = {}
|
||||
target_set: Optional[set[int]] = None,
|
||||
normalized_folder: Optional[str] = None,
|
||||
) -> Iterator[tuple[int, int, Any]]:
|
||||
"""Yield ``(modelId, versionId, base_model)`` for each scannable item."""
|
||||
|
||||
if not cache or not getattr(cache, "raw_data", None):
|
||||
return {}
|
||||
|
||||
target_set = None
|
||||
if target_model_ids:
|
||||
target_set = set(target_model_ids)
|
||||
if not target_set:
|
||||
return {}
|
||||
|
||||
normalized_folder = None
|
||||
if folder_path is not None:
|
||||
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||
return
|
||||
|
||||
for item in cache.raw_data:
|
||||
# Apply folder filter first (cheapest check)
|
||||
@@ -1410,10 +1468,75 @@ class ModelUpdateService:
|
||||
continue
|
||||
if target_set is not None and model_id not in target_set:
|
||||
continue
|
||||
yield model_id, version_id, item.get("base_model")
|
||||
|
||||
def _prepare_collection_filters(
|
||||
self,
|
||||
target_model_ids: Optional[Sequence[int]],
|
||||
folder_path: Optional[str],
|
||||
) -> tuple[Optional[set[int]], Optional[str]]:
|
||||
target_set: Optional[set[int]] = None
|
||||
if target_model_ids:
|
||||
target_set = set(target_model_ids)
|
||||
|
||||
normalized_folder = None
|
||||
if folder_path is not None:
|
||||
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||
return target_set, normalized_folder
|
||||
|
||||
async def _collect_local_versions(
|
||||
self,
|
||||
scanner,
|
||||
*,
|
||||
target_model_ids: Optional[Sequence[int]] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
) -> Dict[int, List[int]]:
|
||||
cache = await scanner.get_cached_data()
|
||||
mapping: Dict[int, set[int]] = {}
|
||||
target_set, normalized_folder = self._prepare_collection_filters(
|
||||
target_model_ids, folder_path
|
||||
)
|
||||
|
||||
if target_model_ids and not target_set:
|
||||
return {}
|
||||
|
||||
for model_id, version_id, _base_model in self._iter_local_civitai_items(
|
||||
cache, target_set=target_set, normalized_folder=normalized_folder
|
||||
):
|
||||
mapping.setdefault(model_id, set()).add(version_id)
|
||||
|
||||
return {model_id: sorted(ids) for model_id, ids in mapping.items()}
|
||||
|
||||
async def _collect_local_version_bases(
|
||||
self,
|
||||
scanner,
|
||||
*,
|
||||
target_model_ids: Optional[Sequence[int]] = None,
|
||||
) -> Dict[int, str]:
|
||||
"""Map version id -> base model from cache items.
|
||||
|
||||
Deliberately unfiltered by folder: synthesized in-library entries must
|
||||
carry a base regardless of which folder triggered the refresh.
|
||||
"""
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
bases: Dict[int, str] = {}
|
||||
target_set, _normalized_folder = self._prepare_collection_filters(
|
||||
target_model_ids, None
|
||||
)
|
||||
|
||||
if target_model_ids and not target_set:
|
||||
return {}
|
||||
|
||||
for _model_id, version_id, base_model in self._iter_local_civitai_items(
|
||||
cache, target_set=target_set
|
||||
):
|
||||
normalized_base = _normalize_string(base_model)
|
||||
if normalized_base:
|
||||
bases[version_id] = normalized_base
|
||||
|
||||
return bases
|
||||
|
||||
def _merge_with_local_versions(
|
||||
self,
|
||||
existing: Optional[ModelUpdateRecord],
|
||||
@@ -1493,6 +1616,7 @@ class ModelUpdateService:
|
||||
timestamp: float,
|
||||
*,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
local_base_models: Optional[Mapping[int, str]] = None,
|
||||
) -> ModelUpdateRecord:
|
||||
local_set = set(local_versions)
|
||||
# When folder-filtering, also consider versions in other folders
|
||||
@@ -1504,6 +1628,7 @@ class ModelUpdateService:
|
||||
)
|
||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||
file_count_map = {version.version_id: version.file_count for version in existing.versions} if existing else {}
|
||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||
existing_map = {version.version_id: version for version in existing.versions} if existing else {}
|
||||
|
||||
@@ -1528,11 +1653,17 @@ class ModelUpdateService:
|
||||
usage_control=remote_version.usage_control,
|
||||
paid_access=remote_version.paid_access,
|
||||
is_paid=remote_version.is_paid,
|
||||
file_count=(
|
||||
remote_version.file_count
|
||||
if remote_version.file_count is not None
|
||||
else file_count_map.get(version_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
missing_local = local_set - seen_ids
|
||||
if missing_local:
|
||||
item_base_models = local_base_models or {}
|
||||
for version_id in sorted(missing_local):
|
||||
existing_version = existing_map.get(version_id)
|
||||
if existing_version:
|
||||
@@ -1547,7 +1678,7 @@ class ModelUpdateService:
|
||||
ModelVersionRecord(
|
||||
version_id=version_id,
|
||||
name=None,
|
||||
base_model=None,
|
||||
base_model=item_base_models.get(version_id),
|
||||
released_at=None,
|
||||
size_bytes=None,
|
||||
preview_url=None,
|
||||
@@ -1620,6 +1751,7 @@ class ModelUpdateService:
|
||||
base_model = _normalize_string(entry.get("baseModel"))
|
||||
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
|
||||
size_bytes = self._extract_size_bytes(entry.get("files"))
|
||||
file_count = self._extract_file_count(entry.get("files"))
|
||||
preview_url = self._extract_preview_url(entry.get("images"))
|
||||
early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt"))
|
||||
|
||||
@@ -1655,6 +1787,7 @@ class ModelUpdateService:
|
||||
usage_control=usage_control,
|
||||
paid_access=paid_access_json,
|
||||
is_paid=is_paid,
|
||||
file_count=file_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1683,6 +1816,25 @@ class ModelUpdateService:
|
||||
return None
|
||||
return {"permanent": permanent, "endsAt": ends_at}
|
||||
|
||||
@staticmethod
|
||||
def _extract_file_count(files) -> Optional[int]:
|
||||
"""Count downloadable weight files in a version entry's ``files`` list.
|
||||
|
||||
Returns None when the payload carries no files array (unknown), so
|
||||
callers can distinguish "no weight files" from "no data".
|
||||
"""
|
||||
|
||||
if not isinstance(files, list):
|
||||
return None
|
||||
count = 0
|
||||
for entry in files:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
entry_type = entry.get("type")
|
||||
if isinstance(entry_type, str) and entry_type in MODEL_WEIGHT_FILE_TYPES:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||
if not isinstance(files, Iterable):
|
||||
return None
|
||||
@@ -1795,7 +1947,7 @@ class ModelUpdateService:
|
||||
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, paid_access, is_paid
|
||||
is_early_access, usage_control, paid_access, is_paid, file_count
|
||||
FROM model_update_versions
|
||||
WHERE model_id IN ({placeholders})
|
||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||
@@ -1826,6 +1978,7 @@ class ModelUpdateService:
|
||||
usage_control=row["usage_control"],
|
||||
paid_access=row["paid_access"],
|
||||
is_paid=bool(row["is_paid"]),
|
||||
file_count=_normalize_int(row["file_count"]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1888,8 +2041,8 @@ class ModelUpdateService:
|
||||
INSERT INTO model_update_versions (
|
||||
version_id, model_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, paid_access, is_paid
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
is_early_access, usage_control, paid_access, is_paid, file_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
version.version_id,
|
||||
@@ -1907,6 +2060,7 @@ class ModelUpdateService:
|
||||
version.usage_control,
|
||||
paid_access_value,
|
||||
1 if version.is_paid else 0,
|
||||
version.file_count,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -500,10 +500,36 @@ class PendingDeleteService:
|
||||
QUARANTINE them (preserving the pre-registry sweep semantics). The
|
||||
walk only descends into dirs literally named ``.lm-pending-delete``,
|
||||
so false positives are structurally limited.
|
||||
|
||||
The filesystem walk itself runs in a worker thread so a large or slow
|
||||
library cannot block the event loop at startup; only the (rare) batch
|
||||
registration awaits run on the loop.
|
||||
"""
|
||||
roots = await self._get_all_model_roots()
|
||||
loop = asyncio.get_event_loop()
|
||||
staging_parents = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
self._collect_staging_parents, # Run the tree walk off the loop
|
||||
roots,
|
||||
)
|
||||
for staging_parent in staging_parents:
|
||||
await self._register_batch_candidates(staging_parent)
|
||||
|
||||
def _collect_staging_parents(self, roots: Sequence[str]) -> List[str]:
|
||||
"""Walk every model root and return its staging-parent dirs.
|
||||
|
||||
Pure synchronous filesystem discovery with no awaits: walks with
|
||||
``followlinks=True, topdown=True``, prunes symlink cycles via a
|
||||
per-root ``visited`` realpath set (realpath is used ONLY for this
|
||||
dedup set - the returned paths are the unresolved business paths),
|
||||
filters out :func:`_is_excluded_dir` dirs, and collects every dir
|
||||
named ``.lm-pending-delete`` (including the case where a model root
|
||||
itself is one). Results are returned in walk order.
|
||||
"""
|
||||
from .model_scanner import _is_excluded_dir
|
||||
|
||||
for root in await self._get_all_model_roots():
|
||||
staging_parents: List[str] = []
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
visited: Set[str] = set()
|
||||
@@ -518,21 +544,20 @@ class PendingDeleteService:
|
||||
visited.add(real_dir)
|
||||
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
|
||||
# The current dir IS a staging parent (reachable only when
|
||||
# a model root itself is one): register its batches.
|
||||
await self._register_batch_candidates(dirpath)
|
||||
# a model root itself is one): collect its batches.
|
||||
staging_parents.append(dirpath)
|
||||
dirnames[:] = []
|
||||
continue
|
||||
next_dirs: List[str] = []
|
||||
for name in dirnames:
|
||||
if name == PENDING_DELETE_DIR_NAME:
|
||||
await self._register_batch_candidates(
|
||||
os.path.join(dirpath, name)
|
||||
)
|
||||
staging_parents.append(os.path.join(dirpath, name))
|
||||
elif _is_excluded_dir(name):
|
||||
continue
|
||||
else:
|
||||
next_dirs.append(name)
|
||||
dirnames[:] = next_dirs
|
||||
return staging_parents
|
||||
|
||||
async def _register_batch_candidates(self, staging_parent: str) -> None:
|
||||
"""Register every non-orphaned batch subdir of a staging parent."""
|
||||
|
||||
@@ -58,6 +58,7 @@ class PersistentRecipeCache:
|
||||
"checkpoint_json",
|
||||
"gen_params_json",
|
||||
"tags_json",
|
||||
"has_workflow",
|
||||
)
|
||||
_instances: Dict[str, "PersistentRecipeCache"] = {}
|
||||
_instance_lock = threading.Lock()
|
||||
@@ -332,6 +333,44 @@ class PersistentRecipeCache:
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist image_id_map: %s", exc)
|
||||
|
||||
def get_metadata_value(self, key: str) -> Optional[str]:
|
||||
"""Return a value from cache_metadata, or None if missing."""
|
||||
if not self.is_enabled() or not self._schema_initialized:
|
||||
return None
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM cache_metadata WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def set_metadata_value(self, key: str, value: str) -> None:
|
||||
"""Store a value in cache_metadata without rewriting the full cache."""
|
||||
if not self.is_enabled() or not self._schema_initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist cache metadata %s: %s", key, exc)
|
||||
|
||||
def get_indexed_recipe_ids(self) -> Set[str]:
|
||||
"""Return all recipe IDs in the cache.
|
||||
|
||||
@@ -407,7 +446,8 @@ class PersistentRecipeCache:
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
|
||||
@@ -426,6 +466,13 @@ class PersistentRecipeCache:
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Migration: add has_workflow column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN has_workflow INTEGER DEFAULT 0"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc:
|
||||
@@ -488,6 +535,7 @@ class PersistentRecipeCache:
|
||||
checkpoint_json,
|
||||
gen_params_json,
|
||||
tags_json,
|
||||
1 if recipe.get("has_workflow") else 0,
|
||||
)
|
||||
|
||||
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||
@@ -533,6 +581,7 @@ class PersistentRecipeCache:
|
||||
"favorite": bool(row["favorite"]),
|
||||
"repair_version": row["repair_version"] or 0,
|
||||
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
|
||||
"has_workflow": bool(row["has_workflow"]),
|
||||
"loras": loras,
|
||||
"gen_params": gen_params,
|
||||
}
|
||||
|
||||
@@ -7,13 +7,14 @@ enabling sub-100ms search times even with 20k+ recipes.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
@@ -165,6 +166,7 @@ class RecipeFTSIndex:
|
||||
batch_size = 500
|
||||
total = len(recipes)
|
||||
inserted = 0
|
||||
indexed_ids: Set[str] = set()
|
||||
|
||||
for i in range(0, total, batch_size):
|
||||
batch = recipes[i:i + batch_size]
|
||||
@@ -179,6 +181,7 @@ class RecipeFTSIndex:
|
||||
row = self._prepare_fts_row(recipe)
|
||||
rows.append(row)
|
||||
inserted += 1
|
||||
indexed_ids.add(recipe_id)
|
||||
|
||||
if rows:
|
||||
# Insert into FTS table
|
||||
@@ -213,7 +216,11 @@ class RecipeFTSIndex:
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
('recipe_count', str(inserted))
|
||||
(self._COUNT_METADATA_KEY, str(inserted))
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(indexed_ids))
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
@@ -288,6 +295,12 @@ class RecipeFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Check existence via the rowid mapping (fast PK lookup)
|
||||
existed = conn.execute(
|
||||
"SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
|
||||
(recipe_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
# Remove existing entry if present
|
||||
self._remove_recipe_locked(conn, recipe_id)
|
||||
|
||||
@@ -312,6 +325,10 @@ class RecipeFTSIndex:
|
||||
(recipe_id, result[0])
|
||||
)
|
||||
|
||||
# Keep validation metadata in sync (only a new id changes it)
|
||||
if not existed:
|
||||
self._update_mutation_metadata_locked(conn, recipe_id, delta=1)
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
@@ -339,7 +356,13 @@ class RecipeFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
existed = conn.execute(
|
||||
"SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
|
||||
(recipe_id,)
|
||||
).fetchone() is not None
|
||||
self._remove_recipe_locked(conn, recipe_id)
|
||||
if existed:
|
||||
self._update_mutation_metadata_locked(conn, recipe_id, delta=-1)
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
@@ -371,6 +394,15 @@ class RecipeFTSIndex:
|
||||
try:
|
||||
conn.execute("DELETE FROM recipe_fts")
|
||||
conn.execute("DELETE FROM recipe_rowid")
|
||||
# Reset validation metadata to the empty index state
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, '0')
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(set()))
|
||||
)
|
||||
conn.commit()
|
||||
self._ready.clear()
|
||||
return True
|
||||
@@ -427,10 +459,12 @@ class RecipeFTSIndex:
|
||||
"""Check if the FTS index matches the expected recipes.
|
||||
|
||||
This method validates whether the existing FTS index can be reused
|
||||
without a full rebuild. It checks:
|
||||
1. The index has been initialized
|
||||
2. The count matches
|
||||
3. The recipe IDs match
|
||||
without a full rebuild. It compares the expected count and recipe ID
|
||||
fingerprint against metadata recorded when the index was (re)built,
|
||||
so it does not scan the FTS content table. Indexes built by older
|
||||
versions lack this metadata; for those the validation falls back to
|
||||
a one-time scan of the content table and records the metadata so
|
||||
subsequent startups are cheap.
|
||||
|
||||
Args:
|
||||
recipe_count: Expected number of recipes.
|
||||
@@ -446,7 +480,28 @@ class RecipeFTSIndex:
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = self._read_validation_metadata()
|
||||
if metadata is not None:
|
||||
stored_count, stored_fingerprint = metadata
|
||||
if stored_count != recipe_count:
|
||||
logger.debug(
|
||||
"FTS index count mismatch: indexed=%d, expected=%d",
|
||||
stored_count, recipe_count
|
||||
)
|
||||
return False
|
||||
|
||||
if stored_fingerprint != self._compute_ids_fingerprint(recipe_ids):
|
||||
logger.debug("FTS index recipe ID fingerprint mismatch")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Legacy fallback: no stored metadata, scan the content table once
|
||||
# and persist the metadata so later validations are cheap.
|
||||
indexed_count = self.get_indexed_count()
|
||||
indexed_ids = self.get_indexed_recipe_ids()
|
||||
self._store_validation_metadata(indexed_count, indexed_ids)
|
||||
|
||||
if indexed_count != recipe_count:
|
||||
logger.debug(
|
||||
"FTS index count mismatch: indexed=%d, expected=%d",
|
||||
@@ -454,7 +509,6 @@ class RecipeFTSIndex:
|
||||
)
|
||||
return False
|
||||
|
||||
indexed_ids = self.get_indexed_recipe_ids()
|
||||
if indexed_ids != recipe_ids:
|
||||
missing = recipe_ids - indexed_ids
|
||||
extra = indexed_ids - recipe_ids
|
||||
@@ -471,6 +525,112 @@ class RecipeFTSIndex:
|
||||
|
||||
# Internal helpers
|
||||
|
||||
_FINGERPRINT_METADATA_KEY = 'recipe_ids_fingerprint'
|
||||
_COUNT_METADATA_KEY = 'recipe_count'
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint_recipe_id(recipe_id: str) -> int:
|
||||
"""Return a stable 64-bit fingerprint contribution for a recipe ID."""
|
||||
digest = hashlib.sha256(recipe_id.encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:8], "big")
|
||||
|
||||
@classmethod
|
||||
def _compute_ids_fingerprint(cls, recipe_ids: Set[str]) -> str:
|
||||
"""Order-independent fingerprint of a recipe ID set (XOR of per-id hashes)."""
|
||||
fingerprint = 0
|
||||
for recipe_id in recipe_ids:
|
||||
fingerprint ^= cls._fingerprint_recipe_id(str(recipe_id))
|
||||
return f"{fingerprint:016x}"
|
||||
|
||||
def _read_validation_metadata(self) -> Optional[Tuple[int, str]]:
|
||||
"""Return stored (recipe count, ID fingerprint), or None if absent."""
|
||||
try:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT key, value FROM fts_metadata WHERE key IN (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, self._FINGERPRINT_METADATA_KEY)
|
||||
).fetchall()
|
||||
values = {row[0]: row[1] for row in rows}
|
||||
fingerprint = values.get(self._FINGERPRINT_METADATA_KEY)
|
||||
if fingerprint is None:
|
||||
return None
|
||||
try:
|
||||
count = int(values.get(self._COUNT_METADATA_KEY) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return count, fingerprint
|
||||
finally:
|
||||
conn.close()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to read FTS validation metadata: %s", exc)
|
||||
return None
|
||||
|
||||
def _store_validation_metadata(self, recipe_count: int, recipe_ids: Set[str]) -> None:
|
||||
"""Persist recipe count and ID fingerprint for cheap future validation."""
|
||||
try:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, str(recipe_count))
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(recipe_ids))
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to store FTS validation metadata: %s", exc)
|
||||
|
||||
def _update_mutation_metadata_locked(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
recipe_id: str,
|
||||
delta: int,
|
||||
) -> None:
|
||||
"""Incrementally maintain validation metadata after add/remove.
|
||||
|
||||
Caller must hold the lock. The fingerprint is only updated when it
|
||||
already exists; without it, validation falls back to a one-time scan
|
||||
that records fresh metadata.
|
||||
"""
|
||||
fingerprint_row = conn.execute(
|
||||
"SELECT value FROM fts_metadata WHERE key = ?",
|
||||
(self._FINGERPRINT_METADATA_KEY,)
|
||||
).fetchone()
|
||||
if fingerprint_row and fingerprint_row[0]:
|
||||
try:
|
||||
fingerprint = int(fingerprint_row[0], 16)
|
||||
except ValueError:
|
||||
fingerprint = None
|
||||
if fingerprint is not None:
|
||||
fingerprint ^= self._fingerprint_recipe_id(recipe_id)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, f"{fingerprint & 0xFFFFFFFFFFFFFFFF:016x}")
|
||||
)
|
||||
|
||||
count_row = conn.execute(
|
||||
"SELECT value FROM fts_metadata WHERE key = ?",
|
||||
(self._COUNT_METADATA_KEY,)
|
||||
).fetchone()
|
||||
if count_row:
|
||||
try:
|
||||
count = max(0, int(count_row[0] or 0) + delta)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, str(count))
|
||||
)
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
|
||||
+248
-53
@@ -13,10 +13,13 @@ import time
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
||||
from ..config import config
|
||||
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.file_utils import calculate_autov3
|
||||
from ..utils.recipe_open_stats import RecipeOpenStats
|
||||
from .model_scanner import WEIGHT_FILE_EXTENSIONS
|
||||
from .recipe_cache import RecipeCache
|
||||
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
||||
from .websocket_manager import ws_manager
|
||||
from natsort import natsorted
|
||||
import sys
|
||||
import re
|
||||
@@ -36,10 +39,8 @@ logger = logging.getLogger(__name__)
|
||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||
|
||||
# Known weight-file extensions stripped by _normalize_filename_key. Names are
|
||||
# stored extensionless on both sides, so splitext would misread dotted stems
|
||||
# ("my.mix" -> "my") and silently collide distinct models.
|
||||
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
|
||||
# Valid LoRA availability statuses for the recipe listing filter.
|
||||
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
|
||||
|
||||
|
||||
class RecipeScanner:
|
||||
@@ -179,13 +180,15 @@ class RecipeScanner:
|
||||
|
||||
Only known weight-file extensions are stripped — names are stored
|
||||
extensionless on both sides, so splitext would misread dotted stems
|
||||
("my.mix" -> "my") and collide distinct models.
|
||||
("my.mix" -> "my") and collide distinct models. The extension set is
|
||||
shared with ModelScanner.find_matching_models, and is iterated longest
|
||||
first to keep the strip ordering identical to that function.
|
||||
"""
|
||||
if not name:
|
||||
return ""
|
||||
basename = os.path.basename(name.replace("\\", "/"))
|
||||
lower = basename.lower()
|
||||
for ext in _WEIGHT_FILE_EXTS:
|
||||
for ext in sorted(WEIGHT_FILE_EXTENSIONS, key=len, reverse=True):
|
||||
if lower.endswith(ext):
|
||||
basename = basename[: -len(ext)]
|
||||
break
|
||||
@@ -482,6 +485,10 @@ class RecipeScanner:
|
||||
return str(value)
|
||||
return "unknown"
|
||||
|
||||
def is_initializing(self) -> bool:
|
||||
"""Check if the scanner is currently initializing"""
|
||||
return self._is_initializing
|
||||
|
||||
def on_library_changed(self) -> None:
|
||||
"""Reset cached state when the active library changes."""
|
||||
|
||||
@@ -1405,7 +1412,20 @@ class RecipeScanner:
|
||||
|
||||
async def initialize_in_background(self) -> None:
|
||||
"""Initialize cache in background using thread pool"""
|
||||
# Mark as initializing before any await so concurrent callers can
|
||||
# wait on this task instead of observing the placeholder empty cache
|
||||
# (the LoRA scanner wait below can take a while at startup).
|
||||
self._is_initializing = True
|
||||
self._initialization_task = asyncio.current_task()
|
||||
try:
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'loading_cache',
|
||||
'progress': 0,
|
||||
'details': 'Loading recipe cache...',
|
||||
'scanner_type': 'recipe',
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
|
||||
await self._wait_for_lora_scanner()
|
||||
|
||||
# Set initial empty cache to avoid None reference errors
|
||||
@@ -1418,39 +1438,61 @@ class RecipeScanner:
|
||||
folder_tree={},
|
||||
)
|
||||
|
||||
# Mark as initializing to prevent concurrent initializations
|
||||
self._is_initializing = True
|
||||
self._initialization_task = asyncio.current_task()
|
||||
# Start timer
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
# Start timer
|
||||
start_time = time.time()
|
||||
# Use thread pool to execute CPU-intensive operations
|
||||
loop = asyncio.get_event_loop()
|
||||
cache = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
self._initialize_recipe_cache_sync, # Run synchronous version in thread
|
||||
)
|
||||
if cache is not None:
|
||||
self._cache = cache
|
||||
|
||||
# Use thread pool to execute CPU-intensive operations
|
||||
loop = asyncio.get_event_loop()
|
||||
cache = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
self._initialize_recipe_cache_sync, # Run synchronous version in thread
|
||||
)
|
||||
if cache is not None:
|
||||
self._cache = cache
|
||||
|
||||
# Calculate elapsed time and log it
|
||||
elapsed_time = time.time() - start_time
|
||||
recipe_count = (
|
||||
len(cache.raw_data) if cache and hasattr(cache, "raw_data") else 0
|
||||
)
|
||||
logger.info(
|
||||
f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes"
|
||||
)
|
||||
self._schedule_post_scan_enrichment()
|
||||
# Schedule FTS index build in background (non-blocking)
|
||||
self._schedule_fts_index_build()
|
||||
finally:
|
||||
# Mark initialization as complete regardless of outcome
|
||||
self._is_initializing = False
|
||||
# Calculate elapsed time and log it
|
||||
elapsed_time = time.time() - start_time
|
||||
recipe_count = (
|
||||
len(cache.raw_data) if cache and hasattr(cache, "raw_data") else 0
|
||||
)
|
||||
logger.info(
|
||||
f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes"
|
||||
)
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'finalizing',
|
||||
'progress': 100,
|
||||
'status': 'complete',
|
||||
'details': f'Found {recipe_count} recipes.',
|
||||
'scanner_type': 'recipe',
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
self._schedule_post_scan_enrichment()
|
||||
# Schedule FTS index build in background (non-blocking)
|
||||
self._schedule_fts_index_build()
|
||||
except Exception as e:
|
||||
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
|
||||
# Ensure the cache is never None so the page stops showing the
|
||||
# initialization screen, and let waiting clients reload into the
|
||||
# regular (possibly empty) view instead of stalling.
|
||||
if self._cache is None:
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'finalizing',
|
||||
'progress': 100,
|
||||
'status': 'complete',
|
||||
'details': 'Recipe cache initialization failed.',
|
||||
'scanner_type': 'recipe',
|
||||
'pageType': 'recipes',
|
||||
})
|
||||
finally:
|
||||
# Mark initialization as complete regardless of outcome
|
||||
self._is_initializing = False
|
||||
|
||||
def _initialize_recipe_cache_sync(self):
|
||||
"""Synchronous version of recipe cache initialization for thread pool execution.
|
||||
@@ -1505,7 +1547,7 @@ class RecipeScanner:
|
||||
self._cache.raw_data = recipes
|
||||
self._update_folder_metadata(self._cache)
|
||||
self._sort_cache_sync()
|
||||
# Backfill source_path from JSON files if missing (schema migration)
|
||||
# Backfill source_path from JSON files if missing (one-shot schema migration)
|
||||
if self._backfill_source_path_if_needed(recipes, json_paths):
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
self._persistent_cache.save_cache(
|
||||
@@ -1532,7 +1574,7 @@ class RecipeScanner:
|
||||
self._cache.raw_data = recipes
|
||||
self._update_folder_metadata(self._cache)
|
||||
self._sort_cache_sync()
|
||||
# Backfill source_path from JSON files if missing (schema migration)
|
||||
# Backfill source_path from JSON files if missing (one-shot schema migration)
|
||||
self._backfill_source_path_if_needed(recipes, json_paths)
|
||||
self._cache.image_id_map = self._build_image_id_map()
|
||||
# Persist updated cache
|
||||
@@ -1669,6 +1711,9 @@ class RecipeScanner:
|
||||
|
||||
return recipes, changed, json_paths
|
||||
|
||||
# Metadata key recording that the one-shot source_path backfill has run.
|
||||
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
|
||||
|
||||
def _backfill_source_path_if_needed(
|
||||
self,
|
||||
recipes: List[Dict[str, Any]],
|
||||
@@ -1676,8 +1721,21 @@ class RecipeScanner:
|
||||
) -> bool:
|
||||
"""Backfill source_path from recipe JSON files if missing from cache.
|
||||
|
||||
This is a one-shot schema migration: once it has run, a completion
|
||||
marker is stored in the persistent cache metadata and later startups
|
||||
skip it entirely. Recipes without a source_path in their JSON file
|
||||
would otherwise be re-read and re-parsed on every startup. New or
|
||||
changed recipe files still get source_path from the normal parse path
|
||||
during reconciliation.
|
||||
|
||||
Returns True if any recipes were updated (caller should persist cache).
|
||||
"""
|
||||
cache = self._persistent_cache
|
||||
if (
|
||||
cache is not None
|
||||
and cache.get_metadata_value(self._SOURCE_PATH_BACKFILL_MARKER) == "1"
|
||||
):
|
||||
return False
|
||||
updated = False
|
||||
for recipe in recipes:
|
||||
if recipe.get("source_path"):
|
||||
@@ -1695,6 +1753,8 @@ class RecipeScanner:
|
||||
updated = True
|
||||
except Exception:
|
||||
pass
|
||||
if cache is not None:
|
||||
cache.set_metadata_value(self._SOURCE_PATH_BACKFILL_MARKER, "1")
|
||||
return updated
|
||||
|
||||
def _full_directory_scan_sync(
|
||||
@@ -1731,6 +1791,23 @@ class RecipeScanner:
|
||||
|
||||
return recipes, json_paths
|
||||
|
||||
@staticmethod
|
||||
def _detect_has_workflow(image_path: Optional[str]) -> bool:
|
||||
"""Detect whether the recipe image embeds a ComfyUI workflow.
|
||||
|
||||
Reuses ``ExifUtils._load_structured_metadata`` so the metadata parsing
|
||||
stays in one place. Any failure (missing/corrupt image, unsupported
|
||||
format, unexpected exception) maps to ``False`` and never propagates —
|
||||
recipe loading must remain resilient.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return False
|
||||
try:
|
||||
metadata = ExifUtils._load_structured_metadata(image_path)
|
||||
return bool(metadata.get("workflow"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _load_recipe_file_sync(self, recipe_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load a single recipe file synchronously.
|
||||
|
||||
@@ -1787,6 +1864,19 @@ class RecipeScanner:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to persist repair for {recipe_path}: {e}")
|
||||
|
||||
# Detect embedded ComfyUI workflow and persist when it changed
|
||||
if "has_workflow" not in recipe_data:
|
||||
has_workflow = self._detect_has_workflow(recipe_data.get("file_path"))
|
||||
if has_workflow != recipe_data.get("has_workflow"):
|
||||
recipe_data["has_workflow"] = has_workflow
|
||||
try:
|
||||
with open(recipe_path, "w", encoding="utf-8") as f:
|
||||
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to persist has_workflow for {recipe_path}: {e}"
|
||||
)
|
||||
|
||||
# Track folder placement relative to recipes directory
|
||||
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
|
||||
recipe_path
|
||||
@@ -2225,21 +2315,28 @@ class RecipeScanner:
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
|
||||
"""Get cached recipe data, refresh if needed"""
|
||||
# If a background initialization is in progress, wait for it to
|
||||
# complete so callers never observe the placeholder empty cache.
|
||||
initialization_task = self._initialization_task
|
||||
if (
|
||||
self._is_initializing
|
||||
and not force_refresh
|
||||
and initialization_task is not None
|
||||
and initialization_task is not asyncio.current_task()
|
||||
and not initialization_task.done()
|
||||
):
|
||||
try:
|
||||
await initialization_task
|
||||
except Exception:
|
||||
# Initialization failures are logged by the task itself; fall
|
||||
# through and return whatever cache state we have.
|
||||
pass
|
||||
|
||||
# If cache is already initialized and no refresh is needed, return it immediately
|
||||
if self._cache is not None and not force_refresh:
|
||||
self._update_folder_metadata()
|
||||
return cast(RecipeCache, self._cache)
|
||||
|
||||
# If another initialization is already in progress, wait for it to complete
|
||||
if self._is_initializing and not force_refresh:
|
||||
return self._cache or RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
|
||||
# If force refresh is requested, re-scan in a thread pool to avoid
|
||||
# blocking the event loop (which is shared with ComfyUI).
|
||||
if force_refresh:
|
||||
@@ -2472,6 +2569,13 @@ class RecipeScanner:
|
||||
if path_updated:
|
||||
self._write_recipe_file(recipe_path, recipe_data)
|
||||
|
||||
# Detect embedded ComfyUI workflow and persist when it changed
|
||||
if "has_workflow" not in recipe_data:
|
||||
has_workflow = self._detect_has_workflow(recipe_data.get("file_path"))
|
||||
if has_workflow != recipe_data.get("has_workflow"):
|
||||
recipe_data["has_workflow"] = has_workflow
|
||||
self._write_recipe_file(recipe_path, recipe_data)
|
||||
|
||||
# Track folder placement relative to recipes directory
|
||||
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
|
||||
recipe_path
|
||||
@@ -2911,6 +3015,43 @@ class RecipeScanner:
|
||||
|
||||
return lora
|
||||
|
||||
def _compute_availability_statuses(self, recipe: Dict[str, Any]) -> Set[str]:
|
||||
"""Compute the LoRA availability status set for a recipe.
|
||||
|
||||
Returns ``{"ready"}`` when every non-excluded LoRA resolves to the
|
||||
local library (recipes without LoRAs count as ready); otherwise a
|
||||
subset of ``{"missing", "deleted"}``. Uses the same inLibrary
|
||||
resolution as ``_enrich_lora_entry`` (hash index with modelVersionId
|
||||
fallback) but performs only in-memory lookups.
|
||||
"""
|
||||
|
||||
statuses: Set[str] = set()
|
||||
for lora in recipe.get("loras") or []:
|
||||
if not isinstance(lora, dict) or lora.get("exclude"):
|
||||
continue
|
||||
|
||||
in_library = False
|
||||
if self._lora_scanner:
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
if hash_value:
|
||||
in_library = self._lora_scanner.has_hash(hash_value)
|
||||
elif lora.get("modelVersionId") is not None:
|
||||
in_library = (
|
||||
self._get_lora_from_version_index(lora.get("modelVersionId"))
|
||||
is not None
|
||||
)
|
||||
|
||||
if in_library:
|
||||
continue
|
||||
if lora.get("isDeleted"):
|
||||
statuses.add("deleted")
|
||||
else:
|
||||
statuses.add("missing")
|
||||
|
||||
if not statuses:
|
||||
statuses.add("ready")
|
||||
return statuses
|
||||
|
||||
def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]:
|
||||
"""Return a preview URL that is reachable from the browser."""
|
||||
|
||||
@@ -2926,13 +3067,45 @@ class RecipeScanner:
|
||||
|
||||
return normalized
|
||||
|
||||
async def get_local_lora(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lookup a local LoRA model by name."""
|
||||
async def get_local_lora(
|
||||
self, name: str, base_model: Optional[str] = None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Lookup an unambiguous local LoRA by name and optional base model."""
|
||||
|
||||
if not self._lora_scanner or not name:
|
||||
return None
|
||||
|
||||
return await self._lora_scanner.get_model_info_by_name(name)
|
||||
return await self._lora_scanner.get_model_info_by_name(
|
||||
name, require_unique=True, base_model=base_model
|
||||
)
|
||||
|
||||
async def find_local_loras_by_name(
|
||||
self, name: str, base_model: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return every local LoRA matching ``name`` (used to explain lookup misses)."""
|
||||
|
||||
if not self._lora_scanner or not name:
|
||||
return []
|
||||
|
||||
return await self._lora_scanner.find_models_by_name(name, base_model=base_model)
|
||||
|
||||
async def get_local_lora_by_hash(self, hash_value: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lookup a local LoRA through the scanner's hash index."""
|
||||
|
||||
if not self._lora_scanner or not hash_value:
|
||||
return None
|
||||
|
||||
file_path = self._lora_scanner.get_path_by_hash(hash_value)
|
||||
if not file_path:
|
||||
return None
|
||||
|
||||
target_path = os.path.normcase(os.path.abspath(file_path))
|
||||
cached_data = await self._lora_scanner.get_cached_data()
|
||||
for model in cached_data.raw_data:
|
||||
model_path = model.get("file_path")
|
||||
if model_path and os.path.normcase(os.path.abspath(model_path)) == target_path:
|
||||
return model
|
||||
return None
|
||||
|
||||
async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Lookup a local checkpoint model by name."""
|
||||
@@ -3146,6 +3319,22 @@ class RecipeScanner:
|
||||
if not matches_exclude(item.get("tags"))
|
||||
]
|
||||
|
||||
# Filter by LoRA availability status
|
||||
availability = filters.get("lora_availability")
|
||||
if availability:
|
||||
selected = {
|
||||
status
|
||||
for status in availability
|
||||
if status in _VALID_LORA_AVAILABILITY_STATUSES
|
||||
}
|
||||
# Selecting every status (or none) means no filtering.
|
||||
if 0 < len(selected) < len(_VALID_LORA_AVAILABILITY_STATUSES):
|
||||
filtered_data = [
|
||||
item
|
||||
for item in filtered_data
|
||||
if self._compute_availability_statuses(item) & selected
|
||||
]
|
||||
|
||||
# Apply sorting if not already handled by pre-sorted cache
|
||||
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
|
||||
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||
@@ -3272,6 +3461,13 @@ class RecipeScanner:
|
||||
# Format the recipe with all needed information
|
||||
formatted_recipe = {**merged_recipe}
|
||||
|
||||
# Fallback for recipes saved before has_workflow existed: detect once
|
||||
# on demand so the modal button works without a rescan.
|
||||
if "has_workflow" not in formatted_recipe:
|
||||
formatted_recipe["has_workflow"] = self._detect_has_workflow(
|
||||
formatted_recipe.get("file_path")
|
||||
)
|
||||
|
||||
# Format file path to URL
|
||||
if "file_path" in formatted_recipe:
|
||||
formatted_recipe["file_url"] = self._format_file_url(
|
||||
@@ -3590,9 +3786,6 @@ class RecipeScanner:
|
||||
|
||||
syntax_parts: List[str] = []
|
||||
for lora in loras:
|
||||
if lora.get("isDeleted", False):
|
||||
continue
|
||||
|
||||
file_name = None
|
||||
folder = ""
|
||||
hash_value = (lora.get("hash") or "").lower()
|
||||
@@ -3627,6 +3820,8 @@ class RecipeScanner:
|
||||
break
|
||||
|
||||
if not file_name:
|
||||
if lora.get("isDeleted", False):
|
||||
continue
|
||||
file_name = lora.get("file_name", "unknown-lora")
|
||||
folder = lora.get("folder", "")
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ class RecipePersistenceService:
|
||||
"loras": loras_data,
|
||||
"gen_params": gen_params,
|
||||
"fingerprint": fingerprint,
|
||||
"has_workflow": self._detect_has_workflow(normalized_image_path),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
@@ -426,8 +427,21 @@ class RecipePersistenceService:
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_base_model = json.load(file_obj).get("base_model", "")
|
||||
|
||||
target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model)
|
||||
if not target_lora:
|
||||
matches = await recipe_scanner.find_local_loras_by_name(target_name)
|
||||
if len(matches) > 1:
|
||||
raise RecipeValidationError(
|
||||
f"Multiple local LoRAs match '{target_name}'; "
|
||||
"include the folder path to disambiguate"
|
||||
)
|
||||
if len(matches) == 1:
|
||||
raise RecipeValidationError(
|
||||
f"Local LoRA '{target_name}' has a different base model than the recipe"
|
||||
)
|
||||
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
|
||||
|
||||
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
|
||||
@@ -602,6 +616,9 @@ class RecipePersistenceService:
|
||||
if key not in ["checkpoint", "loras"]
|
||||
},
|
||||
"loras_stack": lora_stack,
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
@@ -626,6 +643,20 @@ class RecipePersistenceService:
|
||||
|
||||
# Helper methods ---------------------------------------------------
|
||||
|
||||
def _detect_has_workflow(self, image_path: str) -> bool:
|
||||
"""Detect whether the saved recipe image embeds a ComfyUI workflow.
|
||||
|
||||
Extraction failures (missing file, corrupt image, unsupported format)
|
||||
map to ``False`` and never propagate, mirroring the scanner's behavior.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return False
|
||||
try:
|
||||
metadata = self._exif_utils._load_structured_metadata(image_path)
|
||||
return bool(metadata.get("workflow"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _build_widget_checkpoint_entry(
|
||||
self,
|
||||
recipe_scanner,
|
||||
|
||||
@@ -34,6 +34,8 @@ from ..utils.settings_paths import (
|
||||
APP_NAME,
|
||||
ensure_settings_file,
|
||||
get_legacy_settings_path,
|
||||
get_settings_dir_override,
|
||||
is_settings_dir_pinned,
|
||||
)
|
||||
from ..utils.tag_priorities import (
|
||||
PriorityTagEntry,
|
||||
@@ -156,7 +158,10 @@ class SettingsManager:
|
||||
self._check_environment_variables()
|
||||
self._collect_configuration_warnings()
|
||||
|
||||
if os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1":
|
||||
if (
|
||||
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
|
||||
and not is_settings_dir_pinned()
|
||||
):
|
||||
if not self.settings.get("use_portable_settings"):
|
||||
self.settings["use_portable_settings"] = True
|
||||
self._save_settings()
|
||||
@@ -1641,6 +1646,15 @@ class SettingsManager:
|
||||
def _prepare_portable_switch(self, use_portable: bool) -> None:
|
||||
"""Prepare switching the settings storage location."""
|
||||
|
||||
if is_settings_dir_pinned():
|
||||
logger.info(
|
||||
"Portable-mode switch ignored: settings directory is pinned via "
|
||||
"%s/--settings-path (%s)",
|
||||
"LORA_MANAGER_SETTINGS_DIR",
|
||||
get_settings_dir_override(),
|
||||
)
|
||||
return
|
||||
|
||||
legacy_path = get_legacy_settings_path()
|
||||
user_dir = self._get_user_config_directory()
|
||||
user_settings_path = os.path.join(user_dir, "settings.json")
|
||||
|
||||
@@ -13,6 +13,15 @@ from platformdirs import user_config_dir
|
||||
|
||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
|
||||
|
||||
# Explicit settings-directory override. Setting this (env var, or standalone's
|
||||
# ``--settings-path`` which publishes it) pins the settings location: settings.json,
|
||||
# cache/, wildcards/, backups/, logs/, stats/ all resolve under this directory,
|
||||
# bypassing portable mode and the platform user config dir. Useful for sandboxed
|
||||
# development/E2E runs that must not touch the real user data or the project root.
|
||||
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
|
||||
_settings_dir_override: Optional[str] = None
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -22,6 +31,51 @@ def get_project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
def _normalize_settings_dir(path: str) -> str:
|
||||
"""Expand ``~`` and absolutize a user-supplied settings directory."""
|
||||
|
||||
return os.path.abspath(os.path.expanduser(path))
|
||||
|
||||
|
||||
def set_settings_dir_override(path: Optional[str]) -> Optional[str]:
|
||||
"""Set or clear the programmatic settings-directory override.
|
||||
|
||||
Args:
|
||||
path: Absolute/relative directory to pin, or ``None`` to clear the
|
||||
override. ``~`` is expanded and the path absolutized.
|
||||
|
||||
Returns:
|
||||
The previous override value (``None`` when none was active).
|
||||
"""
|
||||
|
||||
global _settings_dir_override
|
||||
previous = _settings_dir_override
|
||||
_settings_dir_override = (
|
||||
_normalize_settings_dir(path) if path else None
|
||||
)
|
||||
return previous
|
||||
|
||||
|
||||
def get_settings_dir_override() -> Optional[str]:
|
||||
"""Return the active explicit settings-directory override, if any.
|
||||
|
||||
The ``LORA_MANAGER_SETTINGS_DIR`` environment variable takes precedence over
|
||||
the programmatic override so that standalone's ``--settings-path`` (which
|
||||
publishes itself through the environment) wins over embedded callers.
|
||||
"""
|
||||
|
||||
env_path = os.environ.get(SETTINGS_DIR_ENV)
|
||||
if env_path:
|
||||
return _normalize_settings_dir(env_path)
|
||||
return _settings_dir_override
|
||||
|
||||
|
||||
def is_settings_dir_pinned() -> bool:
|
||||
"""Return ``True`` when an explicit settings-directory override is active."""
|
||||
|
||||
return get_settings_dir_override() is not None
|
||||
|
||||
|
||||
def get_legacy_settings_path() -> str:
|
||||
"""Return the legacy location of ``settings.json`` within the project tree."""
|
||||
|
||||
@@ -31,6 +85,11 @@ def get_legacy_settings_path() -> str:
|
||||
def get_settings_dir(create: bool = True) -> str:
|
||||
"""Return the user configuration directory for the application.
|
||||
|
||||
An explicit override (``LORA_MANAGER_SETTINGS_DIR`` or
|
||||
:func:`set_settings_dir_override`) takes precedence. Otherwise the portable
|
||||
project-root ``settings.json`` is used when enabled, falling back to the
|
||||
platform-specific user configuration directory.
|
||||
|
||||
Args:
|
||||
create: Whether to create the directory if it does not already exist.
|
||||
|
||||
@@ -38,11 +97,15 @@ def get_settings_dir(create: bool = True) -> str:
|
||||
The absolute path to the user configuration directory.
|
||||
"""
|
||||
|
||||
legacy_path = get_legacy_settings_path()
|
||||
if _should_use_portable_settings(legacy_path, _LOGGER):
|
||||
config_dir = os.path.dirname(legacy_path)
|
||||
override = get_settings_dir_override()
|
||||
if override:
|
||||
config_dir = override
|
||||
else:
|
||||
config_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
legacy_path = get_legacy_settings_path()
|
||||
if _should_use_portable_settings(legacy_path, _LOGGER):
|
||||
config_dir = os.path.dirname(legacy_path)
|
||||
else:
|
||||
config_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
|
||||
if create and config_dir:
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
@@ -58,9 +121,14 @@ def get_settings_file_path(create_dir: bool = True) -> str:
|
||||
def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
"""Ensure the settings file resides in the user configuration directory.
|
||||
|
||||
If a legacy ``settings.json`` is detected in the project root it is migrated to
|
||||
the platform-specific user configuration folder. The caller receives the path
|
||||
to the settings file irrespective of whether a migration was needed.
|
||||
An explicit override (``LORA_MANAGER_SETTINGS_DIR`` or
|
||||
:func:`set_settings_dir_override`) pins the settings file to
|
||||
``<override>/settings.json`` and skips legacy migration entirely.
|
||||
|
||||
Otherwise, if a legacy ``settings.json`` is detected in the project root it is
|
||||
migrated to the platform-specific user configuration folder. The caller
|
||||
receives the path to the settings file irrespective of whether a migration was
|
||||
needed.
|
||||
|
||||
Args:
|
||||
logger: Optional logger used for migration messages. Falls back to a
|
||||
@@ -71,6 +139,12 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
"""
|
||||
|
||||
logger = logger or _LOGGER
|
||||
|
||||
override = get_settings_dir_override()
|
||||
if override:
|
||||
os.makedirs(override, exist_ok=True)
|
||||
return os.path.join(override, "settings.json")
|
||||
|
||||
legacy_path = get_legacy_settings_path()
|
||||
|
||||
if _should_use_portable_settings(legacy_path, logger):
|
||||
|
||||
+47
-1
@@ -8,12 +8,36 @@ from typing import Any, cast
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from py.middleware.cache_middleware import cache_control
|
||||
from py.middleware.error_middleware import api_json_error
|
||||
from py.utils.settings_paths import ensure_settings_file
|
||||
from py.utils.settings_paths import SETTINGS_DIR_ENV, ensure_settings_file
|
||||
|
||||
# Set environment variable to indicate standalone mode
|
||||
os.environ["LORA_MANAGER_STANDALONE"] = "1"
|
||||
|
||||
|
||||
def _apply_settings_dir_from_argv(argv=None):
|
||||
"""Apply ``--settings-path`` from argv before any settings resolution runs.
|
||||
|
||||
Standalone resolves the settings location at import time (session logging and
|
||||
the settings manager run before ``main()`` parses arguments), so pre-scan
|
||||
argv and publish the explicit directory through ``LORA_MANAGER_SETTINGS_DIR``,
|
||||
which ``py.utils.settings_paths`` honors in both standalone and plugin modes.
|
||||
|
||||
Args:
|
||||
argv: Argument list to scan; defaults to ``sys.argv[1:]``.
|
||||
"""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
for index, arg in enumerate(args):
|
||||
if arg == "--settings-path" and index + 1 < len(args):
|
||||
os.environ[SETTINGS_DIR_ENV] = args[index + 1]
|
||||
return
|
||||
if arg.startswith("--settings-path="):
|
||||
os.environ[SETTINGS_DIR_ENV] = arg.split("=", 1)[1]
|
||||
return
|
||||
|
||||
|
||||
_apply_settings_dir_from_argv()
|
||||
|
||||
|
||||
# Create mock modules for py/nodes directory - add this before any other imports
|
||||
def mock_nodes_directory():
|
||||
"""Create mock modules for all Python files in the py/nodes directory"""
|
||||
@@ -395,6 +419,16 @@ def parse_args():
|
||||
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
|
||||
# parser.add_argument("--checkpoints", type=str, nargs="+",
|
||||
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
|
||||
parser.add_argument(
|
||||
"--settings-path",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Explicit settings directory: settings.json, cache/, wildcards/, "
|
||||
"backups/, logs/, stats/ all live under this directory. Overrides portable "
|
||||
"mode and the default user config dir. Equivalent to the "
|
||||
"LORA_MANAGER_SETTINGS_DIR environment variable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
type=str,
|
||||
@@ -414,6 +448,18 @@ async def main():
|
||||
"""Main entry point for standalone mode"""
|
||||
args = parse_args()
|
||||
|
||||
# Normalize and validate the explicit settings directory (the pre-import
|
||||
# argv scan already applied it; re-derive so --settings-path wins over any
|
||||
# pre-existing LORA_MANAGER_SETTINGS_DIR and is canonicalized the same way).
|
||||
if args.settings_path:
|
||||
settings_dir = os.path.abspath(os.path.expanduser(args.settings_path))
|
||||
if os.path.exists(settings_dir) and not os.path.isdir(settings_dir):
|
||||
logger.error(
|
||||
"--settings-path '%s' exists but is not a directory.", settings_dir
|
||||
)
|
||||
return
|
||||
os.environ[SETTINGS_DIR_ENV] = settings_dir
|
||||
|
||||
# Set log level (verbose flag overrides to DEBUG)
|
||||
log_level = "DEBUG" if args.verbose else args.log_level
|
||||
logging.getLogger().setLevel(getattr(logging, log_level))
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
height: 48px;
|
||||
height: var(--header-height, 48px);
|
||||
/* Reduced height */
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
@@ -77,41 +77,84 @@
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* File Input Styles */
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-1);
|
||||
.import-description {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.file-input-button {
|
||||
/* Unified Drop Zone */
|
||||
.import-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-weight: 500;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-input-button:hover {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
.import-drop-zone:hover,
|
||||
.import-drop-zone:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-input-wrapper:hover .file-input-button {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
.import-drop-zone.drag-over {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.08);
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 1.8em;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.drop-zone-primary {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.drop-zone-filename {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Divider between drop zone and URL input */
|
||||
.import-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.import-divider::before,
|
||||
.import-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Loading state for the fetch button */
|
||||
#fetchImageBtn.loading {
|
||||
opacity: 0.8;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* Inputs sit flush against the scrollable step's content edge; an outset
|
||||
outline (global offset: 2px) gets clipped by overflow-x. Draw the focus
|
||||
outline inset instead so the full ring stays visible. */
|
||||
#importModal input:focus-visible,
|
||||
#importModal select:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Recipe Details Layout */
|
||||
|
||||
@@ -68,6 +68,39 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Destructive modal action: ghost icon button right-anchored by its own auto
|
||||
margin, revealing the danger color only on hover/focus. Shared by the model
|
||||
modal and the recipe modal. */
|
||||
.modal-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-delete-btn:hover,
|
||||
.modal-delete-btn:focus-visible {
|
||||
color: var(--lora-error);
|
||||
border-color: var(--lora-error);
|
||||
background: oklch(from var(--lora-error) l c h / 0.08);
|
||||
}
|
||||
|
||||
.modal-delete-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* When license icons directly precede the delete button, they carry the auto
|
||||
margin instead, so the [license][delete] cluster stays right-anchored as
|
||||
one group with the delete button flush at the right edge and no split gap. */
|
||||
.modal-header-actions .license-restrictions {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -76,6 +109,11 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modal-header-actions .license-restrictions + .modal-delete-btn,
|
||||
.modal-header-actions .license-permissions + .modal-delete-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.license-restrictions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -216,6 +254,62 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Hashes footnote — borderless full-width muted line; reads as a footnote
|
||||
to the file info grid rather than a peer field */
|
||||
.hash-footnote {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.hash-footnote .hash-entry {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-kind {
|
||||
font-size: 0.7em;
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hash-footnote .model-hash-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-sep {
|
||||
opacity: 0.3;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.35;
|
||||
font-size: 0.7em;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Toggle button — icon only, inline with the label */
|
||||
.notes-toggle-btn {
|
||||
display: none; /* shown by JS when content exceeds threshold */
|
||||
|
||||
@@ -4,19 +4,268 @@
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
/* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
|
||||
|
||||
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
|
||||
.gallery-indicator-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.gallery-preview-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.gallery-preview-thumb img,
|
||||
.gallery-preview-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-indicator-bar .gallery-show-btn {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.gallery-indicator-bar .gallery-import-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Expanded gallery toolbar */
|
||||
.gallery-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Position badge floats over the main media, bottom-right */
|
||||
.gallery-position-badge {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
bottom: var(--space-2);
|
||||
z-index: 6;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* While the gallery is expanded the thumbnail strip sits in the modal's
|
||||
bottom-right corner, where the back-to-top button would overlap it */
|
||||
.modal-content.showcase-expanded .back-to-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-toolbar .gallery-import-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gallery-show-btn,
|
||||
.gallery-import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.gallery-show-btn:hover,
|
||||
.gallery-import-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.nsfw-filter-notification {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Main viewer — the container hugs the active media's aspect ratio
|
||||
(--media-aspect = width/height, set per item) so no dead space remains.
|
||||
overflow: hidden also clips the hoisted metadata panel while it is
|
||||
translated below the bottom edge, so it never extends the modal's
|
||||
scrollable height (which caused a scroll jump when it appeared) */
|
||||
.gallery-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.main-media-container {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
|
||||
aspect-ratio: var(--media-aspect, 1.3333);
|
||||
max-height: min(75vh, 800px);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel.collapsed {
|
||||
max-height: 0;
|
||||
.main-media-container .media-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
.main-media-container .media-wrapper img,
|
||||
.main-media-container .media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Nav buttons float over the media, visible on hover */
|
||||
.gallery-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 6;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-nav.prev {
|
||||
left: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-nav.next {
|
||||
right: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-main:hover .gallery-nav,
|
||||
.gallery-nav:focus-visible {
|
||||
opacity: 0.9;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gallery-nav:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Thumbnail strip */
|
||||
.gallery-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.gallery-thumb:hover {
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.gallery-thumb.active {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-media.blurred {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-video-badge,
|
||||
.gallery-thumb .thumb-nsfw-badge {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-nsfw-badge {
|
||||
top: 3px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.gallery-strip::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.gallery-strip::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Inline import zone toggled from the toolbar */
|
||||
.gallery-import-zone {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-import-zone.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-import-zone .example-import-area {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.media-wrapper {
|
||||
@@ -31,16 +280,6 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.media-wrapper img,
|
||||
.media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.no-examples {
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
@@ -48,11 +287,6 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Adjust the media wrapper for tab system */
|
||||
#showcase-tab .carousel-container {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Add styles for blurred showcase content */
|
||||
.nsfw-media-wrapper {
|
||||
position: relative;
|
||||
@@ -217,6 +451,24 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
|
||||
.gallery-main > .image-metadata-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 7;
|
||||
max-height: 60%;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gallery-main > .image-metadata-panel.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 0.98;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Adjust to dark theme */
|
||||
[data-theme="dark"] .image-metadata-panel {
|
||||
background: var(--card-bg);
|
||||
@@ -388,31 +640,6 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Scroll Indicator */
|
||||
.scroll-indicator {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.scroll-indicator:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scroll-indicator span {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.lazy {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
z-index: var(--z-modal);
|
||||
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||
}
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Darker than --modal-backdrop-bg to stress destructive actions, but keeps the shared blur */
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
|
||||
|
||||
@@ -514,6 +514,7 @@
|
||||
background: oklch(var(--lora-accent) / 0.18);
|
||||
color: var(--lora-accent);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
@@ -603,6 +604,51 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-option-radio input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Files already in the library are greyed out and not clickable */
|
||||
.file-option.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-option.disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-option.disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Options of the other routing group are temporarily disabled once a
|
||||
selection is made (mixed-type multi-select is not allowed) */
|
||||
.file-option.group-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-option.group-disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-option.group-disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-tag.in-library {
|
||||
background: oklch(var(--lora-accent) / 0.15);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.file-option-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Header row: title + nav controls. Padding reserves space for the
|
||||
absolutely positioned nav buttons (see .modal-nav-controls in lora-modal.css). */
|
||||
.recipe-modal-header-row {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-right: 152px;
|
||||
}
|
||||
|
||||
/* 56px right offset keeps the nav buttons clear of the close (x) button,
|
||||
which is absolutely positioned at the modal-content top-right corner. */
|
||||
.recipe-modal-header-row .modal-nav-controls {
|
||||
right: 56px;
|
||||
}
|
||||
|
||||
#recipeTagsContainer {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -107,12 +122,19 @@
|
||||
#recipeModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Content-sized shell: grows with content up to the viewport limit, inner panes scroll past it */
|
||||
box-sizing: border-box; /* Include padding/border so the shell never exceeds the viewport */
|
||||
width: min(1600px, 94vw);
|
||||
max-width: min(1600px, 94vw);
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#recipeModal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr) 420px;
|
||||
gap: var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -174,19 +196,22 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Top Section: Preview and Gen Params */
|
||||
.recipe-top-section {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
/* Left Column: Preview */
|
||||
.recipe-media-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
|
||||
}
|
||||
|
||||
/* Recipe Preview */
|
||||
.recipe-preview-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
box-sizing: border-box; /* Keep the 1px border inside the column width */
|
||||
height: auto;
|
||||
max-height: 42vh;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
@@ -196,18 +221,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 42vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.recipe-preview-media {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 42vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -340,9 +366,10 @@
|
||||
|
||||
/* Generation Parameters */
|
||||
.recipe-gen-params {
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gen-params-header-row {
|
||||
@@ -399,8 +426,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
@@ -453,8 +478,6 @@
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -526,14 +549,12 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Bottom Section: Resources */
|
||||
/* Right Column: Resources */
|
||||
.recipe-bottom-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
@@ -1010,18 +1031,43 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 1500px) {
|
||||
#recipeModal .modal-body {
|
||||
grid-template-columns: 300px minmax(0, 1fr) 380px;
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
#recipeModal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
.recipe-media-column {
|
||||
overflow-y: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-preview-container,
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video,
|
||||
.recipe-preview-media {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.recipe-loras-list {
|
||||
max-height: 45vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,19 +1091,11 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
.recipe-preview-container,
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video,
|
||||
.recipe-preview-media {
|
||||
max-height: 32vh;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
@@ -1070,7 +1108,6 @@
|
||||
}
|
||||
|
||||
.param-content {
|
||||
max-height: 90px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -1083,10 +1120,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9em;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.toast.toast-copy.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
@@ -19,14 +19,15 @@
|
||||
right: 20px;
|
||||
left: auto;
|
||||
transform: translateX(120%);
|
||||
min-width: 300px;
|
||||
box-sizing: border-box;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: var(--shadow-toast);
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
z-index: var(--z-toast);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -130,7 +131,6 @@
|
||||
.toast {
|
||||
width: calc(100% - 40px);
|
||||
max-width: none;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +166,17 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Toast Container for stacked notifications */
|
||||
/* Toast Container for stacked notifications (top-right, flush below the header) */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: var(--header-height, 48px); /* Start right below the fixed header */
|
||||
right: 0;
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
padding: 8px 20px 0; /* Small breathing room below the header */
|
||||
pointer-events: none; /* Allow clicking through the container */
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
@@ -215,8 +216,7 @@
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.toast-container {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
--shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
--shadow-inset-top: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
|
||||
--modal-backdrop-blur: 6px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
@@ -1206,9 +1206,13 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUnifiedFolderTree() {
|
||||
async fetchUnifiedFolderTree(options = {}) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||
const { includeEmpty = false } = options;
|
||||
const url = includeEmpty
|
||||
? `${this.apiConfig.endpoints.unifiedFolderTree}?include_empty=1`
|
||||
: this.apiConfig.endpoints.unifiedFolderTree;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch unified folder tree`);
|
||||
}
|
||||
@@ -1337,6 +1341,9 @@ export class BaseModelApiClient {
|
||||
if (pageState.searchOptions.creator !== undefined) {
|
||||
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||
}
|
||||
if (pageState.searchOptions.hash !== undefined) {
|
||||
params.append('search_hash', pageState.searchOptions.hash.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,28 @@ export async function fetchRecipeDetails(recipeId) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function sendRecipeWorkflow(recipeId) {
|
||||
if (!recipeId) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const encodedRecipeId = encodeURIComponent(recipeId);
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}/send-workflow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: result.error || response.statusText };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recipes with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
@@ -152,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add LoRA availability filter (no statuses selected = no filtering)
|
||||
if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) {
|
||||
params.append('lora_availability', pageState.filters.loraAvailability.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
|
||||
@@ -6,7 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { MODEL_CONFIG } from '../../api/apiConfig.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
|
||||
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
|
||||
import { classifyModelRelinkUrl } from '../../utils/civitaiUtils.js';
|
||||
|
||||
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
|
||||
export const ModelContextMenuMixin = {
|
||||
@@ -106,6 +106,17 @@ export const ModelContextMenuMixin = {
|
||||
},
|
||||
|
||||
// Civitai re-linking methods
|
||||
getModelTypePrefix() {
|
||||
// Map the mixin model type to its API route prefix; the relink route
|
||||
// exists for all model types via COMMON_ROUTE_DEFINITIONS.
|
||||
const prefixMap = {
|
||||
lora: 'loras',
|
||||
checkpoint: 'checkpoints',
|
||||
embedding: 'embeddings'
|
||||
};
|
||||
return prefixMap[this.modelType] || 'loras';
|
||||
},
|
||||
|
||||
showRelinkCivitaiModal() {
|
||||
const filePath = this.currentCard.dataset.filepath;
|
||||
if (!filePath) return;
|
||||
@@ -123,43 +134,55 @@ export const ModelContextMenuMixin = {
|
||||
// Create new bound handler
|
||||
this._boundRelinkHandler = async () => {
|
||||
const url = urlInput.value.trim();
|
||||
const { modelId, modelVersionId } = this.extractModelVersionId(url);
|
||||
|
||||
if (!modelId) {
|
||||
errorDiv.textContent = 'Invalid URL format. Must include model ID.';
|
||||
const { source, modelId, modelVersionId } = classifyModelRelinkUrl(url);
|
||||
|
||||
if (!source || !modelId) {
|
||||
errorDiv.textContent = 'Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
errorDiv.textContent = '';
|
||||
modalManager.closeModal('relinkCivitaiModal');
|
||||
|
||||
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
|
||||
|
||||
const endpoint = this.modelType === 'checkpoint' ?
|
||||
'/api/lm/checkpoints/relink-civitai' :
|
||||
'/api/lm/loras/relink-civitai';
|
||||
|
||||
const isCivArchive = source === 'civarchive';
|
||||
state.loadingManager.showSimpleLoading(
|
||||
isCivArchive ? 'Re-linking via CivitArchive...' : 'Re-linking to Civitai...'
|
||||
);
|
||||
|
||||
const endpoint = `/api/lm/${this.getModelTypePrefix()}/relink-civitai`;
|
||||
|
||||
const payload = {
|
||||
file_path: filePath,
|
||||
model_id: modelId,
|
||||
model_version_id: modelVersionId
|
||||
};
|
||||
// Omitted source keeps backend default-provider behaviour; only
|
||||
// civarchive pins the provider explicitly.
|
||||
if (isCivArchive) {
|
||||
payload.source = source;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
file_path: filePath,
|
||||
model_id: modelId,
|
||||
model_version_id: modelVersionId
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to re-link model: ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
if (data.success) {
|
||||
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
|
||||
showToast(
|
||||
isCivArchive ? 'toast.contextMenu.linkCivArchSuccess' : 'toast.contextMenu.relinkSuccess',
|
||||
{},
|
||||
'success'
|
||||
);
|
||||
// Reload the current view to show updated data
|
||||
await this.resetAndReload();
|
||||
} else {
|
||||
@@ -255,10 +278,6 @@ export const ModelContextMenuMixin = {
|
||||
setTimeout(() => urlInput.focus(), 50);
|
||||
},
|
||||
|
||||
extractModelVersionId(url) {
|
||||
return extractCivitaiModelUrlParts(url);
|
||||
},
|
||||
|
||||
parseModelId(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
|
||||
@@ -12,6 +12,7 @@ export class DuplicatesManager {
|
||||
this.duplicateGroups = [];
|
||||
this.inDuplicateMode = false;
|
||||
this.selectedForDeletion = new Set();
|
||||
this._isFindingDuplicates = false;
|
||||
this._initPromptMatchToggle();
|
||||
this._initHelpTooltip();
|
||||
}
|
||||
@@ -87,6 +88,19 @@ export class DuplicatesManager {
|
||||
}
|
||||
|
||||
async findDuplicates() {
|
||||
// Guard against re-entry: the scan can take a while on large
|
||||
// libraries, and repeated clicks would pile up identical requests
|
||||
// on the backend.
|
||||
if (this._isFindingDuplicates) {
|
||||
return false;
|
||||
}
|
||||
this._isFindingDuplicates = true;
|
||||
const triggerButton = document.querySelector('[data-action="find-duplicates"]');
|
||||
if (triggerButton) {
|
||||
triggerButton.disabled = true;
|
||||
triggerButton.classList.add('loading');
|
||||
}
|
||||
state.loadingManager?.showSimpleLoading(translate('recipes.duplicates.finding'));
|
||||
try {
|
||||
const includePrompt = this._getPromptMatchPreference();
|
||||
const endpoint = includePrompt
|
||||
@@ -96,14 +110,14 @@ export class DuplicatesManager {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to find duplicates');
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Unknown error finding duplicates');
|
||||
}
|
||||
|
||||
|
||||
this.duplicateGroups = data.duplicate_groups || [];
|
||||
|
||||
|
||||
if (this.duplicateGroups.length === 0) {
|
||||
showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
|
||||
// Keep (or enter) the duplicates view when the user is tuning
|
||||
@@ -115,13 +129,20 @@ export class DuplicatesManager {
|
||||
this.enterDuplicateMode();
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
this.enterDuplicateMode();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error finding duplicates:', error);
|
||||
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
this._isFindingDuplicates = false;
|
||||
if (triggerButton) {
|
||||
triggerButton.disabled = false;
|
||||
triggerButton.classList.remove('loading');
|
||||
}
|
||||
state.loadingManager?.hide();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+137
-117
@@ -240,9 +240,12 @@ class RecipeCard {
|
||||
|
||||
// Recipe card click event - only attach if not in duplicates mode
|
||||
if (!isDuplicatesMode) {
|
||||
card.addEventListener('click', () => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (state.bulkMode) {
|
||||
bulkManager.toggleCardSelection(card);
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
bulkManager.toggleCardSelection(card, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
this.clickHandler(this.recipe);
|
||||
@@ -339,124 +342,11 @@ class RecipeCard {
|
||||
}
|
||||
|
||||
showDeleteConfirmation() {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
const filePath = this.recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${this.recipe.title}</h3>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
deleteModal.dataset.filePath = filePath;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
showRecipeDeleteConfirmation(this.recipe);
|
||||
}
|
||||
|
||||
confirmDeleteRecipe() {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
confirmRecipeDelete(this.recipe);
|
||||
}
|
||||
|
||||
shareRecipe() {
|
||||
@@ -507,4 +397,134 @@ class RecipeCard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the delete confirmation modal for a recipe. Shared by RecipeCard and
|
||||
* RecipeModal so the flow stays identical regardless of where it starts.
|
||||
* @param {Object} recipe - The recipe to delete
|
||||
*/
|
||||
export function showRecipeDeleteConfirmation(recipe) {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = recipe.id;
|
||||
const filePath = recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const previewUrl = recipe.file_url || '/loras_static/images/no-preview.png';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${recipe.title}</h3>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
deleteModal.dataset.filePath = filePath;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => confirmRecipeDelete(recipe);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the recipe deletion after the user confirms in the delete modal.
|
||||
* @param {Object} recipe - The recipe being deleted (used for toast messaging)
|
||||
*/
|
||||
function confirmRecipeDelete(recipe) {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
export { RecipeCard };
|
||||
|
||||
@@ -4,10 +4,11 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow } from '../api/recipeApi.js';
|
||||
import { downloadManager } from '../managers/DownloadManager.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { openMediaViewer } from './shared/MediaViewer.js';
|
||||
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
|
||||
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
|
||||
import { setupTagEditMode } from './shared/ModelTags.js';
|
||||
|
||||
@@ -55,6 +56,8 @@ class RecipeModal {
|
||||
constructor() {
|
||||
this.promptEditorState = {};
|
||||
this.recipeHydrationRequestId = 0;
|
||||
this.navigationKeyHandler = null;
|
||||
this.navigationInProgress = false;
|
||||
this.resetLocalEditState();
|
||||
this.init();
|
||||
}
|
||||
@@ -120,6 +123,8 @@ class RecipeModal {
|
||||
this.setupCopyButtons();
|
||||
this.setupStripLoraToggle();
|
||||
this.setupPromptEditors();
|
||||
this.setupNavigationControls();
|
||||
this.setupDeleteControl();
|
||||
// Set up tooltip positioning handlers after DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.setupTooltipPositioning();
|
||||
@@ -164,6 +169,119 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
setupNavigationControls() {
|
||||
const prevBtn = document.getElementById('recipeNavPrevBtn');
|
||||
const nextBtn = document.getElementById('recipeNavNextBtn');
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => this.handleDirectionalNavigation('prev'));
|
||||
}
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => this.handleDirectionalNavigation('next'));
|
||||
}
|
||||
this.updateNavigationControls();
|
||||
}
|
||||
|
||||
setupDeleteControl() {
|
||||
const deleteBtn = document.getElementById('deleteRecipeBtn');
|
||||
if (deleteBtn) {
|
||||
deleteBtn.addEventListener('click', () => this.handleDeleteRecipe());
|
||||
}
|
||||
}
|
||||
|
||||
handleDeleteRecipe() {
|
||||
if (!this.currentRecipe) return;
|
||||
showRecipeDeleteConfirmation(this.currentRecipe);
|
||||
}
|
||||
|
||||
shouldIgnoreNavigationKey(event) {
|
||||
const target = event.target;
|
||||
if (!target) return false;
|
||||
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
|
||||
return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName);
|
||||
}
|
||||
|
||||
updateNavigationControls() {
|
||||
const modalElement = document.getElementById('recipeModal');
|
||||
if (!modalElement) return;
|
||||
|
||||
const prevBtn = modalElement.querySelector('#recipeNavPrevBtn');
|
||||
const nextBtn = modalElement.querySelector('#recipeNavNextBtn');
|
||||
if (!prevBtn || !nextBtn) return;
|
||||
|
||||
const scroller = state.virtualScroller;
|
||||
if (!scroller || typeof scroller.getNavigationState !== 'function') {
|
||||
prevBtn.disabled = true;
|
||||
nextBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const { hasPrev, hasNext } = scroller.getNavigationState(this.listFilePath || this.filePath || '');
|
||||
prevBtn.disabled = this.navigationInProgress || !hasPrev;
|
||||
nextBtn.disabled = this.navigationInProgress || !hasNext;
|
||||
}
|
||||
|
||||
cleanupNavigationShortcuts() {
|
||||
if (this.navigationKeyHandler) {
|
||||
document.removeEventListener('keydown', this.navigationKeyHandler);
|
||||
this.navigationKeyHandler = null;
|
||||
}
|
||||
this.navigationInProgress = false;
|
||||
}
|
||||
|
||||
setupNavigationShortcuts() {
|
||||
const modalElement = document.getElementById('recipeModal');
|
||||
if (!modalElement) return;
|
||||
|
||||
this.cleanupNavigationShortcuts();
|
||||
|
||||
this.navigationKeyHandler = (event) => {
|
||||
if (this.shouldIgnoreNavigationKey(event)) return;
|
||||
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
this.handleDirectionalNavigation('prev');
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
this.handleDirectionalNavigation('next');
|
||||
} else if (event.key === 'Delete') {
|
||||
event.preventDefault();
|
||||
this.handleDeleteRecipe();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', this.navigationKeyHandler);
|
||||
}
|
||||
|
||||
async handleDirectionalNavigation(direction) {
|
||||
if (this.navigationInProgress) return;
|
||||
|
||||
const scroller = state.virtualScroller;
|
||||
const filePath = this.listFilePath || this.filePath || '';
|
||||
|
||||
if (!filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.navigationInProgress = true;
|
||||
this.updateNavigationControls();
|
||||
|
||||
try {
|
||||
const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction);
|
||||
if (!adjacent || !adjacent.item) {
|
||||
const toastKey = direction === 'prev' ? 'toast.recipes.noPreviousRecipe' : 'toast.recipes.noNextRecipe';
|
||||
const toastFallback = direction === 'prev' ? 'No previous recipe available' : 'No next recipe available';
|
||||
showToast(toastKey, {}, 'info', toastFallback);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showRecipeDetails(adjacent.item);
|
||||
} finally {
|
||||
this.navigationInProgress = false;
|
||||
this.updateNavigationControls();
|
||||
}
|
||||
}
|
||||
|
||||
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips
|
||||
setupTooltipPositioning() {
|
||||
document.addEventListener('mouseover', (event) => {
|
||||
@@ -300,10 +418,12 @@ class RecipeModal {
|
||||
|
||||
this.syncGenerationParams(hydratedRecipe.gen_params);
|
||||
this.syncResourcesSection(hydratedRecipe);
|
||||
this.syncSourceUrlAction();
|
||||
this.syncHeaderActions();
|
||||
|
||||
// Show the modal
|
||||
modalManager.showModal('recipeModal');
|
||||
modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts());
|
||||
this.updateNavigationControls();
|
||||
this.setupNavigationShortcuts();
|
||||
|
||||
if (this.recipeId) {
|
||||
// Fire-and-forget: record this open for the "Recently Opened"
|
||||
@@ -385,6 +505,10 @@ class RecipeModal {
|
||||
nextRecipe.gen_params = preservedGenParams;
|
||||
}
|
||||
|
||||
if (fullRecipe.has_workflow !== undefined) {
|
||||
nextRecipe.has_workflow = fullRecipe.has_workflow;
|
||||
}
|
||||
|
||||
if (fullRecipe.checkpoint !== undefined) {
|
||||
nextRecipe.checkpoint = fullRecipe.checkpoint;
|
||||
} else {
|
||||
@@ -441,7 +565,7 @@ class RecipeModal {
|
||||
} else {
|
||||
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
|
||||
}
|
||||
this.syncSourceUrlAction();
|
||||
this.syncHeaderActions();
|
||||
}
|
||||
|
||||
getPreviewMediaUrl(recipe = {}) {
|
||||
@@ -509,28 +633,68 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
syncSourceUrlAction() {
|
||||
syncHeaderActions() {
|
||||
const actionsContainer = document.getElementById('recipeHeaderActions');
|
||||
if (!actionsContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionsContainer.innerHTML = '';
|
||||
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
|
||||
|
||||
// Keep the delete button as the last (rightmost) header action;
|
||||
// insertBefore with null falls back to appendChild if it is missing.
|
||||
const deleteBtn = document.getElementById('deleteRecipeBtn');
|
||||
|
||||
if (this.currentRecipe?.has_workflow === true) {
|
||||
const workflowBtn = document.createElement('button');
|
||||
workflowBtn.className = 'recipe-source-url-btn';
|
||||
workflowBtn.id = 'sendWorkflowBtn';
|
||||
workflowBtn.title = 'Send Workflow to ComfyUI';
|
||||
workflowBtn.innerHTML = '<i class="fas fa-project-diagram"></i> Send Workflow to ComfyUI';
|
||||
workflowBtn.addEventListener('click', () => {
|
||||
this.sendWorkflowToComfyUI();
|
||||
});
|
||||
actionsContainer.insertBefore(workflowBtn, deleteBtn);
|
||||
}
|
||||
|
||||
const sourcePath = this.currentRecipe?.source_path || '';
|
||||
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
|
||||
if (!isValidUrl) {
|
||||
if (isValidUrl) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'recipe-source-url-btn';
|
||||
btn.title = sourcePath;
|
||||
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(sourcePath, '_blank');
|
||||
});
|
||||
actionsContainer.insertBefore(btn, deleteBtn);
|
||||
}
|
||||
}
|
||||
|
||||
async sendWorkflowToComfyUI() {
|
||||
if (!this.recipeId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'recipe-source-url-btn';
|
||||
btn.title = sourcePath;
|
||||
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
|
||||
btn.addEventListener('click', () => {
|
||||
window.open(sourcePath, '_blank');
|
||||
});
|
||||
actionsContainer.appendChild(btn);
|
||||
try {
|
||||
const result = await sendRecipeWorkflow(this.recipeId);
|
||||
if (result?.success) {
|
||||
showToast('toast.recipes.workflowSent', {}, 'success', 'Workflow sent to ComfyUI');
|
||||
return;
|
||||
}
|
||||
|
||||
const error = result?.error || '';
|
||||
if (error === 'Standalone Mode Active') {
|
||||
showToast('toast.general.cannotInteractStandalone', {}, 'warning', 'Cannot interact with ComfyUI in standalone mode');
|
||||
} else if (error === 'no_workflow') {
|
||||
showToast('toast.recipes.workflowNoWorkflow', {}, 'warning', 'No embedded workflow found in this recipe');
|
||||
} else {
|
||||
showToast('toast.recipes.workflowSendFailed', { error }, 'error', `Failed to send workflow to ComfyUI: ${error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send workflow to ComfyUI:', error);
|
||||
showToast('toast.recipes.workflowSendFailed', { error: error.message }, 'error', `Failed to send workflow to ComfyUI: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
syncTagsDisplay(tags) {
|
||||
@@ -719,7 +883,7 @@ class RecipeModal {
|
||||
}
|
||||
}
|
||||
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
|
||||
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
|
||||
|
||||
setTimeout(() => {
|
||||
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
|
||||
@@ -1153,7 +1317,7 @@ class RecipeModal {
|
||||
// Update source URL in the UI
|
||||
this.commitField('source_path');
|
||||
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
|
||||
this.syncSourceUrlAction();
|
||||
this.syncHeaderActions();
|
||||
|
||||
// Update the current recipe object
|
||||
this.currentRecipe.source_path = newSourceUrl;
|
||||
@@ -1180,11 +1344,10 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Setup copy buttons for prompts and recipe syntax
|
||||
// Setup copy buttons for prompts and send recipe button
|
||||
setupCopyButtons() {
|
||||
const copyPromptBtn = document.getElementById('copyPromptBtn');
|
||||
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
|
||||
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
|
||||
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
|
||||
|
||||
if (copyPromptBtn) {
|
||||
@@ -1207,13 +1370,6 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
if (copyRecipeSyntaxBtn) {
|
||||
copyRecipeSyntaxBtn.addEventListener('click', () => {
|
||||
// Use backend API to get recipe syntax
|
||||
this.fetchAndCopyRecipeSyntax();
|
||||
});
|
||||
}
|
||||
|
||||
if (sendRecipeBtn) {
|
||||
sendRecipeBtn.addEventListener('click', () => {
|
||||
// Send recipe to ComfyUI workflow
|
||||
@@ -1299,35 +1455,6 @@ class RecipeModal {
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch recipe syntax from backend and copy to clipboard
|
||||
async fetchAndCopyRecipeSyntax() {
|
||||
if (!this.recipeId) {
|
||||
showToast('toast.recipes.noRecipeId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch recipe syntax from backend
|
||||
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.syntax) {
|
||||
// Use the centralized copyToClipboard utility function
|
||||
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
|
||||
} else {
|
||||
throw new Error(data.error || 'No syntax returned from server');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching recipe syntax:', error);
|
||||
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to copy text to clipboard
|
||||
copyToClipboard(text, successMessage) {
|
||||
copyToClipboard(text, successMessage);
|
||||
|
||||
@@ -9,12 +9,14 @@ import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
this.pageControls = null;
|
||||
this.pageType = null;
|
||||
this.treeData = {};
|
||||
this.folderTreeLoaded = false;
|
||||
this.selectedPath = '';
|
||||
this.expandedNodes = new Set();
|
||||
this.apiClient = null;
|
||||
@@ -252,6 +254,9 @@ export class SidebarManager {
|
||||
if (dataTransfer) {
|
||||
dataTransfer.effectAllowed = 'move';
|
||||
dataTransfer.setData('text/plain', filePaths.join(','));
|
||||
// Tag the drag as an internal card drag so preview-drop handlers on
|
||||
// other cards ignore it (no highlight, no preview replacement).
|
||||
dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(','));
|
||||
try {
|
||||
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
|
||||
} catch (error) {
|
||||
@@ -1167,13 +1172,32 @@ export class SidebarManager {
|
||||
const response = await this.apiClient.fetchModelFolders();
|
||||
this.foldersList = response.folders || [];
|
||||
}
|
||||
this.folderTreeLoaded = true;
|
||||
this.renderFolderDisplay();
|
||||
} catch (error) {
|
||||
this.folderTreeLoaded = false;
|
||||
console.error('Failed to load folder data:', error);
|
||||
this.renderEmptyState();
|
||||
}
|
||||
}
|
||||
|
||||
folderExistsInTree(path) {
|
||||
if (!path) return true;
|
||||
|
||||
if (this.displayMode === 'tree') {
|
||||
let node = this.treeData;
|
||||
for (const segment of path.split('/')) {
|
||||
if (!node || typeof node !== 'object' || !(segment in node)) {
|
||||
return false;
|
||||
}
|
||||
node = node[segment];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.foldersList.includes(path);
|
||||
}
|
||||
|
||||
renderFolderDisplay() {
|
||||
if (this.displayMode === 'tree') {
|
||||
this.renderTree();
|
||||
@@ -1805,7 +1829,31 @@ export class SidebarManager {
|
||||
restoreSelectedFolder() {
|
||||
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
|
||||
if (activeFolder && typeof activeFolder === 'string') {
|
||||
this.selectedPath = activeFolder;
|
||||
// Fall back to the root when the persisted folder no longer
|
||||
// exists in the freshly loaded tree (e.g. it was moved or
|
||||
// deleted); otherwise the grid stays empty with a phantom
|
||||
// breadcrumb. Skip validation when the tree failed to load so a
|
||||
// transient API error doesn't wipe the saved location.
|
||||
if (this.folderTreeLoaded && !this.folderExistsInTree(activeFolder)) {
|
||||
console.warn(`Persisted folder "${activeFolder}" not found in folder tree, falling back to root`);
|
||||
this.selectedPath = '';
|
||||
if (this.pageControls?.pageState) {
|
||||
this.pageControls.pageState.activeFolder = '';
|
||||
}
|
||||
setStorageItem(`${this.pageType}_activeFolder`, '');
|
||||
// When the reset happens after initialization (e.g. via
|
||||
// refresh() after a drag move emptied the folder), reload the
|
||||
// listing so the grid shows the root contents instead of
|
||||
// staying empty. Skipped during initialize() — the first load
|
||||
// picks up the cleared filter on its own.
|
||||
if (this.isInitialized && typeof this.pageControls?.resetAndReload === 'function') {
|
||||
this.pageControls.resetAndReload().catch((error) => {
|
||||
console.error('Failed to reload after resetting folder selection:', error);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.selectedPath = activeFolder;
|
||||
}
|
||||
this.updateTreeSelection();
|
||||
this.updateBreadcrumbs();
|
||||
this.updateSidebarHeader();
|
||||
|
||||
@@ -52,7 +52,11 @@ class InitializationManager {
|
||||
detectPageType() {
|
||||
// Get the current page type from URL or data attribute
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/checkpoints')) {
|
||||
// The recipes page lives at /loras/recipes, so it must be matched
|
||||
// before the generic '/loras' check.
|
||||
if (path.includes('/recipes')) {
|
||||
this.pageType = 'recipes';
|
||||
} else if (path.includes('/checkpoints')) {
|
||||
this.pageType = 'checkpoints';
|
||||
} else if (path.includes('/loras')) {
|
||||
this.pageType = 'loras';
|
||||
@@ -216,7 +220,8 @@ class InitializationManager {
|
||||
const scannerTypeToPageType = {
|
||||
'lora': 'loras',
|
||||
'checkpoint': 'checkpoints',
|
||||
'embedding': 'embeddings'
|
||||
'embedding': 'embeddings',
|
||||
'recipe': 'recipes'
|
||||
};
|
||||
|
||||
if (scannerTypeToPageType[data.scanner_type] !== this.pageType) {
|
||||
|
||||
@@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) {
|
||||
|
||||
const keyHandler = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
// Stop propagation so bubble-phase handlers (e.g. ModalManager's
|
||||
// Escape handler) do not also close the modal underneath.
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
closeMediaViewer();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { toggleShowcase } from './showcase/ShowcaseView.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
@@ -109,7 +108,10 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
}
|
||||
|
||||
// If no specific element was clicked, handle the card click (show modal or toggle selection)
|
||||
handleCardClick(card, modelType);
|
||||
if (state.bulkMode && event.shiftKey) {
|
||||
event.preventDefault(); // keep shift+click from extending a text selection
|
||||
}
|
||||
handleCardClick(card, modelType, event.shiftKey);
|
||||
return false; // Continue with other handlers (e.g., bulk selection)
|
||||
}
|
||||
|
||||
@@ -289,12 +291,12 @@ function handleViewLocalVersionsFromCard(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(card, modelType) {
|
||||
function handleCardClick(card, modelType, extendSelection = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
if (state.bulkMode) {
|
||||
// Toggle selection using the bulk manager
|
||||
bulkManager.toggleCardSelection(card);
|
||||
bulkManager.toggleCardSelection(card, extendSelection);
|
||||
} else if (pageState && pageState.duplicatesMode) {
|
||||
// In duplicates mode, don't open modal when clicking cards
|
||||
return;
|
||||
@@ -304,10 +306,21 @@ function handleCardClick(card, modelType) {
|
||||
}
|
||||
}
|
||||
|
||||
// Preview URL is not in the dataset; read it from the card's rendered media
|
||||
function getCardPreviewUrl(card) {
|
||||
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
|
||||
if (!cardMedia) return '';
|
||||
return cardMedia.tagName === 'VIDEO'
|
||||
? (cardMedia.dataset.src || '')
|
||||
: (cardMedia.src || '');
|
||||
}
|
||||
|
||||
async function showModelModalFromCard(card, modelType) {
|
||||
// Create model metadata object
|
||||
const modelMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
autov3: card.dataset.autov3 || '',
|
||||
preview_url: getCardPreviewUrl(card),
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
@@ -397,6 +410,8 @@ function showExampleAccessModal(card, modelType) {
|
||||
// Get the model data from card dataset (works for both lora and checkpoint)
|
||||
const modelMeta = {
|
||||
sha256: card.dataset.sha256,
|
||||
autov3: card.dataset.autov3 || '',
|
||||
preview_url: getCardPreviewUrl(card),
|
||||
file_path: card.dataset.filepath,
|
||||
model_name: card.dataset.name,
|
||||
file_name: card.dataset.file_name,
|
||||
@@ -421,30 +436,18 @@ function showExampleAccessModal(card, modelType) {
|
||||
// Show the model modal
|
||||
await showModelModal(modelMeta, modelType);
|
||||
|
||||
// Scroll to import area after modal is visible
|
||||
// Reveal the import entry once the modal content has rendered
|
||||
setTimeout(() => {
|
||||
const importArea = document.querySelector('.example-import-area');
|
||||
// Gallery mode: the import button is always visible — expand the zone
|
||||
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
|
||||
if (importBtn) {
|
||||
importBtn.click();
|
||||
return;
|
||||
}
|
||||
// Empty state: the import area is the whole tab content — scroll to it
|
||||
const importArea = document.querySelector('#modelModal .example-import-area');
|
||||
if (importArea) {
|
||||
const showcaseTab = document.getElementById('showcase-tab');
|
||||
if (showcaseTab) {
|
||||
// First make sure showcase tab is visible
|
||||
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
|
||||
if (tabBtn && !tabBtn.classList.contains('active')) {
|
||||
tabBtn.click();
|
||||
}
|
||||
|
||||
// Then toggle showcase if collapsed
|
||||
const carousel = showcaseTab.querySelector('.carousel');
|
||||
if (carousel && carousel.classList.contains('collapsed')) {
|
||||
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
|
||||
if (scrollIndicator) {
|
||||
toggleShowcase(scrollIndicator);
|
||||
}
|
||||
}
|
||||
|
||||
// Finally scroll to the import area
|
||||
importArea.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
@@ -457,8 +460,12 @@ function showExampleAccessModal(card, modelType) {
|
||||
export function createModelCard(model, modelType) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card'; // Reuse the same class for styling
|
||||
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
|
||||
// from click jitter are rendered harmless by the preview-drop handlers
|
||||
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
|
||||
card.draggable = true;
|
||||
card.dataset.sha256 = model.sha256;
|
||||
card.dataset.autov3 = model.autov3 || '';
|
||||
card.dataset.filepath = model.file_path;
|
||||
card.dataset.name = model.model_name;
|
||||
card.dataset.file_name = model.file_name;
|
||||
@@ -539,8 +546,9 @@ export function createModelCard(model, modelType) {
|
||||
card.classList.add('excluded-model');
|
||||
}
|
||||
|
||||
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
|
||||
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
|
||||
// state.selectedModels resolves to the active page's set (selectedLoras
|
||||
// included) - do not narrow this back to selectedLoras/LORA-only.
|
||||
if (state.bulkMode && state.selectedModels.has(model.file_path)) {
|
||||
card.classList.add('selected');
|
||||
}
|
||||
|
||||
@@ -649,7 +657,7 @@ export function createModelCard(model, modelType) {
|
||||
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
|
||||
${isVideo ?
|
||||
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
|
||||
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
<div class="card-header">
|
||||
${shouldBlur ?
|
||||
@@ -743,6 +751,11 @@ export function createModelCard(model, modelType) {
|
||||
|
||||
// Dropping an image/video onto the card replaces the model preview via the
|
||||
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
|
||||
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
|
||||
// SidebarManager and must be ignored here entirely: no highlight, no upload.
|
||||
const isInternalCardDrag = (event) =>
|
||||
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
|
||||
|
||||
const preventDragDefaults = (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -750,17 +763,20 @@ export function createModelCard(model, modelType) {
|
||||
|
||||
['dragenter', 'dragover'].forEach((eventName) => {
|
||||
card.addEventListener(eventName, (event) => {
|
||||
if (isInternalCardDrag(event)) return;
|
||||
preventDragDefaults(event);
|
||||
card.classList.add('drag-over');
|
||||
});
|
||||
});
|
||||
|
||||
card.addEventListener('dragleave', (event) => {
|
||||
if (isInternalCardDrag(event)) return;
|
||||
preventDragDefaults(event);
|
||||
card.classList.remove('drag-over');
|
||||
});
|
||||
|
||||
card.addEventListener('drop', (event) => {
|
||||
if (isInternalCardDrag(event)) return;
|
||||
preventDragDefaults(event);
|
||||
card.classList.remove('drag-over');
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
||||
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax, copyToClipboard } from '../../utils/uiHelpers.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { MODEL_TYPES } from '../../api/apiConfig.js';
|
||||
import {
|
||||
toggleShowcase,
|
||||
setupShowcaseScroll,
|
||||
scrollToTop,
|
||||
loadExampleImages
|
||||
} from './showcase/ShowcaseView.js';
|
||||
@@ -22,6 +20,7 @@ import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||
import { loadRecipesForModel } from './RecipeTab.js';
|
||||
import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { state } from '../../state/index.js';
|
||||
|
||||
function getModalFilePath(fallback = '') {
|
||||
@@ -353,6 +352,39 @@ export async function showModelModal(model, modelType) {
|
||||
};
|
||||
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
|
||||
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
|
||||
// De-emphasized hash display: a borderless full-width footnote line below
|
||||
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
|
||||
// full (12 chars); the full value is copied via data-hash.
|
||||
const modelSha256 = modelWithFullData.sha256 || '';
|
||||
const modelAutov3 = modelWithFullData.autov3 || '';
|
||||
const truncatedSha256 = modelSha256.length > 16
|
||||
? `${modelSha256.slice(0, 10)}\u2026${modelSha256.slice(-6)}`
|
||||
: modelSha256;
|
||||
const copyHashTitle = translate('modals.model.actions.copyHash', {}, 'Copy hash');
|
||||
const hashEntries = [];
|
||||
if (modelSha256) {
|
||||
hashEntries.push(`
|
||||
<span class="hash-entry">
|
||||
<span class="hash-kind">SHA256</span>
|
||||
<span class="model-hash-value" title="${escapeAttribute(modelSha256)}">${escapeHtml(truncatedSha256)}</span>
|
||||
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(modelSha256)}" title="${copyHashTitle}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
if (modelAutov3) {
|
||||
hashEntries.push(`
|
||||
<span class="hash-entry">
|
||||
<span class="hash-kind">AutoV3</span>
|
||||
<span class="model-hash-value" title="${escapeAttribute(modelAutov3)}">${escapeHtml(modelAutov3)}</span>
|
||||
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(modelAutov3)}" title="${copyHashTitle}">
|
||||
<i class="fas fa-copy"></i>
|
||||
</button>
|
||||
</span>`);
|
||||
}
|
||||
const hashesMarkup = modelSha256 && hashEntries.length ? `
|
||||
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${hashEntries.join('<span class="hash-sep">·</span>')}
|
||||
</div>` : '';
|
||||
const useNewIcons = state.global.settings.use_new_license_icons !== false;
|
||||
const licenseIcons = useNewIcons
|
||||
? renderNewLicenseIcons(modelWithFullData)
|
||||
@@ -413,6 +445,17 @@ export async function showModelModal(model, modelType) {
|
||||
if (licenseIcons) {
|
||||
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
|
||||
}
|
||||
|
||||
// Destructive action stays last (rightmost). The license icons' auto
|
||||
// margin right-anchors the [license][delete] cluster as one group.
|
||||
const deleteModelTitle = translate('modals.model.actions.deleteModelWithShortcut', {}, 'Delete model (Del)');
|
||||
const deleteModelButton = `
|
||||
<button class="modal-delete-btn" data-action="delete-model" title="${deleteModelTitle}" aria-label="${deleteModelTitle}">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
`.trim();
|
||||
headerActionItems.push(indentMarkup(deleteModelButton, 20));
|
||||
|
||||
const headerActionsMarkup = headerActionItems.length
|
||||
? [
|
||||
' <div class="modal-header-actions">',
|
||||
@@ -615,6 +658,7 @@ export async function showModelModal(model, modelType) {
|
||||
<span>${formatFileSize(modelWithFullData.file_size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
${hashesMarkup}
|
||||
${typeSpecificContent}
|
||||
<div class="info-item notes">
|
||||
<div class="notes-header">
|
||||
@@ -727,8 +771,6 @@ export async function showModelModal(model, modelType) {
|
||||
updateCardUpdateAvailability(hasUpdate);
|
||||
}
|
||||
|
||||
let showcaseCleanup;
|
||||
|
||||
const onCloseCallback = function () {
|
||||
// Clean up all handlers when modal closes for LoRA
|
||||
const modalElement = document.getElementById(modalId);
|
||||
@@ -736,10 +778,6 @@ export async function showModelModal(model, modelType) {
|
||||
modalElement.removeEventListener('click', modalElement._clickHandler);
|
||||
delete modalElement._clickHandler;
|
||||
}
|
||||
if (showcaseCleanup) {
|
||||
showcaseCleanup();
|
||||
showcaseCleanup = null;
|
||||
}
|
||||
cleanupNavigationShortcuts();
|
||||
};
|
||||
|
||||
@@ -759,6 +797,14 @@ export async function showModelModal(model, modelType) {
|
||||
if (modelType === 'embeddings' && modelWithFullData.folder) {
|
||||
activeModalElement.dataset.folder = modelWithFullData.folder;
|
||||
}
|
||||
// Show the back-to-top button once the modal content is scrolled
|
||||
const modalContent = activeModalElement.querySelector('.modal-content');
|
||||
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
|
||||
if (modalContent && backToTopBtn) {
|
||||
modalContent.addEventListener('scroll', () => {
|
||||
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
|
||||
});
|
||||
}
|
||||
}
|
||||
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
|
||||
const versionsTabController = initVersionsTab({
|
||||
@@ -771,7 +817,6 @@ export async function showModelModal(model, modelType) {
|
||||
onUpdateStatusChange: handleUpdateStatusChange,
|
||||
});
|
||||
setupEditableFields(modelWithFullData.file_path, modelType);
|
||||
showcaseCleanup = setupShowcaseScroll(modalId);
|
||||
setupTabSwitching({
|
||||
onTabChange: async (tab) => {
|
||||
if (tab === 'versions') {
|
||||
@@ -814,7 +859,7 @@ export async function showModelModal(model, modelType) {
|
||||
const customImages = modelWithFullData.civitai?.customImages || [];
|
||||
// Combine images - regular images first, then custom images
|
||||
const allImages = [...regularImages, ...customImages];
|
||||
loadExampleImages(allImages, modelWithFullData.sha256);
|
||||
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
|
||||
}
|
||||
|
||||
function renderLoraSpecificContent(lora, escapedWords) {
|
||||
@@ -911,6 +956,14 @@ function setupEventHandlers(filePath, modelType) {
|
||||
case 'send-to-workflow':
|
||||
handleSendToWorkflow(target, modelType);
|
||||
break;
|
||||
case 'delete-model':
|
||||
handleDeleteModel();
|
||||
break;
|
||||
case 'copy-hash':
|
||||
if (target.dataset.hash) {
|
||||
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1180,12 +1233,26 @@ function setupNavigationShortcuts(modelType) {
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault();
|
||||
handleDirectionalNavigation('next', navigationModelType);
|
||||
} else if (event.key === 'Delete') {
|
||||
event.preventDefault();
|
||||
handleDeleteModel();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', navigationKeyHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the shared delete confirmation for the model currently shown in the
|
||||
* modal. Showing the delete modal replaces this modal (ModalManager only
|
||||
* keeps one modal open), which also unregisters these shortcuts.
|
||||
*/
|
||||
function handleDeleteModel() {
|
||||
const filePath = getModalFilePath();
|
||||
if (!filePath) return;
|
||||
showDeleteModal(filePath);
|
||||
}
|
||||
|
||||
async function handleDirectionalNavigation(direction, modelType) {
|
||||
if (navigationInProgress) return;
|
||||
|
||||
@@ -1316,7 +1383,6 @@ async function handleSendToWorkflow(target, modelType) {
|
||||
// Export the model modal API
|
||||
const modelModal = {
|
||||
show: showModelModal,
|
||||
toggleShowcase,
|
||||
scrollToTop
|
||||
};
|
||||
|
||||
|
||||
@@ -573,9 +573,17 @@ function renderRow(version, options) {
|
||||
);
|
||||
|
||||
const actions = [];
|
||||
if (!version.isInLibrary) {
|
||||
const canDownload = isDownloadAllowed(version);
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
const canDownload = isDownloadAllowed(version);
|
||||
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
|
||||
// The Download button always fetches the default (primary) file, keeping
|
||||
// the single-file experience for users who don't care about variants.
|
||||
// In-library versions hide it: their default file already exists locally,
|
||||
// and multi-file versions use the "N files" badge below for the remaining
|
||||
// variants instead (#1058). fileCount is null for records persisted before
|
||||
// the field existed; default to the single-file behavior in that case.
|
||||
const fileCount = typeof version.fileCount === 'number' ? version.fileCount : null;
|
||||
const showDownload = !version.isInLibrary;
|
||||
if (showDownload) {
|
||||
let downloadTitle;
|
||||
if (!canDownload) {
|
||||
downloadTitle = translate(
|
||||
@@ -612,7 +620,16 @@ function renderRow(version, options) {
|
||||
disabled: !canDownload,
|
||||
}
|
||||
));
|
||||
} else if (version.filePath) {
|
||||
}
|
||||
|
||||
// Multi-file versions get an explicit entry into the download modal's
|
||||
// file-selection step, mirroring the version step's file badge (#1058).
|
||||
const fileSelectionBadge = fileCount !== null && fileCount > 1
|
||||
? `<button type="button" class="file-select-badge" data-version-files title="${escapeHtml(translate('modals.model.versions.actions.downloadChooseFilesTooltip', {}, 'Choose which files to download'))}">
|
||||
<i class="fas fa-th-list"></i> ${fileCount} ${escapeHtml(translate('modals.download.fileSelection.files', {}, 'files'))} <i class="fas fa-chevron-right badge-arrow"></i>
|
||||
</button>`
|
||||
: '';
|
||||
if (version.isInLibrary && version.filePath) {
|
||||
actions.push(buildActionButton(
|
||||
deleteLabel,
|
||||
'version-action-danger',
|
||||
@@ -689,6 +706,7 @@ function renderRow(version, options) {
|
||||
<div class="version-badges">${badges.join('')}</div>
|
||||
<div class="version-meta">
|
||||
${buildMetaMarkup(version, { showEarlyAccess: true })}
|
||||
${fileSelectionBadge}
|
||||
</div>
|
||||
</div>
|
||||
<div class="version-actions">
|
||||
@@ -1422,6 +1440,9 @@ export function initVersionsTab({
|
||||
button.disabled = true;
|
||||
|
||||
try {
|
||||
// The Download button only renders for versions not in the library
|
||||
// and always fetches the default (primary) file. Multi-file
|
||||
// variants are reached through the "N files" badge instead.
|
||||
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
||||
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
|
||||
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||
@@ -1500,6 +1521,21 @@ export function initVersionsTab({
|
||||
return;
|
||||
}
|
||||
|
||||
// File-selection badge: enter the download modal's file step directly.
|
||||
// Must run before the row-click navigation below (rows are clickable).
|
||||
const filesBadge = event.target.closest('[data-version-files]');
|
||||
if (filesBadge) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const row = filesBadge.closest('.model-version-row');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
const versionId = Number(row.dataset.versionId);
|
||||
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
|
||||
return;
|
||||
}
|
||||
|
||||
const row = event.target.closest('.model-version-row.is-clickable');
|
||||
const civitaiLink = event.target.closest('.version-civitai-link');
|
||||
if (civitaiLink) {
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate video wrapper HTML
|
||||
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
|
||||
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||
* @param {Object} media - Media metadata
|
||||
* @param {number} heightPercent - Height percentage for container
|
||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||
* @param {string} nsfwText - NSFW warning text
|
||||
* @param {string} metadataPanel - Metadata panel HTML
|
||||
@@ -15,11 +15,11 @@
|
||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||
|
||||
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
@@ -48,9 +48,9 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image wrapper HTML
|
||||
* Generate image wrapper HTML. The wrapper fills its container (the gallery's
|
||||
* main viewer) and the media is letterboxed inside via object-fit: contain.
|
||||
* @param {Object} media - Media metadata
|
||||
* @param {number} heightPercent - Height percentage for container
|
||||
* @param {boolean} shouldBlur - Whether content should be blurred
|
||||
* @param {string} nsfwText - NSFW warning text
|
||||
* @param {string} metadataPanel - Metadata panel HTML
|
||||
@@ -59,11 +59,11 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
|
||||
* @param {string} mediaControlsHtml - HTML for media control buttons
|
||||
* @returns {string} HTML content
|
||||
*/
|
||||
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
|
||||
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
|
||||
|
||||
|
||||
return `
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
|
||||
${shouldBlur ? `
|
||||
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
|
||||
<i class="fas fa-eye"></i>
|
||||
|
||||
@@ -213,190 +213,170 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize metadata panel interaction handlers
|
||||
* Initialize metadata panel interaction handlers: hover over the media reveals
|
||||
* the panel and media controls (same as the legacy carousel). Panel-internal
|
||||
* buttons and wheel isolation are bound here as well.
|
||||
* @param {HTMLElement} container - Container element with media wrappers
|
||||
*/
|
||||
export function initMetadataPanelHandlers(container) {
|
||||
const mediaWrappers = container.querySelectorAll('.media-wrapper');
|
||||
|
||||
|
||||
mediaWrappers.forEach(wrapper => {
|
||||
// Get the metadata panel and media element (img or video)
|
||||
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
|
||||
if (!metadataPanel) return;
|
||||
|
||||
const mediaControls = wrapper.querySelector('.media-controls');
|
||||
const mediaElement = wrapper.querySelector('img, video');
|
||||
|
||||
if (!mediaElement) return;
|
||||
|
||||
let isOverMetadataPanel = false;
|
||||
|
||||
// Add event listeners to the wrapper for mouse tracking
|
||||
wrapper.addEventListener('mousemove', (e) => {
|
||||
// Get mouse position relative to wrapper
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
// Get the actual displayed dimensions of the media element
|
||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||
|
||||
// Check if mouse is over the actual media content
|
||||
const isOverMedia = (
|
||||
mouseX >= mediaRect.left &&
|
||||
mouseX <= mediaRect.right &&
|
||||
mouseY >= mediaRect.top &&
|
||||
mouseY <= mediaRect.bottom
|
||||
);
|
||||
|
||||
// Show metadata panel and controls when over media content or metadata panel itself
|
||||
if (isOverMedia || isOverMetadataPanel) {
|
||||
if (metadataPanel) metadataPanel.classList.add('visible');
|
||||
if (mediaControls) mediaControls.classList.add('visible');
|
||||
} else {
|
||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseleave', () => {
|
||||
if (!isOverMetadataPanel) {
|
||||
if (metadataPanel) metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
// Add mouse enter/leave events for the metadata panel itself
|
||||
if (metadataPanel) {
|
||||
|
||||
if (mediaElement) {
|
||||
let isOverMetadataPanel = false;
|
||||
|
||||
// Hovering the actual media content reveals the metadata panel and controls
|
||||
wrapper.addEventListener('mousemove', (e) => {
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||
const isOverMedia = (
|
||||
mouseX >= mediaRect.left &&
|
||||
mouseX <= mediaRect.right &&
|
||||
mouseY >= mediaRect.top &&
|
||||
mouseY <= mediaRect.bottom
|
||||
);
|
||||
|
||||
if (isOverMedia || isOverMetadataPanel) {
|
||||
metadataPanel.classList.add('visible');
|
||||
if (mediaControls) mediaControls.classList.add('visible');
|
||||
} else {
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
wrapper.addEventListener('mouseleave', () => {
|
||||
if (!isOverMetadataPanel) {
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
metadataPanel.addEventListener('mouseenter', () => {
|
||||
isOverMetadataPanel = true;
|
||||
metadataPanel.classList.add('visible');
|
||||
if (mediaControls) mediaControls.classList.add('visible');
|
||||
});
|
||||
|
||||
|
||||
metadataPanel.addEventListener('mouseleave', () => {
|
||||
isOverMetadataPanel = false;
|
||||
// Only hide if mouse is not over the media
|
||||
const rect = wrapper.getBoundingClientRect();
|
||||
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
|
||||
const mouseX = event.clientX - rect.left;
|
||||
const mouseY = event.clientY - rect.top;
|
||||
|
||||
const isOverMedia = (
|
||||
mouseX >= mediaRect.left &&
|
||||
mouseX <= mediaRect.right &&
|
||||
mouseY >= mediaRect.top &&
|
||||
mouseY <= mediaRect.bottom
|
||||
);
|
||||
|
||||
if (!isOverMedia) {
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
}
|
||||
metadataPanel.classList.remove('visible');
|
||||
if (mediaControls) mediaControls.classList.remove('visible');
|
||||
});
|
||||
|
||||
// Prevent events from bubbling
|
||||
metadataPanel.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Handle copy prompt buttons
|
||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||
copyBtns.forEach(copyBtn => {
|
||||
const promptIndex = copyBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send prompt buttons
|
||||
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
||||
sendBtns.forEach(sendBtn => {
|
||||
const promptIndex = sendBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
sendBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
let promptText = promptElement.textContent || '';
|
||||
if (!promptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect strip <lora> setting from global state
|
||||
if (state.global.settings?.strip_lora_on_copy) {
|
||||
promptText = stripLoraTags(promptText);
|
||||
}
|
||||
|
||||
sendPromptToWorkflow(promptText);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send params buttons
|
||||
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
||||
if (paramsBtn) {
|
||||
paramsBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Collect gen params from the param-tag elements
|
||||
const tagsContainer = wrapper.querySelector('.params-tags');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
||||
const genParams = {};
|
||||
|
||||
// Map display labels to genParams keys
|
||||
const labelToKey = {
|
||||
'Seed': 'seed',
|
||||
'Steps': 'steps',
|
||||
'Sampler': 'sampler',
|
||||
'CFG': 'cfg_scale',
|
||||
};
|
||||
|
||||
paramTags.forEach(tag => {
|
||||
const nameEl = tag.querySelector('.param-name');
|
||||
const valueEl = tag.querySelector('.param-value');
|
||||
if (!nameEl || !valueEl) return;
|
||||
|
||||
const label = nameEl.textContent.replace(':', '').trim();
|
||||
const key = labelToKey[label];
|
||||
if (key) {
|
||||
genParams[key] = valueEl.textContent.trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(genParams).length === 0) {
|
||||
showToast('No sendable parameters found', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGenParamsToWorkflow(genParams);
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent panel scroll from causing modal scroll
|
||||
metadataPanel.addEventListener('wheel', (e) => {
|
||||
const isAtTop = metadataPanel.scrollTop === 0;
|
||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
||||
|
||||
// Only prevent default if scrolling would cause the panel to scroll
|
||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
// Prevent events from bubbling
|
||||
metadataPanel.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Handle copy prompt buttons
|
||||
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
|
||||
copyBtns.forEach(copyBtn => {
|
||||
const promptIndex = copyBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
try {
|
||||
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
|
||||
} catch (err) {
|
||||
console.error('Copy failed:', err);
|
||||
showToast('toast.triggerWords.copyFailed', {}, 'error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send prompt buttons
|
||||
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
|
||||
sendBtns.forEach(sendBtn => {
|
||||
const promptIndex = sendBtn.dataset.promptIndex;
|
||||
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
|
||||
|
||||
sendBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (!promptElement) return;
|
||||
|
||||
let promptText = promptElement.textContent || '';
|
||||
if (!promptText.trim()) {
|
||||
showToast('toast.recipes.noPromptToSend', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// Respect strip <lora> setting from global state
|
||||
if (state.global.settings?.strip_lora_on_copy) {
|
||||
promptText = stripLoraTags(promptText);
|
||||
}
|
||||
|
||||
sendPromptToWorkflow(promptText);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle send params buttons
|
||||
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
|
||||
if (paramsBtn) {
|
||||
paramsBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Collect gen params from the param-tag elements
|
||||
const tagsContainer = wrapper.querySelector('.params-tags');
|
||||
if (!tagsContainer) return;
|
||||
|
||||
const paramTags = tagsContainer.querySelectorAll('.param-tag');
|
||||
const genParams = {};
|
||||
|
||||
// Map display labels to genParams keys
|
||||
const labelToKey = {
|
||||
'Seed': 'seed',
|
||||
'Steps': 'steps',
|
||||
'Sampler': 'sampler',
|
||||
'CFG': 'cfg_scale',
|
||||
};
|
||||
|
||||
paramTags.forEach(tag => {
|
||||
const nameEl = tag.querySelector('.param-name');
|
||||
const valueEl = tag.querySelector('.param-value');
|
||||
if (!nameEl || !valueEl) return;
|
||||
|
||||
const label = nameEl.textContent.replace(':', '').trim();
|
||||
const key = labelToKey[label];
|
||||
if (key) {
|
||||
genParams[key] = valueEl.textContent.trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(genParams).length === 0) {
|
||||
showToast('No sendable parameters found', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
await sendGenParamsToWorkflow(genParams);
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent panel scroll from causing modal scroll
|
||||
metadataPanel.addEventListener('wheel', (e) => {
|
||||
const isAtTop = metadataPanel.scrollTop === 0;
|
||||
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
|
||||
|
||||
// Only prevent default if scrolling would cause the panel to scroll
|
||||
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -525,6 +505,12 @@ export function initMediaControlHandlers(container) {
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
// Let the gallery refresh itself (removes thumbnail + selects a neighbor)
|
||||
mediaWrapper.dispatchEvent(new CustomEvent('example-media-deleted', {
|
||||
bubbles: true,
|
||||
detail: { shortId }
|
||||
}));
|
||||
|
||||
// Success: remove the media wrapper from the DOM
|
||||
mediaWrapper.style.opacity = '0';
|
||||
mediaWrapper.style.height = '0';
|
||||
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
|
||||
// Initialize NSFW level buttons
|
||||
initSetNsfwHandlers(container);
|
||||
|
||||
// Media control visibility is now handled in initMetadataPanelHandlers
|
||||
// Media control visibility is handled with pure CSS (.media-wrapper:hover .media-controls)
|
||||
// Any click handlers or other functionality can still be added here
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,7 +49,10 @@ class I18nManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/locales/${normalizedLocale}.json`);
|
||||
// 'no-cache' forces revalidation (cheap 304 via ETag) so locale
|
||||
// edits are picked up on a plain reload instead of serving a
|
||||
// stale cached copy.
|
||||
const response = await fetch(`/locales/${normalizedLocale}.json`, { cache: 'no-cache' });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
@@ -17,17 +17,77 @@ export class BatchImportManager {
|
||||
this.progress = null;
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
this.isImporting = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the batch import modal
|
||||
* Show the batch import modal.
|
||||
*
|
||||
* If an import is still running in the background (e.g. the modal was
|
||||
* closed mid-run with the X button or a backdrop click), reopen it in the
|
||||
* progress/results view instead of resetting to a fresh form, so the modal
|
||||
* never becomes unusable while an operation is in flight.
|
||||
*/
|
||||
showModal() {
|
||||
if (!this.initialized) {
|
||||
this.initialize();
|
||||
}
|
||||
this.resetState();
|
||||
modalManager.showModal('batchImportModal');
|
||||
|
||||
if (this.isImporting && this.operationId) {
|
||||
console.log(
|
||||
`[BatchImport] Reopening modal while operation ${this.operationId} is still active; restoring its view.`
|
||||
);
|
||||
this.resumeRunningImportView();
|
||||
} else if (this.results && this.operationId) {
|
||||
// A previous operation finished while the modal was closed —
|
||||
// restore its results view instead of discarding them.
|
||||
console.log(
|
||||
`[BatchImport] Reopening modal after operation ${this.operationId} finished; showing results.`
|
||||
);
|
||||
this.showStep('batchResultsStep');
|
||||
this.updateResultsUI(this.results);
|
||||
} else {
|
||||
this.resetState();
|
||||
console.log('[BatchImport] Opening batch import modal.');
|
||||
}
|
||||
|
||||
modalManager.showModal('batchImportModal', null, () => this.handleModalClosed());
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore the progress (or results) view for an operation that is still
|
||||
* running in the background after the modal was closed.
|
||||
*/
|
||||
resumeRunningImportView() {
|
||||
// Operation completed while the modal was closed — show results
|
||||
if (this.results) {
|
||||
this.showStep('batchResultsStep');
|
||||
this.updateResultsUI(this.results);
|
||||
return;
|
||||
}
|
||||
|
||||
// Still running — restore the progress step and re-attach live updates
|
||||
this.showStep('batchProgressStep');
|
||||
this.updateProgressUI(this.progress || {});
|
||||
if (!this.wsConnection && !this.pollingInterval) {
|
||||
this.connectWebSocket();
|
||||
this.startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the modal is closed (X button, backdrop click, cancel,
|
||||
* closeAndReset). Logs whether an operation is still running so users can
|
||||
* tell from the console that work continues in the background.
|
||||
*/
|
||||
handleModalClosed() {
|
||||
if (this.isImporting && this.operationId) {
|
||||
console.log(
|
||||
`[BatchImport] Modal closed while import ${this.operationId} is still running; it keeps running in the background. Reopen the modal to watch its progress.`
|
||||
);
|
||||
} else {
|
||||
console.log('[BatchImport] Modal closed (no active import).');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,6 +117,7 @@ export class BatchImportManager {
|
||||
this.progress = null;
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
this.isImporting = false;
|
||||
|
||||
// Reset UI
|
||||
this.showStep('batchInputStep');
|
||||
@@ -172,6 +233,10 @@ export class BatchImportManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[BatchImport] Starting import: mode=${data.mode}, items=${data.items ? data.items.length : 'directory'}, tags=${data.tags.length}`
|
||||
);
|
||||
|
||||
try {
|
||||
// Show progress step
|
||||
this.showStep('batchProgressStep');
|
||||
@@ -182,6 +247,8 @@ export class BatchImportManager {
|
||||
if (response.success) {
|
||||
this.operationId = response.operation_id;
|
||||
this.isCancelled = false;
|
||||
this.isImporting = true;
|
||||
console.log(`[BatchImport] Import started, operation_id=${this.operationId}`);
|
||||
|
||||
// Connect to WebSocket for real-time updates
|
||||
this.connectWebSocket();
|
||||
@@ -189,6 +256,7 @@ export class BatchImportManager {
|
||||
// Start polling as fallback
|
||||
this.startPolling();
|
||||
} else {
|
||||
console.warn(`[BatchImport] Failed to start import: ${response.error}`);
|
||||
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
@@ -355,8 +423,31 @@ export class BatchImportManager {
|
||||
* Handle progress update from WebSocket or polling
|
||||
*/
|
||||
handleProgressUpdate(progress) {
|
||||
const prev = this.progress;
|
||||
this.progress = progress;
|
||||
this.updateProgressUI(progress);
|
||||
|
||||
// Only log when something actually changed (and on the first update),
|
||||
// so per-second polling does not spam the console with identical lines.
|
||||
const changed =
|
||||
!prev ||
|
||||
prev.total !== progress.total ||
|
||||
prev.completed !== progress.completed ||
|
||||
prev.success !== progress.success ||
|
||||
prev.failed !== progress.failed ||
|
||||
prev.skipped !== progress.skipped ||
|
||||
prev.status !== progress.status ||
|
||||
prev.current_item !== progress.current_item;
|
||||
|
||||
if (changed) {
|
||||
console.log(
|
||||
`[BatchImport] Progress ${Math.round(progress.progress_percent || 0)}% ` +
|
||||
`(${progress.completed}/${progress.total}) ` +
|
||||
`status=${progress.status} ` +
|
||||
`success=${progress.success} failed=${progress.failed} skipped=${progress.skipped} ` +
|
||||
`item=${progress.current_item || '-'}`
|
||||
);
|
||||
}
|
||||
|
||||
// Check if import is complete
|
||||
if (progress.status === 'completed' || progress.status === 'cancelled' ||
|
||||
@@ -431,7 +522,12 @@ export class BatchImportManager {
|
||||
*/
|
||||
importComplete(progress) {
|
||||
this.cleanupConnections();
|
||||
this.isImporting = false;
|
||||
this.results = progress;
|
||||
console.log(
|
||||
`[BatchImport] Import finished: status=${progress.status} ` +
|
||||
`total=${progress.total} success=${progress.success} failed=${progress.failed} skipped=${progress.skipped}`
|
||||
);
|
||||
|
||||
// Refresh recipes list to show newly imported recipes
|
||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||
@@ -559,6 +655,7 @@ export class BatchImportManager {
|
||||
if (!this.operationId) return;
|
||||
|
||||
this.isCancelled = true;
|
||||
console.log(`[BatchImport] Cancelling import ${this.operationId}...`);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
|
||||
@@ -572,8 +669,10 @@ export class BatchImportManager {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
console.log(`[BatchImport] Cancel request accepted for ${this.operationId}`);
|
||||
showToast('toast.recipes.batchImportCancelling', {}, 'info');
|
||||
} else {
|
||||
console.warn(`[BatchImport] Cancel request failed: ${data.error}`);
|
||||
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -586,6 +685,7 @@ export class BatchImportManager {
|
||||
* Close modal and reset state
|
||||
*/
|
||||
closeAndReset() {
|
||||
console.log('[BatchImport] Closing modal and resetting state.');
|
||||
this.cleanupConnections();
|
||||
this.resetState();
|
||||
modalManager.closeModal('batchImportModal');
|
||||
@@ -595,6 +695,7 @@ export class BatchImportManager {
|
||||
* Start a new import (from results step)
|
||||
*/
|
||||
startNewImport() {
|
||||
console.log('[BatchImport] Starting a new import from the results view.');
|
||||
this.resetState();
|
||||
this.showStep('batchInputStep');
|
||||
}
|
||||
@@ -789,6 +890,14 @@ export class BatchImportManager {
|
||||
* Clean up WebSocket and polling connections
|
||||
*/
|
||||
cleanupConnections() {
|
||||
const hasWs = this.wsConnection && (
|
||||
this.wsConnection.readyState === WebSocket.OPEN ||
|
||||
this.wsConnection.readyState === WebSocket.CONNECTING
|
||||
);
|
||||
if (hasWs || this.pollingInterval) {
|
||||
console.log('[BatchImport] Cleaning up live connections (WebSocket/polling).');
|
||||
}
|
||||
|
||||
if (this.wsConnection) {
|
||||
if (this.wsConnection.readyState === WebSocket.OPEN ||
|
||||
this.wsConnection.readyState === WebSocket.CONNECTING) {
|
||||
|
||||
@@ -26,6 +26,10 @@ export class BulkManager {
|
||||
this.marqueeElement = null;
|
||||
this.initialSelectedModels = new Set();
|
||||
|
||||
// Shift+click range anchor: last plain-clicked filepath. Set in
|
||||
// toggleCardSelection, cleared in clearSelection.
|
||||
this.bulkAnchorFilepath = null;
|
||||
|
||||
// Drag detection properties
|
||||
this.dragThreshold = 5; // Pixels to move before considering it a drag
|
||||
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
|
||||
@@ -351,6 +355,7 @@ export class BulkManager {
|
||||
card.classList.remove('selected');
|
||||
});
|
||||
state.selectedModels.clear();
|
||||
this.bulkAnchorFilepath = null;
|
||||
|
||||
// Update context menu header if visible
|
||||
if (this.bulkContextMenu) {
|
||||
@@ -358,9 +363,13 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
toggleCardSelection(card) {
|
||||
toggleCardSelection(card, extendSelection = false) {
|
||||
const filepath = card.dataset.filepath;
|
||||
|
||||
if (extendSelection && this.selectRangeFromAnchor(filepath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.classList.contains('selected')) {
|
||||
card.classList.remove('selected');
|
||||
state.selectedModels.delete(filepath);
|
||||
@@ -372,12 +381,78 @@ export class BulkManager {
|
||||
this.updateMetadataCacheFromCard(filepath, card);
|
||||
}
|
||||
|
||||
this.bulkAnchorFilepath = filepath;
|
||||
|
||||
// Update context menu header if visible
|
||||
if (this.bulkContextMenu) {
|
||||
this.bulkContextMenu.updateSelectedCountHeader();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select exactly the items between the shift anchor and the target
|
||||
* (inclusive), following list order. Explorer-style range semantics:
|
||||
* selections outside the new range are dropped, and consecutive shifts
|
||||
* re-derive the range from the same anchor. Returns false when there is
|
||||
* no usable anchor so the caller can fall back to a single-card toggle.
|
||||
*/
|
||||
selectRangeFromAnchor(targetFilepath) {
|
||||
const scroller = state.virtualScroller;
|
||||
if (!scroller || !scroller.items || !this.bulkAnchorFilepath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorIndex = scroller.findIndexByFilePath(this.bulkAnchorFilepath);
|
||||
const targetIndex = scroller.findIndexByFilePath(targetFilepath);
|
||||
if (anchorIndex === -1 || targetIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startIndex = Math.min(anchorIndex, targetIndex);
|
||||
const endIndex = Math.max(anchorIndex, targetIndex);
|
||||
const metadataCache = this.getMetadataCache();
|
||||
const rangePaths = new Set();
|
||||
|
||||
for (let i = startIndex; i <= endIndex; i++) {
|
||||
const item = scroller.items[i];
|
||||
if (!item || !item.file_path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rangePaths.add(item.file_path);
|
||||
|
||||
if (!metadataCache.has(item.file_path)) {
|
||||
const modelId = this.parseModelId(item?.civitai?.modelId);
|
||||
metadataCache.set(item.file_path, {
|
||||
fileName: item.file_name,
|
||||
folder: item.folder || '',
|
||||
usageTips: item.usage_tips || '{}',
|
||||
modelName: item.name || item.file_name,
|
||||
...(modelId !== null ? { modelId } : {})
|
||||
});
|
||||
}
|
||||
|
||||
state.selectedModels.add(item.file_path);
|
||||
}
|
||||
|
||||
for (const filepath of [...state.selectedModels]) {
|
||||
if (!rangePaths.has(filepath)) {
|
||||
state.selectedModels.delete(filepath);
|
||||
}
|
||||
}
|
||||
this.applySelectionState();
|
||||
|
||||
if (this.bulkContextMenu) {
|
||||
this.bulkContextMenu.updateSelectedCountHeader();
|
||||
}
|
||||
|
||||
if (this.isStripVisible) {
|
||||
this.updateThumbnailStrip();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getMetadataCache() {
|
||||
const currentType = state.currentPageType;
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
@@ -25,6 +25,12 @@ export class DownloadManager {
|
||||
this.apiClient = null;
|
||||
this.useDefaultPath = false;
|
||||
|
||||
// Multi-file selection state: selectedFile stays the first selected
|
||||
// file for backward compatibility with single-file flows (#1058).
|
||||
this.selectedFile = null;
|
||||
this.selectedFiles = [];
|
||||
this._lastDownloadError = null;
|
||||
|
||||
// Batch mode state
|
||||
this.batchModels = [];
|
||||
this.isBatchMode = false;
|
||||
@@ -160,6 +166,8 @@ export class DownloadManager {
|
||||
this.modelVersionId = null;
|
||||
this.source = null;
|
||||
this.selectedFile = null;
|
||||
this.selectedFiles = [];
|
||||
this._lastDownloadError = null;
|
||||
this._isDiffusionModel = false;
|
||||
|
||||
this.selectedFolder = '';
|
||||
@@ -546,6 +554,64 @@ export class DownloadManager {
|
||||
await this.fetchVersionsForCurrentModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the download modal directly on the file-selection step for a
|
||||
* specific model version (#1058). Used by entry points (e.g.
|
||||
* ModelVersionsTab) whose version payloads lack per-file downloaded
|
||||
* state, so the full versions payload is fetched here first.
|
||||
*/
|
||||
async openFileSelectionForVersion(modelType, modelId, versionId, { source = null } = {}) {
|
||||
try {
|
||||
this.apiClient = getModelApiClient(modelType);
|
||||
} catch (error) {
|
||||
this.apiClient = getModelApiClient();
|
||||
}
|
||||
|
||||
this.showDownloadModal();
|
||||
|
||||
this.modelId = modelId ? modelId.toString() : null;
|
||||
this.modelVersionId = versionId ? versionId.toString() : null;
|
||||
this.source = source;
|
||||
|
||||
if (!this.modelId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
|
||||
await this.retrieveVersionsForModel(this.modelId, this.source);
|
||||
} catch (error) {
|
||||
showToast('toast.downloads.loadError', { message: error.message }, 'error');
|
||||
return;
|
||||
} finally {
|
||||
this.loadingManager.hide();
|
||||
}
|
||||
|
||||
const version = this.versions.find(v => v.id.toString() === this.modelVersionId);
|
||||
if (!version) {
|
||||
console.warn('[download] openFileSelectionForVersion: version %s not found for model %s',
|
||||
this.modelVersionId, this.modelId);
|
||||
this.showVersionStep();
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRemainingFiles = this._getWeightFiles(version).length > 1
|
||||
&& this._getRemainingFiles(version).length > 0;
|
||||
|
||||
if (hasRemainingFiles) {
|
||||
this.showFileSelectionStep(version.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing left to download for this version (single file or all
|
||||
// files already in the library) — fall back to the version step.
|
||||
if (version.existsLocally) {
|
||||
showToast('toast.loras.versionExists', {}, 'info');
|
||||
}
|
||||
this.currentVersion = version;
|
||||
this.showVersionStep();
|
||||
}
|
||||
|
||||
showVersionStep() {
|
||||
document.getElementById('urlStep').style.display = 'none';
|
||||
document.getElementById('versionStep').style.display = 'block';
|
||||
@@ -595,7 +661,10 @@ export class DownloadManager {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const fileBadge = modelFiles.length > 1 && !existsLocally
|
||||
// Always offer the file-selection entry for multi-file versions,
|
||||
// even when the version is already (partially) in the library, so
|
||||
// remaining files can still be downloaded (#1058).
|
||||
const fileBadge = modelFiles.length > 1
|
||||
? `<span class="file-select-badge" data-version-id="${version.id}">
|
||||
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
|
||||
</span>`
|
||||
@@ -667,9 +736,14 @@ export class DownloadManager {
|
||||
const nextButton = document.getElementById('nextFromVersion');
|
||||
if (!nextButton) return;
|
||||
|
||||
const existsLocally = this.currentVersion?.existsLocally;
|
||||
const version = this.currentVersion;
|
||||
const existsLocally = version?.existsLocally;
|
||||
// A partially downloaded multi-file version still has downloadable
|
||||
// files, so Next routes into the file dialog instead of blocking (#1058).
|
||||
const hasRemainingFiles = this._getWeightFiles(version).length > 1
|
||||
&& this._getRemainingFiles(version).length > 0;
|
||||
|
||||
if (existsLocally) {
|
||||
if (existsLocally && !hasRemainingFiles) {
|
||||
nextButton.disabled = true;
|
||||
nextButton.classList.add('disabled');
|
||||
nextButton.textContent = translate('modals.download.alreadyInLibrary');
|
||||
@@ -680,14 +754,41 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
_getWeightFiles(version) {
|
||||
return (version?.files || []).filter(f => isModelWeightFile(f.type));
|
||||
}
|
||||
|
||||
_getRemainingFiles(version) {
|
||||
const downloadedIds = new Set(
|
||||
(version?.downloadedFiles || []).map(f => String(f.fileId))
|
||||
);
|
||||
return this._getWeightFiles(version).filter(f => !downloadedIds.has(String(f.id)));
|
||||
}
|
||||
|
||||
// Files of type UNet / Diffusion Model are routed to the diffusion_model
|
||||
// root while regular files go to the model-type root, so a single
|
||||
// multi-file selection session must stay within one routing group.
|
||||
_getFileRoutingGroup(file) {
|
||||
return (file.type === 'UNet' || file.type === 'Diffusion Model') ? 'diffusion' : 'model';
|
||||
}
|
||||
|
||||
showFileSelectionStep(versionId) {
|
||||
const version = this.versions.find(v => v.id.toString() === versionId.toString());
|
||||
if (!version) return;
|
||||
|
||||
this.currentVersion = version;
|
||||
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
||||
// Start each file-selection session with a clean selection
|
||||
this.selectedFiles = [];
|
||||
this.selectedFile = null;
|
||||
const modelFiles = this._getWeightFiles(version);
|
||||
const downloadedIds = new Set(
|
||||
(version.downloadedFiles || []).map(f => String(f.fileId))
|
||||
);
|
||||
|
||||
document.getElementById('versionStep').style.display = 'none';
|
||||
// Hide every other step — this dialog can be entered directly from
|
||||
// entry points like ModelVersionsTab, where the URL step would
|
||||
// otherwise remain visible (#1058).
|
||||
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
|
||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||
|
||||
const nameEl = document.getElementById('fileSelectionVersionName');
|
||||
@@ -699,9 +800,12 @@ export class DownloadManager {
|
||||
container.innerHTML = modelFiles.map(file => {
|
||||
const meta = file.metadata || {};
|
||||
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
|
||||
const isSelected = this.selectedFile?.id === file.id;
|
||||
const isDownloaded = downloadedIds.has(String(file.id));
|
||||
|
||||
const tags = [];
|
||||
if (isDownloaded) {
|
||||
tags.push(`<span class="file-tag in-library">${translate('modals.download.fileSelection.inLibrary', {}, 'In Library')}</span>`);
|
||||
}
|
||||
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
|
||||
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
|
||||
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
|
||||
@@ -709,9 +813,9 @@ export class DownloadManager {
|
||||
const fileName = file.name || '';
|
||||
|
||||
return `
|
||||
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
|
||||
<div class="file-option ${isDownloaded ? 'disabled' : ''}" data-file-id="${file.id}">
|
||||
<div class="file-option-radio">
|
||||
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
|
||||
<input type="checkbox" name="fileSelection" value="${file.id}" ${isDownloaded ? 'disabled' : ''}>
|
||||
</div>
|
||||
<div class="file-option-info">
|
||||
<div class="file-option-tags">
|
||||
@@ -725,33 +829,80 @@ export class DownloadManager {
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.file-option').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
|
||||
el.classList.add('selected');
|
||||
const radio = el.querySelector('input[type="radio"]');
|
||||
if (radio) radio.checked = true;
|
||||
el.addEventListener('click', (event) => {
|
||||
// Already-downloaded files stay disabled regardless
|
||||
if (el.classList.contains('disabled')) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||
if (!checkbox || checkbox.disabled) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
// Clicking the checkbox directly toggles natively; clicking
|
||||
// anywhere else on the option toggles it programmatically.
|
||||
if (event.target !== checkbox) {
|
||||
checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
this._syncFileSelectionState();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
confirmFileSelection() {
|
||||
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
|
||||
if (!selectedRadio) {
|
||||
console.warn('[download] confirmFileSelection: no radio button checked');
|
||||
return;
|
||||
}
|
||||
// Sync this.selectedFiles with the DOM checkboxes and enforce the
|
||||
// mixed-type routing guard by disabling the other routing group.
|
||||
_syncFileSelectionState() {
|
||||
const container = document.getElementById('fileSelectionList');
|
||||
if (!container || !this.currentVersion) return;
|
||||
|
||||
const checkedValues = new Set(
|
||||
Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
|
||||
.map(cb => cb.value)
|
||||
);
|
||||
const modelFiles = this._getWeightFiles(this.currentVersion);
|
||||
this.selectedFiles = modelFiles.filter(f => checkedValues.has(f.id.toString()));
|
||||
this.selectedFile = this.selectedFiles[0] || null;
|
||||
|
||||
const activeGroup = this.selectedFiles.length > 0
|
||||
? this._getFileRoutingGroup(this.selectedFiles[0])
|
||||
: null;
|
||||
|
||||
container.querySelectorAll('.file-option').forEach(el => {
|
||||
const checkbox = el.querySelector('input[type="checkbox"]');
|
||||
if (!checkbox || el.classList.contains('disabled')) return;
|
||||
|
||||
const file = modelFiles.find(f => f.id.toString() === el.dataset.fileId);
|
||||
const groupBlocked = activeGroup !== null
|
||||
&& file
|
||||
&& this._getFileRoutingGroup(file) !== activeGroup
|
||||
&& !checkbox.checked;
|
||||
|
||||
el.classList.toggle('selected', checkbox.checked);
|
||||
el.classList.toggle('group-disabled', groupBlocked);
|
||||
checkbox.disabled = groupBlocked;
|
||||
});
|
||||
}
|
||||
|
||||
confirmFileSelection() {
|
||||
const version = this.currentVersion;
|
||||
if (!version) {
|
||||
console.warn('[download] confirmFileSelection: no currentVersion set');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
||||
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||
// Sync from the DOM first so programmatically checked boxes count too
|
||||
this._syncFileSelectionState();
|
||||
|
||||
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
||||
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
|
||||
if (this.selectedFiles.length === 0) {
|
||||
console.warn('[download] confirmFileSelection: no file selected');
|
||||
showToast('toast.loras.pleaseSelectFile', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[download] confirmFileSelection: %d file(s) selected — %o',
|
||||
this.selectedFiles.length,
|
||||
this.selectedFiles.map(f => ({ id: f.id, name: f.name, type: f.type })));
|
||||
|
||||
document.getElementById('fileSelectionStep').style.display = 'none';
|
||||
document.getElementById('downloadLocationStep').style.display = 'block';
|
||||
@@ -782,6 +933,13 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
if (this.currentVersion.existsLocally) {
|
||||
// Multi-file versions with remaining undownloaded files route
|
||||
// into the file dialog instead of being blocked outright (#1058).
|
||||
if (this._getWeightFiles(this.currentVersion).length > 1
|
||||
&& this._getRemainingFiles(this.currentVersion).length > 0) {
|
||||
this.showFileSelectionStep(this.currentVersion.id);
|
||||
return;
|
||||
}
|
||||
showToast('toast.loras.versionExists', {}, 'info');
|
||||
return;
|
||||
}
|
||||
@@ -916,6 +1074,9 @@ export class DownloadManager {
|
||||
source = null,
|
||||
fileParams = null,
|
||||
closeModal = false,
|
||||
deferReload = false,
|
||||
suppressSuccessToast = false,
|
||||
suppressFailureSummary = false,
|
||||
}) {
|
||||
const config = this.apiClient?.apiConfig?.config;
|
||||
|
||||
@@ -924,7 +1085,8 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
const displayName = versionName || `#${versionId}`;
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
|
||||
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary };
|
||||
this._lastDownloadError = null;
|
||||
let ws = null;
|
||||
let updateProgress = () => { };
|
||||
let cancelled = false;
|
||||
@@ -1007,7 +1169,9 @@ export class DownloadManager {
|
||||
if (response?.skipped) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
updateProgress(100, 0, displayName);
|
||||
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
|
||||
if (!suppressSuccessToast) {
|
||||
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
|
||||
}
|
||||
if (closeModal) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
}
|
||||
@@ -1016,6 +1180,22 @@ export class DownloadManager {
|
||||
|
||||
if (!response?.success) {
|
||||
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
|
||||
const errorMessage = response?.error || 'Unknown error';
|
||||
// When the caller aggregates failures itself (multi-file
|
||||
// loop), just record the error and return (#1058).
|
||||
if (suppressFailureSummary) {
|
||||
this._lastDownloadError = errorMessage;
|
||||
return false;
|
||||
}
|
||||
// A file-level "already in library" rejection is an expected
|
||||
// outcome when browsing files of a partially downloaded
|
||||
// version — surface it as a lightweight toast instead of the
|
||||
// failure summary modal so the user can simply go back and
|
||||
// pick another file (#1058).
|
||||
if (typeof errorMessage === 'string' && errorMessage.includes('already exists in')) {
|
||||
showToast(errorMessage, {}, 'info');
|
||||
return false;
|
||||
}
|
||||
showDownloadBatchSummary({
|
||||
total: 1,
|
||||
completed: 0,
|
||||
@@ -1026,7 +1206,7 @@ export class DownloadManager {
|
||||
source,
|
||||
url: this._buildSingleItemUrl({ modelId, versionId, source }),
|
||||
},
|
||||
error: response?.error || 'Unknown error',
|
||||
error: errorMessage,
|
||||
name: displayName,
|
||||
}],
|
||||
onRetry: () => this.executeDownloadWithProgress(retryParams),
|
||||
@@ -1034,7 +1214,9 @@ export class DownloadManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
if (!suppressSuccessToast) {
|
||||
showToast('toast.loras.downloadCompleted', {}, 'success');
|
||||
}
|
||||
|
||||
if (closeModal) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
@@ -1045,29 +1227,35 @@ export class DownloadManager {
|
||||
ws = null;
|
||||
}
|
||||
|
||||
const pageState = this.apiClient.getPageState();
|
||||
if (!deferReload) {
|
||||
const pageState = this.apiClient.getPageState();
|
||||
|
||||
if (!useDefaultPaths && targetFolder) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
if (!useDefaultPaths && targetFolder) {
|
||||
pageState.activeFolder = targetFolder;
|
||||
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
|
||||
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
|
||||
const isActive = tag.dataset.folder === targetFolder;
|
||||
tag.classList.toggle('active', isActive);
|
||||
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
|
||||
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (cancelled) {
|
||||
console.log('Download cancelled by user:', downloadId);
|
||||
} else {
|
||||
console.error('Failed to download model version:', error);
|
||||
if (suppressFailureSummary) {
|
||||
this._lastDownloadError = error?.message || 'Unknown error';
|
||||
return false;
|
||||
}
|
||||
showDownloadBatchSummary({
|
||||
total: 1,
|
||||
completed: 0,
|
||||
@@ -1097,6 +1285,89 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download multiple selected files of the same version sequentially,
|
||||
* reusing the location-step choices for every file. Per-file toasts,
|
||||
* reloads and failure modals are suppressed; a single aggregated result
|
||||
* is shown at the end (design decision D5, #1058).
|
||||
*/
|
||||
async _downloadSelectedFilesSequentially({ modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot = false, files = null }) {
|
||||
const filesToDownload = files || this.selectedFiles;
|
||||
const totalFiles = filesToDownload.length;
|
||||
const failedItems = [];
|
||||
let completedDownloads = 0;
|
||||
|
||||
for (const file of filesToDownload) {
|
||||
const fileParams = {
|
||||
id: file.id,
|
||||
name: file.name || null,
|
||||
type: file.type || 'Model',
|
||||
format: file.metadata?.format || null,
|
||||
size: file.metadata?.size || null,
|
||||
fp: file.metadata?.fp || null,
|
||||
};
|
||||
|
||||
console.log('[download] multi-file loop: downloading file id=%s, name="%s" (%d/%d)',
|
||||
fileParams.id, fileParams.name, completedDownloads + failedItems.length + 1, totalFiles);
|
||||
|
||||
const success = await this.executeDownloadWithProgress({
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
versionName: file.name || `${this.currentVersion.name} #${file.id}`,
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
useSaveDirAsRoot,
|
||||
source: this.source,
|
||||
fileParams,
|
||||
closeModal: false,
|
||||
deferReload: true,
|
||||
suppressSuccessToast: true,
|
||||
suppressFailureSummary: true,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
completedDownloads++;
|
||||
} else {
|
||||
failedItems.push({
|
||||
item: {
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
source: this.source,
|
||||
file,
|
||||
url: this._buildSingleItemUrl({
|
||||
modelId: this.modelId,
|
||||
versionId: this.currentVersion.id,
|
||||
source: this.source,
|
||||
}),
|
||||
},
|
||||
error: this._lastDownloadError || 'Unknown error',
|
||||
name: file.name || `#${file.id}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failedItems.length === 0) {
|
||||
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
|
||||
} else {
|
||||
showDownloadBatchSummary({
|
||||
total: totalFiles,
|
||||
completed: completedDownloads,
|
||||
failedItems,
|
||||
onRetry: () => this._downloadSelectedFilesSequentially({
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
useSaveDirAsRoot,
|
||||
files: failedItems.map(f => f.item.file),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
await resetAndReload(true);
|
||||
return failedItems.length === 0;
|
||||
}
|
||||
|
||||
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
this.loadingManager.restoreProgressBar();
|
||||
@@ -1307,6 +1578,14 @@ export class DownloadManager {
|
||||
? (ver.modelSizeKB / 1024).toFixed(1)
|
||||
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
|
||||
const existsLocally = ver?.existsLocally;
|
||||
// Multi-file versions that are only partially downloaded get a
|
||||
// distinct hint instead of the plain in-library badge (#1058).
|
||||
const isPartiallyDownloaded = existsLocally
|
||||
&& this._getWeightFiles(ver).length > 1
|
||||
&& this._getRemainingFiles(ver).length > 0;
|
||||
const localBadgeLabel = isPartiallyDownloaded
|
||||
? translate('modals.download.partiallyDownloaded', {}, 'Partially downloaded')
|
||||
: translate('modals.download.inLibrary');
|
||||
return `
|
||||
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
|
||||
<div class="batch-preview-thumbnail">
|
||||
@@ -1317,7 +1596,7 @@ export class DownloadManager {
|
||||
<div class="batch-preview-meta">
|
||||
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
|
||||
<span>${fileSize} MB</span>
|
||||
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
|
||||
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${localBadgeLabel}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${item.versions.length > 1 ? `
|
||||
@@ -1608,8 +1887,20 @@ export class DownloadManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Multi-file selection: download all selected files sequentially,
|
||||
// reusing the chosen location for every file (#1058).
|
||||
if (this.selectedFiles.length > 1) {
|
||||
modalManager.closeModal('downloadModal');
|
||||
return this._downloadSelectedFilesSequentially({
|
||||
modelRoot,
|
||||
targetFolder,
|
||||
useDefaultPaths,
|
||||
});
|
||||
}
|
||||
|
||||
const fileParams = this.selectedFile ? {
|
||||
id: this.selectedFile.id,
|
||||
name: this.selectedFile.name || null,
|
||||
type: this.selectedFile.type || 'Model',
|
||||
format: this.selectedFile.metadata?.format || null,
|
||||
size: this.selectedFile.metadata?.size || null,
|
||||
@@ -1843,8 +2134,9 @@ export class DownloadManager {
|
||||
|
||||
async initializeFolderTree() {
|
||||
try {
|
||||
// Fetch unified folder tree
|
||||
const treeData = await this.apiClient.fetchUnifiedFolderTree();
|
||||
// Fetch unified folder tree, including empty directories so they
|
||||
// can be selected as download destinations
|
||||
const treeData = await this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true });
|
||||
|
||||
if (treeData.success) {
|
||||
// Load tree data into folder tree manager
|
||||
|
||||
@@ -7,6 +7,10 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
|
||||
|
||||
// LoRA availability statuses available on the recipes page. No statuses
|
||||
// selected (the default) means no filtering.
|
||||
const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted'];
|
||||
|
||||
export class FilterManager {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
@@ -74,6 +78,11 @@ export class FilterManager {
|
||||
this.initializeLicenseFilters();
|
||||
}
|
||||
|
||||
// Add click handlers for LoRA availability tags (recipes page only)
|
||||
if (this.shouldShowLoraAvailabilityFilter()) {
|
||||
this.initializeLoraAvailabilityFilters();
|
||||
}
|
||||
|
||||
// Initialize tag logic toggle
|
||||
this.initializeTagLogicToggle();
|
||||
|
||||
@@ -421,6 +430,42 @@ export class FilterManager {
|
||||
});
|
||||
}
|
||||
|
||||
initializeLoraAvailabilityFilters() {
|
||||
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
|
||||
availabilityTags.forEach(tag => {
|
||||
tag.addEventListener('click', async () => {
|
||||
const status = tag.dataset.availability;
|
||||
const selected = this.filters.loraAvailability || [];
|
||||
|
||||
if (selected.includes(status)) {
|
||||
this.filters.loraAvailability = selected.filter(value => value !== status);
|
||||
tag.classList.remove('active');
|
||||
} else {
|
||||
this.filters.loraAvailability = [...selected, status];
|
||||
tag.classList.add('active');
|
||||
}
|
||||
|
||||
this.updateActiveFiltersCount();
|
||||
await this.applyFilters(false);
|
||||
});
|
||||
});
|
||||
|
||||
// Update selections based on stored filters
|
||||
this.updateLoraAvailabilitySelections();
|
||||
}
|
||||
|
||||
updateLoraAvailabilitySelections() {
|
||||
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
|
||||
const selected = this.filters.loraAvailability || [];
|
||||
availabilityTags.forEach(tag => {
|
||||
if (selected.includes(tag.dataset.availability)) {
|
||||
tag.classList.add('active');
|
||||
} else {
|
||||
tag.classList.remove('active');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
createBaseModelTags() {
|
||||
const baseModelTagsContainer = document.getElementById('baseModelTags');
|
||||
if (!baseModelTagsContainer) return;
|
||||
@@ -681,6 +726,11 @@ export class FilterManager {
|
||||
}
|
||||
this.updateModelTypeSelections();
|
||||
|
||||
// Update LoRA availability tags if visible on this page
|
||||
if (this.shouldShowLoraAvailabilityFilter()) {
|
||||
this.updateLoraAvailabilitySelections();
|
||||
}
|
||||
|
||||
const autoTagEls = document.querySelectorAll('.auto-tag-filter');
|
||||
autoTagEls.forEach(el => {
|
||||
const tag = el.dataset.autoTag;
|
||||
@@ -708,7 +758,9 @@ export class FilterManager {
|
||||
const modelTypeFilterCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount;
|
||||
// Active when at least one availability status is deselected
|
||||
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
|
||||
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount;
|
||||
|
||||
if (this.activeFiltersCount) {
|
||||
if (totalActiveFilters > 0) {
|
||||
@@ -805,6 +857,7 @@ export class FilterManager {
|
||||
autoTags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
loraAvailability: [],
|
||||
tagLogic: 'any'
|
||||
});
|
||||
|
||||
@@ -891,12 +944,14 @@ export class FilterManager {
|
||||
const modelTypeCount = this.filters.modelTypes.length;
|
||||
// Exclude EMPTY_WILDCARD_MARKER from base model count
|
||||
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
|
||||
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
|
||||
return (
|
||||
baseModelCount > 0 ||
|
||||
tagCount > 0 ||
|
||||
autoTagCount > 0 ||
|
||||
licenseCount > 0 ||
|
||||
modelTypeCount > 0
|
||||
modelTypeCount > 0 ||
|
||||
loraAvailabilityCount > 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -909,6 +964,7 @@ export class FilterManager {
|
||||
autoTags: this.normalizeTagFilters(source.autoTags),
|
||||
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
|
||||
modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
|
||||
loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability),
|
||||
tagLogic: source.tagLogic || 'any'
|
||||
};
|
||||
}
|
||||
@@ -917,6 +973,33 @@ export class FilterManager {
|
||||
return this.currentPage !== 'recipes';
|
||||
}
|
||||
|
||||
shouldShowLoraAvailabilityFilter() {
|
||||
return this.currentPage === 'recipes';
|
||||
}
|
||||
|
||||
normalizeLoraAvailabilityFilters(loraAvailability) {
|
||||
// Default to no statuses selected (= no filtering)
|
||||
if (!Array.isArray(loraAvailability)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set();
|
||||
return loraAvailability.reduce((acc, status) => {
|
||||
if (typeof status !== 'string') {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (!LORA_AVAILABILITY_STATUSES.includes(normalized) || seen.has(normalized)) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
acc.push(normalized);
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
||||
normalizeTagFilters(tagFilters) {
|
||||
if (!tagFilters) {
|
||||
return {};
|
||||
@@ -994,6 +1077,7 @@ export class FilterManager {
|
||||
autoTags: { ...(this.filters.autoTags || {}) },
|
||||
license: { ...(this.filters.license || {}) },
|
||||
modelTypes: [...(this.filters.modelTypes || [])],
|
||||
loraAvailability: [...(this.filters.loraAvailability || [])],
|
||||
tagLogic: this.filters.tagLogic || 'any',
|
||||
search: pageState?.filters?.search ?? ''
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ImportManager {
|
||||
this.selectedFolder = '';
|
||||
this.downloadableLoRAs = [];
|
||||
this.recipeId = null;
|
||||
this.importMode = 'url'; // Default mode: 'url' or 'upload'
|
||||
this.importMode = null; // Set by input handlers: 'url' or 'upload'
|
||||
this.useDefaultPath = false;
|
||||
this.apiClient = null;
|
||||
|
||||
@@ -66,14 +66,17 @@ export class ImportManager {
|
||||
|
||||
// Show modal
|
||||
modalManager.showModal('importModal', null, () => {
|
||||
console.log('[RecipeImport] Import modal closed.');
|
||||
this.cleanupFolderBrowser();
|
||||
this.stepManager.removeInjectedStyles();
|
||||
});
|
||||
|
||||
// Verify visibility and focus on URL input
|
||||
console.log(
|
||||
`[RecipeImport] Import modal opened (${recipeData ? 'download-missing-loras mode' : 'new import'}).`
|
||||
);
|
||||
|
||||
// Verify visibility and focus on the URL input (primary mode)
|
||||
setTimeout(() => {
|
||||
// Ensure URL option is selected and focus on the input
|
||||
this.toggleImportMode('url');
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
if (urlInput) {
|
||||
urlInput.focus();
|
||||
@@ -87,6 +90,62 @@ export class ImportManager {
|
||||
if (useDefaultPathToggle) {
|
||||
useDefaultPathToggle.addEventListener('change', this.handleToggleDefaultPath);
|
||||
}
|
||||
|
||||
const modal = document.getElementById('importModal');
|
||||
const dropZone = document.getElementById('importDropZone');
|
||||
const fileInput = document.getElementById('recipeImageUpload');
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
|
||||
// Submit URL with Enter
|
||||
if (urlInput) {
|
||||
urlInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
this.handleUrlInput();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (dropZone && fileInput) {
|
||||
// Click or keyboard activation opens the file picker
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
fileInput.click();
|
||||
}
|
||||
});
|
||||
|
||||
// Drag & drop
|
||||
dropZone.addEventListener('dragover', (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.add('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('drag-over');
|
||||
});
|
||||
dropZone.addEventListener('drop', (event) => {
|
||||
event.preventDefault();
|
||||
dropZone.classList.remove('drag-over');
|
||||
const file = event.dataTransfer?.files?.[0];
|
||||
if (file) {
|
||||
this.imageProcessor.handleDroppedFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Paste an image from clipboard while the modal is open
|
||||
if (modal) {
|
||||
modal.addEventListener('paste', (event) => {
|
||||
if (this.stepManager.currentStep !== 'uploadStep') return;
|
||||
const file = Array.from(event.clipboardData?.files || [])
|
||||
.find(f => f.type.startsWith('image/'));
|
||||
if (file) {
|
||||
event.preventDefault();
|
||||
this.imageProcessor.handleDroppedFile(file);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetSteps() {
|
||||
@@ -128,9 +187,11 @@ export class ImportManager {
|
||||
this.downloadableLoRAs = [];
|
||||
this.selectedFolder = '';
|
||||
|
||||
// Reset import mode
|
||||
this.importMode = 'url';
|
||||
this.toggleImportMode('url');
|
||||
// Import mode is set by the input handlers ('url' or 'upload')
|
||||
this.importMode = null;
|
||||
|
||||
// Reset drop zone filename feedback
|
||||
this.updateSelectedFileName(null);
|
||||
|
||||
// Clear folder tree selection
|
||||
if (this.folderTreeManager) {
|
||||
@@ -166,43 +227,24 @@ export class ImportManager {
|
||||
}
|
||||
}
|
||||
|
||||
toggleImportMode(mode) {
|
||||
this.importMode = mode;
|
||||
/**
|
||||
* Show the selected file name in the drop zone, or restore the default
|
||||
* hint text when called with null.
|
||||
*/
|
||||
updateSelectedFileName(fileName) {
|
||||
const nameEl = document.getElementById('selectedFileName');
|
||||
const hintEl = document.getElementById('dropZonePrimaryText');
|
||||
if (!nameEl || !hintEl) return;
|
||||
|
||||
// Update toggle buttons
|
||||
const uploadBtn = document.querySelector('.toggle-btn[data-mode="upload"]');
|
||||
const urlBtn = document.querySelector('.toggle-btn[data-mode="url"]');
|
||||
|
||||
if (uploadBtn && urlBtn) {
|
||||
if (mode === 'upload') {
|
||||
uploadBtn.classList.add('active');
|
||||
urlBtn.classList.remove('active');
|
||||
} else {
|
||||
uploadBtn.classList.remove('active');
|
||||
urlBtn.classList.add('active');
|
||||
}
|
||||
if (fileName) {
|
||||
nameEl.textContent = fileName;
|
||||
nameEl.style.display = 'block';
|
||||
hintEl.style.display = 'none';
|
||||
} else {
|
||||
nameEl.textContent = '';
|
||||
nameEl.style.display = 'none';
|
||||
hintEl.style.display = '';
|
||||
}
|
||||
|
||||
// Show/hide appropriate sections
|
||||
const uploadSection = document.getElementById('uploadSection');
|
||||
const urlSection = document.getElementById('urlSection');
|
||||
|
||||
if (uploadSection && urlSection) {
|
||||
if (mode === 'upload') {
|
||||
uploadSection.style.display = 'block';
|
||||
urlSection.style.display = 'none';
|
||||
} else {
|
||||
uploadSection.style.display = 'none';
|
||||
urlSection.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// Clear error messages
|
||||
const uploadError = document.getElementById('uploadError');
|
||||
const importUrlError = document.getElementById('importUrlError');
|
||||
|
||||
if (uploadError) uploadError.textContent = '';
|
||||
if (importUrlError) importUrlError.textContent = '';
|
||||
}
|
||||
|
||||
handleImageUpload(event) {
|
||||
@@ -345,6 +387,9 @@ export class ImportManager {
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
if (urlInput) urlInput.value = '';
|
||||
|
||||
// Reset drop zone filename feedback
|
||||
this.updateSelectedFileName(null);
|
||||
|
||||
// Clear error messages
|
||||
const uploadError = document.getElementById('uploadError');
|
||||
if (uploadError) uploadError.textContent = '';
|
||||
|
||||
@@ -146,7 +146,13 @@ export class ModalManager {
|
||||
});
|
||||
}
|
||||
|
||||
// Add batchImportModal registration
|
||||
// Add batchImportModal registration.
|
||||
// Deliberately no closeOnOutsideClick: batch import is a stateful,
|
||||
// multi-step workflow (input -> progress -> results) that runs a
|
||||
// long-lived background operation. A stray backdrop click would
|
||||
// dismiss the modal while the import keeps running, leaving users
|
||||
// unable to tell what is still happening (issue #1084). Close is
|
||||
// available via the explicit X button / Cancel instead.
|
||||
const batchImportModal = document.getElementById('batchImportModal');
|
||||
if (batchImportModal) {
|
||||
this.registerModal('batchImportModal', {
|
||||
@@ -154,8 +160,7 @@ export class ModalManager {
|
||||
onClose: () => {
|
||||
this.getModal('batchImportModal').element.style.display = 'none';
|
||||
document.body.classList.remove('modal-open');
|
||||
},
|
||||
closeOnOutsideClick: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -200,8 +200,9 @@ class MoveManager {
|
||||
async initializeFolderTree() {
|
||||
try {
|
||||
const apiClient = this._getApiClient();
|
||||
// Fetch unified folder tree
|
||||
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||
// Fetch unified folder tree, including empty directories so they
|
||||
// can be selected as move targets
|
||||
const treeData = await apiClient.fetchUnifiedFolderTree({ includeEmpty: true });
|
||||
|
||||
if (treeData.success) {
|
||||
// Load tree data into folder tree manager
|
||||
|
||||
@@ -304,6 +304,7 @@ export class SearchManager {
|
||||
pageState.searchOptions.modelname = options.modelname || false;
|
||||
pageState.searchOptions.tags = options.tags || false;
|
||||
pageState.searchOptions.creator = options.creator || false;
|
||||
pageState.searchOptions.hash = options.hash || false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -904,6 +904,9 @@ export class SettingsManager {
|
||||
// Helper to update model Combobox presets from catalog / Ollama API
|
||||
const llmModelInput = document.getElementById('llmModel');
|
||||
this._llmModelCombobox = null;
|
||||
if (llmModelInput) {
|
||||
llmModelInput.value = state.global.settings.llm_model || '';
|
||||
}
|
||||
if (llmModelInput && typeof Combobox !== 'undefined') {
|
||||
const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai';
|
||||
const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []);
|
||||
|
||||
@@ -19,6 +19,10 @@ export class DownloadManager {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[RecipeImport] Saving recipe "${this.importManager.recipeName}" (download-only=${isDownloadOnly}, skipDownload=${skipDownload})`
|
||||
);
|
||||
|
||||
try {
|
||||
// Show progress indicator
|
||||
const loadingMessage = skipDownload
|
||||
@@ -102,6 +106,7 @@ export class DownloadManager {
|
||||
if (!result.success) {
|
||||
// Handle save error
|
||||
console.error("Failed to save recipe:", result.error);
|
||||
console.log('[RecipeImport] Save failed; closing import modal.');
|
||||
showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error');
|
||||
// Close modal
|
||||
modalManager.closeModal('importModal');
|
||||
@@ -112,6 +117,7 @@ export class DownloadManager {
|
||||
// Check if we need to download LoRAs (skip if skipDownload is true)
|
||||
let failedDownloads = 0;
|
||||
if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
|
||||
console.log(`[RecipeImport] Downloading ${this.importManager.downloadableLoRAs.length} missing LoRA(s)...`);
|
||||
await this.downloadMissingLoras();
|
||||
}
|
||||
|
||||
@@ -127,6 +133,7 @@ export class DownloadManager {
|
||||
}
|
||||
|
||||
modalManager.closeModal('importModal');
|
||||
console.log(`[RecipeImport] Recipe "${this.importManager.recipeName}" saved successfully.`);
|
||||
|
||||
if (isDownloadOnly && state.virtualScroller) {
|
||||
const recipeId = this.importManager.recipeId;
|
||||
|
||||
@@ -8,20 +8,33 @@ export class ImageProcessor {
|
||||
|
||||
handleFileUpload(event) {
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
this.handleDroppedFile(file);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared entry for files coming from the file picker, drag & drop,
|
||||
* or clipboard paste.
|
||||
*/
|
||||
handleDroppedFile(file) {
|
||||
const errorElement = document.getElementById('uploadError');
|
||||
|
||||
if (!file) return;
|
||||
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.match('image.*')) {
|
||||
errorElement.textContent = translate('recipes.controls.import.errors.selectImageFile', {}, 'Please select an image file');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Reset error
|
||||
errorElement.textContent = '';
|
||||
this.importManager.recipeImage = file;
|
||||
|
||||
this.importManager.importMode = 'upload';
|
||||
console.log(`[RecipeImport] Recipe image selected: ${file.name}`);
|
||||
|
||||
// Show the selected file name in the drop zone
|
||||
this.importManager.updateSelectedFileName(file.name);
|
||||
|
||||
// Auto-proceed to next step if file is selected
|
||||
this.importManager.uploadAndAnalyzeImage();
|
||||
}
|
||||
@@ -30,19 +43,41 @@ export class ImageProcessor {
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
const errorElement = document.getElementById('importUrlError');
|
||||
const input = urlInput.value.trim();
|
||||
|
||||
|
||||
// Validate input
|
||||
if (!input) {
|
||||
errorElement.textContent = translate('recipes.controls.import.errors.enterUrlOrPath', {}, 'Please enter a URL or file path');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Front-end format validation before hitting the backend
|
||||
if (input.startsWith('http://') || input.startsWith('https://')) {
|
||||
try {
|
||||
new URL(input);
|
||||
} catch {
|
||||
errorElement.textContent = translate('recipes.controls.import.errors.invalidUrl', {}, 'Please enter a valid URL');
|
||||
return;
|
||||
}
|
||||
} else if (!/\.(png|jpe?g|webp|gif|bmp|avif|jxl|mp4|webm)$/i.test(input)) {
|
||||
errorElement.textContent = translate('recipes.controls.import.errors.invalidInputFormat', {}, 'Please enter an image URL or a local image file path');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset error
|
||||
errorElement.textContent = '';
|
||||
|
||||
this.importManager.importMode = 'url';
|
||||
|
||||
console.log(
|
||||
`[RecipeImport] Analyzing recipe input (${input.startsWith('http://') || input.startsWith('https://') ? 'remote URL' : 'local path'}): ${input.slice(0, 80)}`
|
||||
);
|
||||
|
||||
// Put the fetch button into a loading state to prevent duplicate submits
|
||||
const fetchBtn = document.getElementById('fetchImageBtn');
|
||||
this._setFetchButtonLoading(fetchBtn, true);
|
||||
|
||||
// Show loading indicator
|
||||
this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.processingInput', {}, 'Processing input...'));
|
||||
|
||||
|
||||
try {
|
||||
// Check if it's a URL or a local file path
|
||||
if (input.startsWith('http://') || input.startsWith('https://')) {
|
||||
@@ -55,10 +90,21 @@ export class ImageProcessor {
|
||||
} catch (error) {
|
||||
errorElement.textContent = error.message || 'Failed to process input';
|
||||
} finally {
|
||||
this._setFetchButtonLoading(fetchBtn, false);
|
||||
this.importManager.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
_setFetchButtonLoading(button, isLoading) {
|
||||
if (!button) return;
|
||||
button.disabled = isLoading;
|
||||
button.classList.toggle('loading', isLoading);
|
||||
const icon = button.querySelector('i');
|
||||
if (icon) {
|
||||
icon.className = isLoading ? 'fas fa-spinner fa-spin' : 'fas fa-download';
|
||||
}
|
||||
}
|
||||
|
||||
async analyzeImageFromUrl(url) {
|
||||
try {
|
||||
// Call the API with URL data
|
||||
@@ -103,6 +149,9 @@ export class ImageProcessor {
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
console.log(
|
||||
`[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.`
|
||||
);
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
} catch (error) {
|
||||
@@ -155,6 +204,9 @@ export class ImageProcessor {
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
console.log(
|
||||
`[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.`
|
||||
);
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
} catch (error) {
|
||||
@@ -210,6 +262,9 @@ export class ImageProcessor {
|
||||
this.importManager.importAsNew = false;
|
||||
|
||||
// Proceed to recipe details step
|
||||
console.log(
|
||||
`[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.`
|
||||
);
|
||||
this.importManager.showRecipeDetailsStep();
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export class ImportStepManager {
|
||||
constructor() {
|
||||
this.injectedStyles = null;
|
||||
this.currentStep = null;
|
||||
}
|
||||
|
||||
removeInjectedStyles() {
|
||||
@@ -18,6 +19,7 @@ export class ImportStepManager {
|
||||
showStep(stepId) {
|
||||
// Remove any injected styles to prevent conflicts
|
||||
this.removeInjectedStyles();
|
||||
this.currentStep = stepId;
|
||||
|
||||
// Hide all steps first
|
||||
document.querySelectorAll('.import-step').forEach(step => {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
@@ -147,6 +148,7 @@ export const state = {
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
loraAvailability: [],
|
||||
search: ''
|
||||
},
|
||||
pageSize: 20,
|
||||
@@ -168,6 +170,7 @@ export const state = {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
@@ -207,6 +210,7 @@ export const state = {
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
hash: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
|
||||
@@ -544,6 +544,12 @@ export class MasonryScroller {
|
||||
modelCard.style.minWidth = '0';
|
||||
}
|
||||
|
||||
// Scroll recycling rebuilds cards from scratch, dropping `.selected`
|
||||
// even though the filepath is still selected in state.
|
||||
if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
|
||||
modelCard.classList.add('selected');
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -497,6 +497,12 @@ export class VirtualScroller {
|
||||
modelCard.style.minWidth = '0';
|
||||
}
|
||||
|
||||
// Scroll recycling rebuilds cards from scratch, dropping `.selected`
|
||||
// even though the filepath is still selected in state.
|
||||
if (state.bulkMode && modelCard && state.selectedModels.has(modelCard.dataset.filepath)) {
|
||||
modelCard.classList.add('selected');
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,47 @@ export function extractCivitaiModelUrlParts(url) {
|
||||
}
|
||||
}
|
||||
|
||||
const CIVITARCHIVE_PAGE_HOSTS = new Set([
|
||||
'civitaiarchive.com',
|
||||
'civarchive.com',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Classify a relink URL by its hosting source and extract ids.
|
||||
* CivitArchive mirrors the Civitai id namespace, so both sources resolve to
|
||||
* the same {modelId, modelVersionId} shape; only `source` differs.
|
||||
*/
|
||||
export function classifyModelRelinkUrl(url) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return { source: null, modelId: null, modelVersionId: null };
|
||||
}
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url.trim());
|
||||
} catch (e) {
|
||||
return { source: null, modelId: null, modelVersionId: null };
|
||||
}
|
||||
|
||||
if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
|
||||
return { source: null, modelId: null, modelVersionId: null };
|
||||
}
|
||||
|
||||
const hostname = parsedUrl.hostname.toLowerCase().replace(/^www\./, '');
|
||||
const pathMatch = parsedUrl.pathname.match(/\/models\/(\d+)/);
|
||||
const modelId = pathMatch ? pathMatch[1] : null;
|
||||
const modelVersionId = parsedUrl.searchParams.get('modelVersionId');
|
||||
|
||||
if (SUPPORTED_CIVITAI_PAGE_HOSTS.has(hostname)) {
|
||||
return { source: 'civitai', modelId, modelVersionId };
|
||||
}
|
||||
if (CIVITARCHIVE_PAGE_HOSTS.has(hostname) && modelId) {
|
||||
return { source: 'civarchive', modelId, modelVersionId };
|
||||
}
|
||||
|
||||
return { source: null, modelId: null, modelVersionId: null };
|
||||
}
|
||||
|
||||
export function extractCivitaiImageId(url) {
|
||||
if (!url) {
|
||||
return null;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user