mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
6 Commits
c52cfc7e7a
...
574dfbbe55
| Author | SHA1 | Date | |
|---|---|---|---|
| 574dfbbe55 | |||
| 1d3bcdfe47 | |||
| 74369940bf | |||
| d188cec306 | |||
| 641a61f804 | |||
| 3025c64fea |
@@ -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():
|
||||
|
||||
+9
-6
@@ -963,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",
|
||||
@@ -1421,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"
|
||||
},
|
||||
@@ -2224,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"
|
||||
|
||||
+11
-8
@@ -864,8 +864,8 @@
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Recipe navigation",
|
||||
"previousWithShortcut": "Previous recipe (\u2190)",
|
||||
"nextWithShortcut": "Next recipe (\u2192)"
|
||||
"previousWithShortcut": "Previous recipe (←)",
|
||||
"nextWithShortcut": "Next recipe (→)"
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Send Workflow to ComfyUI",
|
||||
@@ -963,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",
|
||||
@@ -1421,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"
|
||||
},
|
||||
@@ -2224,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"
|
||||
|
||||
+9
-6
@@ -963,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",
|
||||
@@ -1421,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"
|
||||
},
|
||||
@@ -2224,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"
|
||||
|
||||
+9
-6
@@ -963,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",
|
||||
@@ -1421,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"
|
||||
},
|
||||
@@ -2224,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"
|
||||
|
||||
+9
-6
@@ -963,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "סורק למציאת מתכונים כפולים...",
|
||||
"found": "נמצאו {count} קבוצות כפולות",
|
||||
"noGroups": "לא נמצאו קבוצות כפולות לפי קריטריון ההתאמה הנוכחי",
|
||||
"keepLatest": "שמור גרסאות אחרונות",
|
||||
@@ -1421,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": "אשר קישור מחדש"
|
||||
},
|
||||
@@ -2224,6 +2226,7 @@
|
||||
"relinkFailed": "שגיאה: {message}",
|
||||
"linkHfSuccess": "המודל נקשר בהצלחה ל-HuggingFace",
|
||||
"linkHfFailed": "שגיאה: {message}",
|
||||
"linkCivArchSuccess": "המודל קושר מחדש דרך CivitArchive בהצלחה",
|
||||
"fetchMetadataFirst": "אנא אחזר מטא-דאטה מ-CivitAI תחילה",
|
||||
"noCivitaiInfo": "אין מידע מ-CivitAI זמין",
|
||||
"missingHash": "ה-hash של המודל אינו זמין"
|
||||
|
||||
+9
-6
@@ -963,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "重複レシピをスキャンしています...",
|
||||
"found": "{count} 個の重複グループが見つかりました",
|
||||
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
|
||||
"keepLatest": "最新バージョンを保持",
|
||||
@@ -1421,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": "再リンクを確認"
|
||||
},
|
||||
@@ -2224,6 +2226,7 @@
|
||||
"relinkFailed": "エラー:{message}",
|
||||
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||
"linkHfFailed": "エラー:{message}",
|
||||
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
|
||||
+9
-6
@@ -963,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "중복 레시피를 스캔하는 중...",
|
||||
"found": "{count}개의 중복 그룹 발견",
|
||||
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
|
||||
"keepLatest": "최신 버전 유지",
|
||||
@@ -1421,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": "다시 연결 확인"
|
||||
},
|
||||
@@ -2224,6 +2226,7 @@
|
||||
"relinkFailed": "오류: {message}",
|
||||
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||
"linkHfFailed": "오류: {message}",
|
||||
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
|
||||
+9
-6
@@ -963,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "Поиск дублирующихся рецептов...",
|
||||
"found": "Найдено {count} групп дубликатов",
|
||||
"noGroups": "Дубликатов с текущим критерием не найдено",
|
||||
"keepLatest": "Оставить последние версии",
|
||||
@@ -1421,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": "Подтвердить пересвязывание"
|
||||
},
|
||||
@@ -2224,6 +2226,7 @@
|
||||
"relinkFailed": "Ошибка: {message}",
|
||||
"linkHfSuccess": "Модель успешно связана с HuggingFace",
|
||||
"linkHfFailed": "Ошибка: {message}",
|
||||
"linkCivArchSuccess": "Модель успешно пересвязана через CivitArchive",
|
||||
"fetchMetadataFirst": "Пожалуйста, сначала получите метаданные с CivitAI",
|
||||
"noCivitaiInfo": "Информация CivitAI недоступна",
|
||||
"missingHash": "Хеш модели недоступен"
|
||||
|
||||
+9
-6
@@ -963,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "正在扫描重复配方...",
|
||||
"found": "发现 {count} 个重复组",
|
||||
"noGroups": "按当前判重依据未找到重复组",
|
||||
"keepLatest": "保留最新版本",
|
||||
@@ -1421,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": "确认重新关联"
|
||||
},
|
||||
@@ -2224,6 +2226,7 @@
|
||||
"relinkFailed": "错误:{message}",
|
||||
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||
"linkHfFailed": "错误:{message}",
|
||||
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
|
||||
+9
-6
@@ -963,6 +963,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "正在掃描重複配方...",
|
||||
"found": "發現 {count} 組重複項",
|
||||
"noGroups": "按目前判重依據未找到重複組",
|
||||
"keepLatest": "保留最新版本",
|
||||
@@ -1421,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": "確認重新連結"
|
||||
},
|
||||
@@ -2224,6 +2226,7 @@
|
||||
"relinkFailed": "錯誤:{message}",
|
||||
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||
"linkHfFailed": "錯誤:{message}",
|
||||
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
|
||||
@@ -634,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(
|
||||
@@ -649,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,
|
||||
@@ -670,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(
|
||||
|
||||
@@ -618,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"),
|
||||
@@ -642,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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "")
|
||||
)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -66,10 +66,15 @@ export class ImportManager {
|
||||
|
||||
// Show modal
|
||||
modalManager.showModal('importModal', null, () => {
|
||||
console.log('[RecipeImport] Import modal closed.');
|
||||
this.cleanupFolderBrowser();
|
||||
this.stepManager.removeInjectedStyles();
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[RecipeImport] Import modal opened (${recipeData ? 'download-missing-loras mode' : 'new import'}).`
|
||||
);
|
||||
|
||||
// Verify visibility and focus on the URL input (primary mode)
|
||||
setTimeout(() => {
|
||||
const urlInput = document.getElementById('imageUrlInput');
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,6 +30,7 @@ export class ImageProcessor {
|
||||
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);
|
||||
@@ -66,6 +67,10 @@ export class ImageProcessor {
|
||||
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);
|
||||
@@ -144,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) {
|
||||
@@ -196,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) {
|
||||
@@ -251,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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
• {{ t('modals.relinkCivitai.helpText.format1') }}<br>
|
||||
• {{ t('modals.relinkCivitai.helpText.format2') }}<br>
|
||||
• {{ t('modals.relinkCivitai.helpText.format3') }}<br>
|
||||
• {{ t('modals.relinkCivitai.helpText.format4') }}<br>
|
||||
<em>{{ t('modals.relinkCivitai.helpText.note') }}</em>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2504,4 +2504,172 @@ describe('Interaction-level regression coverage', () => {
|
||||
|
||||
delete stateStub.currentPageType;
|
||||
});
|
||||
|
||||
it('opens the relink modal from the relink-civitai menu action', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu">
|
||||
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
|
||||
<div class="context-submenu">
|
||||
<div class="context-menu-item" data-action="relink-civitai"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
const showModalSpy = vi.spyOn(contextMenu, 'showRelinkCivitaiModal').mockImplementation(() => {});
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
document.querySelector('[data-action="relink-civitai"]').dispatchEvent(new Event('click', { bubbles: true }));
|
||||
|
||||
expect(showModalSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects an unsupported relink URL with an inline error and no fetch', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
contextMenu.showRelinkCivitaiModal();
|
||||
|
||||
document.getElementById('civitaiModelUrl').value = 'https://example.com/models/123456';
|
||||
await contextMenu._boundRelinkHandler();
|
||||
|
||||
expect(document.getElementById('civitaiModelUrlError').textContent)
|
||||
.toBe('Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}');
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(modalManagerMock.closeModal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a valid CivitArchive URL to the relink endpoint with the civarchive source', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
}));
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
contextMenu.showRelinkCivitaiModal();
|
||||
|
||||
document.getElementById('civitaiModelUrl').value = 'https://civarchive.com/models/123456?modelVersionId=789012';
|
||||
await contextMenu._boundRelinkHandler();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('relinkCivitaiModal');
|
||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalledWith('Re-linking via CivitArchive...');
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
file_path: '/models/test.safetensors',
|
||||
model_id: '123456',
|
||||
model_version_id: '789012',
|
||||
source: 'civarchive',
|
||||
}),
|
||||
});
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.linkCivArchSuccess', {}, 'success');
|
||||
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts a Civitai URL without a source key so backend defaults apply', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
<div id="relinkCivitaiModal" class="modal">
|
||||
<input type="text" id="civitaiModelUrl" />
|
||||
<div class="input-error" id="civitaiModelUrlError"></div>
|
||||
<button class="confirm-btn" id="confirmRelinkBtn"></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
global.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
}));
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'model-card';
|
||||
card.dataset.filepath = '/models/test.safetensors';
|
||||
document.body.appendChild(card);
|
||||
|
||||
contextMenu.showMenu(100, 100, card);
|
||||
contextMenu.showRelinkCivitaiModal();
|
||||
|
||||
document.getElementById('civitaiModelUrl').value = 'https://civitai.com/models/65423?modelVersionId=777';
|
||||
await contextMenu._boundRelinkHandler();
|
||||
await flushAsyncTasks();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/relink-civitai', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
file_path: '/models/test.safetensors',
|
||||
model_id: '65423',
|
||||
model_version_id: '777',
|
||||
}),
|
||||
});
|
||||
expect(showToastMock).toHaveBeenCalledWith('toast.contextMenu.relinkSuccess', {}, 'success');
|
||||
});
|
||||
|
||||
it('derives relink endpoint prefixes for all model types', async () => {
|
||||
document.body.innerHTML = `
|
||||
<div id="loraContextMenu" class="context-menu"></div>
|
||||
`;
|
||||
|
||||
const { LoraContextMenu } = await import('../../../static/js/components/ContextMenu/LoraContextMenu.js');
|
||||
const contextMenu = new LoraContextMenu();
|
||||
|
||||
contextMenu.modelType = 'lora';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('loras');
|
||||
contextMenu.modelType = 'checkpoint';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('checkpoints');
|
||||
contextMenu.modelType = 'embedding';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('embeddings');
|
||||
contextMenu.modelType = 'unknown';
|
||||
expect(contextMenu.getModelTypePrefix()).toBe('loras');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,20 +2,15 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { ModelContextMenuMixin } from '../../../static/js/components/ContextMenu/ModelContextMenuMixin.js';
|
||||
|
||||
describe('ModelContextMenuMixin.extractModelVersionId', () => {
|
||||
it('accepts civitai.red model URLs', () => {
|
||||
expect(
|
||||
ModelContextMenuMixin.extractModelVersionId(
|
||||
'https://civitai.red/models/65423/nijimecha-artstyle?modelVersionId=777'
|
||||
)
|
||||
).toEqual({ modelId: '65423', modelVersionId: '777' });
|
||||
describe('ModelContextMenuMixin.getModelTypePrefix', () => {
|
||||
it('maps every known model type to its API route prefix', () => {
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'lora' })).toBe('loras');
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'checkpoint' })).toBe('checkpoints');
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'embedding' })).toBe('embeddings');
|
||||
});
|
||||
|
||||
it('rejects model-like URLs from unsupported hosts', () => {
|
||||
expect(
|
||||
ModelContextMenuMixin.extractModelVersionId(
|
||||
'https://example.com/models/65423?modelVersionId=777'
|
||||
)
|
||||
).toEqual({ modelId: null, modelVersionId: null });
|
||||
it('falls back to the loras prefix for unknown types', () => {
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({ modelType: 'unknown' })).toBe('loras');
|
||||
expect(ModelContextMenuMixin.getModelTypePrefix.call({})).toBe('loras');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderTemplate } from '../utils/domFixtures.js';
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
setupAutoNewlineOnPaste: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||
WS_ENDPOINTS: {},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
getStorageItem: vi.fn(() => true),
|
||||
setStorageItem: vi.fn(),
|
||||
}));
|
||||
|
||||
// jsdom has no WebSocket; the manager only needs open/connecting/close states.
|
||||
class FakeWebSocket {
|
||||
constructor(url) {
|
||||
this.url = url;
|
||||
this.readyState = 0;
|
||||
}
|
||||
close() {
|
||||
this.readyState = 3;
|
||||
}
|
||||
}
|
||||
FakeWebSocket.OPEN = 1;
|
||||
FakeWebSocket.CONNECTING = 0;
|
||||
|
||||
const RUNNING_PROGRESS = {
|
||||
status: 'running',
|
||||
total: 2,
|
||||
completed: 1,
|
||||
success: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
progress_percent: 50,
|
||||
current_item: 'image-1.png',
|
||||
};
|
||||
|
||||
const COMPLETED_PROGRESS = {
|
||||
status: 'completed',
|
||||
total: 2,
|
||||
completed: 2,
|
||||
success: 2,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
progress_percent: 100,
|
||||
current_item: '',
|
||||
};
|
||||
|
||||
describe('BatchImportManager reopen behavior (#1084)', () => {
|
||||
let modalManager;
|
||||
let batchImportManager;
|
||||
let fetchMock;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
document.body.innerHTML = '';
|
||||
renderTemplate('components/batch_import_modal.html');
|
||||
|
||||
// jsdom does not implement window.scrollTo; ModalManager calls it on close.
|
||||
window.scrollTo = vi.fn();
|
||||
|
||||
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||
fetchMock = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({}),
|
||||
}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const modalModule = await import('../../../static/js/managers/ModalManager.js');
|
||||
modalManager = modalModule.modalManager;
|
||||
modalManager.initialize();
|
||||
|
||||
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
|
||||
batchImportManager = new batchModule.BatchImportManager();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (batchImportManager) {
|
||||
batchImportManager.cleanupConnections();
|
||||
}
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
async function startImportViaUrls(urls) {
|
||||
batchImportManager.showModal();
|
||||
document.getElementById('batchUrlInput').value = urls.join('\n');
|
||||
fetchMock.mockImplementation(async (url) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () =>
|
||||
url.includes('/batch-import/start')
|
||||
? { success: true, operation_id: 'op-123' }
|
||||
: { success: true, progress: RUNNING_PROGRESS },
|
||||
}));
|
||||
await batchImportManager.startImport();
|
||||
}
|
||||
|
||||
it('opens a fresh input form when no operation exists', () => {
|
||||
batchImportManager.showModal();
|
||||
expect(document.getElementById('batchImportModal').style.display).toBe('block');
|
||||
expect(document.getElementById('batchInputStep').style.display).toBe('block');
|
||||
expect(document.getElementById('batchProgressStep').style.display).toBe('none');
|
||||
});
|
||||
|
||||
it('reopens into the progress view while an import keeps running in the background', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await startImportViaUrls([
|
||||
'https://civitai.com/images/1',
|
||||
'https://civitai.com/images/2',
|
||||
]);
|
||||
|
||||
expect(batchImportManager.isImporting).toBe(true);
|
||||
expect(batchImportManager.operationId).toBe('op-123');
|
||||
expect(document.getElementById('batchProgressStep').style.display).toBe('block');
|
||||
|
||||
// Close the modal the same way the X button does.
|
||||
modalManager.closeModal('batchImportModal');
|
||||
expect(document.getElementById('batchImportModal').style.display).toBe('none');
|
||||
|
||||
// Closing while running must be visible in the console (#1084).
|
||||
const closedWhileRunning = logSpy.mock.calls.some((call) =>
|
||||
String(call[0]).includes('Modal closed while import op-123 is still running')
|
||||
);
|
||||
expect(closedWhileRunning).toBe(true);
|
||||
|
||||
// Reopen: the in-flight operation must be restored, not discarded.
|
||||
batchImportManager.showModal();
|
||||
expect(document.getElementById('batchImportModal').style.display).toBe('block');
|
||||
expect(batchImportManager.operationId).toBe('op-123');
|
||||
expect(batchImportManager.isImporting).toBe(true);
|
||||
expect(document.getElementById('batchProgressStep').style.display).toBe('block');
|
||||
expect(document.getElementById('batchInputStep').style.display).toBe('none');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('reopens into the results view after a background import completes', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await startImportViaUrls([
|
||||
'https://civitai.com/images/1',
|
||||
'https://civitai.com/images/2',
|
||||
]);
|
||||
|
||||
// Operation finishes while the modal stays closed.
|
||||
modalManager.closeModal('batchImportModal');
|
||||
batchImportManager.handleProgressUpdate(RUNNING_PROGRESS);
|
||||
batchImportManager.handleProgressUpdate(COMPLETED_PROGRESS);
|
||||
|
||||
expect(batchImportManager.isImporting).toBe(false);
|
||||
expect(batchImportManager.results.status).toBe('completed');
|
||||
|
||||
// Reopening shows the finished results instead of a blank form.
|
||||
batchImportManager.showModal();
|
||||
expect(document.getElementById('batchImportModal').style.display).toBe('block');
|
||||
expect(document.getElementById('batchResultsStep').style.display).toBe('block');
|
||||
expect(document.getElementById('batchInputStep').style.display).toBe('none');
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not close when clicking the backdrop (stateful workflow, #1084)', async () => {
|
||||
await startImportViaUrls(['https://civitai.com/images/1']);
|
||||
|
||||
const modalEl = document.getElementById('batchImportModal');
|
||||
expect(modalEl.style.display).toBe('block');
|
||||
|
||||
// Simulate a backdrop click: mousedown + mouseup on the modal shell.
|
||||
// Because batch import is a stateful, multi-step workflow, the modal must
|
||||
// not dismiss on stray outside clicks.
|
||||
modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
modalEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||
|
||||
expect(modalEl.style.display).toBe('block');
|
||||
expect(modalManager.isAnyModalOpen()).toBe('batchImportModal');
|
||||
});
|
||||
|
||||
it('logs start, progress and completion to the console', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
|
||||
await startImportViaUrls(['https://civitai.com/images/1']);
|
||||
|
||||
batchImportManager.handleProgressUpdate(RUNNING_PROGRESS);
|
||||
// A second poll tick with identical data must not log again (#1084).
|
||||
batchImportManager.handleProgressUpdate(RUNNING_PROGRESS);
|
||||
batchImportManager.handleProgressUpdate(COMPLETED_PROGRESS);
|
||||
|
||||
const messages = logSpy.mock.calls.map((call) => String(call[0]));
|
||||
expect(messages.some((m) => m.includes('[BatchImport] Import started, operation_id=op-123'))).toBe(true);
|
||||
expect(messages.filter((m) => m.includes('[BatchImport] Progress 50%')).length).toBe(1);
|
||||
expect(messages.some((m) => m.includes('[BatchImport] Import finished: status=completed'))).toBe(true);
|
||||
|
||||
logSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Regression guard: every `.modal` element shipped via components/modals.html
|
||||
// must be registered in ModalManager.initialize(). An unregistered modal makes
|
||||
// modalManager.showModal(id) silently no-op (see getModal returning undefined),
|
||||
// which manifests as "clicking the menu item does nothing" with no console
|
||||
// error — exactly the Link-to-CivitArchive bug this file guards against.
|
||||
describe('ModalManager registry parity', () => {
|
||||
const repoRoot = path.resolve(__dirname, '../../..');
|
||||
const modalsHtml = readFileSync(
|
||||
path.join(repoRoot, 'templates/components/modals.html'),
|
||||
'utf-8'
|
||||
);
|
||||
const modalManagerSrc = readFileSync(
|
||||
path.join(repoRoot, 'static/js/managers/ModalManager.js'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
const collectModalIds = (target, seen = new Set()) => {
|
||||
if (statSync(target).isFile()) {
|
||||
extractIds(readFileSync(target, 'utf-8'), seen);
|
||||
return seen;
|
||||
}
|
||||
for (const entry of readdirSync(target, { withFileTypes: true })) {
|
||||
collectModalIds(path.join(target, entry.name), seen);
|
||||
}
|
||||
return seen;
|
||||
};
|
||||
|
||||
const extractIds = (content, seen) => {
|
||||
for (const match of content.matchAll(/id="([A-Za-z][\w-]*)"[^>]*class="modal"/g)) {
|
||||
seen.add(match[1]);
|
||||
}
|
||||
};
|
||||
|
||||
it('registers every modal declared in templates', () => {
|
||||
const includeFiles = [
|
||||
...modalsHtml.matchAll(/\{%\s*include\s*'([^']+\.html)'\s*%\}/g),
|
||||
].map((m) => m[1]);
|
||||
|
||||
expect(includeFiles.length).toBeGreaterThan(0);
|
||||
|
||||
const declaredIds = new Set();
|
||||
for (const relPath of includeFiles) {
|
||||
collectModalIds(path.join(repoRoot, 'templates', relPath), declaredIds);
|
||||
}
|
||||
|
||||
expect(declaredIds.size).toBeGreaterThan(0);
|
||||
|
||||
const unregistered = [...declaredIds].filter(
|
||||
(id) => !modalManagerSrc.includes(`registerModal('${id}'`)
|
||||
);
|
||||
|
||||
expect(
|
||||
unregistered,
|
||||
'Modal ids rendered on pages but never registered in ModalManager.initialize() — showModal() will silently do nothing for them'
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getThumbnailUrl,
|
||||
extractCivitaiImageId,
|
||||
extractCivitaiModelUrlParts,
|
||||
classifyModelRelinkUrl,
|
||||
isCivitaiUrl,
|
||||
isSupportedCivitaiPageHost,
|
||||
OptimizationMode
|
||||
@@ -305,4 +306,48 @@ describe('civitaiUtils', () => {
|
||||
expect(extractCivitaiImageId('https://example.com/images/126920345')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyModelRelinkUrl', () => {
|
||||
it('classifies civitai.com model URLs', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civitai.com/models/649516/name?modelVersionId=726676')
|
||||
).toEqual({ source: 'civitai', modelId: '649516', modelVersionId: '726676' });
|
||||
});
|
||||
|
||||
it('classifies civitai.red model URLs without a version id', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civitai.red/models/65423/')
|
||||
).toEqual({ source: 'civitai', modelId: '65423', modelVersionId: null });
|
||||
});
|
||||
|
||||
it('classifies civarchive and civitaiarchive model URLs', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civarchive.com/models/1746460')
|
||||
).toEqual({ source: 'civarchive', modelId: '1746460', modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl('http://www.civitaiarchive.com/models/42?modelVersionId=43')
|
||||
).toEqual({ source: 'civarchive', modelId: '42', modelVersionId: '43' });
|
||||
});
|
||||
|
||||
it('rejects archive hosts when the path has no numeric model id', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://civarchive.com/images/123')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
});
|
||||
|
||||
it('rejects unsupported hosts and malformed input', () => {
|
||||
expect(
|
||||
classifyModelRelinkUrl('https://example.com/models/65423')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl('not a url')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl('')
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
expect(
|
||||
classifyModelRelinkUrl(null)
|
||||
).toEqual({ source: null, modelId: null, modelVersionId: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,16 @@ import json
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from py.config import config
|
||||
from py.routes.handlers.model_handlers import ModelCivitaiHandler, ModelUpdateHandler
|
||||
from py.routes.handlers.model_handlers import (
|
||||
ModelCivitaiHandler,
|
||||
ModelManagementHandler,
|
||||
ModelUpdateHandler,
|
||||
)
|
||||
from py.services.service_registry import ServiceRegistry
|
||||
from py.utils.metadata_manager import MetadataManager
|
||||
from py.services.model_update_service import ModelUpdateRecord, ModelVersionRecord
|
||||
@@ -965,3 +970,110 @@ def test_serialize_version_file_count_defaults_to_none():
|
||||
)
|
||||
serialized = ModelUpdateHandler._serialize_version(version, None)
|
||||
assert serialized["fileCount"] is None
|
||||
|
||||
|
||||
def _build_relink_handler(metadata_sync):
|
||||
service = SimpleNamespace(
|
||||
scanner=SimpleNamespace(update_single_model_cache=AsyncMock())
|
||||
)
|
||||
return ModelManagementHandler(
|
||||
service=service,
|
||||
logger=logging.getLogger(__name__),
|
||||
metadata_sync=metadata_sync,
|
||||
preview_service=SimpleNamespace(),
|
||||
tag_update_service=SimpleNamespace(),
|
||||
lifecycle_service=SimpleNamespace(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_civitai_rejects_unsupported_source():
|
||||
metadata_sync = SimpleNamespace(
|
||||
load_local_metadata=AsyncMock(return_value={}),
|
||||
relink_metadata=AsyncMock(),
|
||||
)
|
||||
handler = _build_relink_handler(metadata_sync)
|
||||
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"file_path": "/tmp/model.safetensors",
|
||||
"model_id": "123",
|
||||
"model_version_id": "456",
|
||||
"source": "huggingface",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
response = await handler.relink_civitai(request)
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert "Unsupported relink source" in payload["error"]
|
||||
metadata_sync.relink_metadata.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_civitai_passes_provider_name_for_civarchive_source():
|
||||
metadata_sync = SimpleNamespace(
|
||||
load_local_metadata=AsyncMock(return_value={"model_name": "Local"}),
|
||||
relink_metadata=AsyncMock(
|
||||
return_value={"model_name": "Archived", "sha256": "abc"}
|
||||
),
|
||||
)
|
||||
handler = _build_relink_handler(metadata_sync)
|
||||
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"file_path": "/tmp/model.safetensors",
|
||||
"model_id": "123",
|
||||
"model_version_id": "456",
|
||||
"source": "civarchive",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
response = await handler.relink_civitai(request)
|
||||
assert response.status == 200
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is True
|
||||
assert "CivArchive" in payload["message"]
|
||||
metadata_sync.relink_metadata.assert_awaited_once_with(
|
||||
file_path="/tmp/model.safetensors",
|
||||
metadata={"model_name": "Local"},
|
||||
model_id=123,
|
||||
model_version_id=456,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_civitai_surfaces_provider_unavailable_without_500():
|
||||
metadata_sync = SimpleNamespace(
|
||||
load_local_metadata=AsyncMock(return_value={}),
|
||||
relink_metadata=AsyncMock(
|
||||
side_effect=ValueError(
|
||||
"CivitArchive is not available or not enabled. "
|
||||
"Enable the CivitArchive API in settings to relink via CivArchive."
|
||||
)
|
||||
),
|
||||
)
|
||||
handler = _build_relink_handler(metadata_sync)
|
||||
|
||||
request = SimpleNamespace(
|
||||
json=AsyncMock(
|
||||
return_value={
|
||||
"file_path": "/tmp/model.safetensors",
|
||||
"model_id": "123",
|
||||
"model_version_id": None,
|
||||
"source": "civarchive",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
response = await handler.relink_civitai(request)
|
||||
assert response.status == 400
|
||||
payload = json.loads(response.text)
|
||||
assert payload["success"] is False
|
||||
assert "CivitArchive" in payload["error"]
|
||||
|
||||
@@ -2003,10 +2003,10 @@ async def test_find_duplicates_defaults_to_fingerprint_only(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.recipes = {
|
||||
"r1": {"id": "r1", "title": "One", "modified": 100},
|
||||
"r2": {"id": "r2", "title": "Two", "modified": 200},
|
||||
}
|
||||
harness.scanner.cached_raw = [
|
||||
{"id": "r1", "title": "One", "modified": 100},
|
||||
{"id": "r2", "title": "Two", "modified": 200},
|
||||
]
|
||||
harness.scanner.duplicate_groups_override = {"abc:0.8": ["r1", "r2"]}
|
||||
harness.scanner.duplicate_source_groups_override = {}
|
||||
|
||||
@@ -2028,12 +2028,12 @@ async def test_find_duplicates_forwards_include_prompt_and_assigns_unique_keys(
|
||||
monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.scanner.recipes = {
|
||||
"r1": {"id": "r1", "title": "One", "modified": 100},
|
||||
"r2": {"id": "r2", "title": "Two", "modified": 200},
|
||||
"r3": {"id": "r3", "title": "Three", "modified": 300},
|
||||
"r4": {"id": "r4", "title": "Four", "modified": 400},
|
||||
}
|
||||
harness.scanner.cached_raw = [
|
||||
{"id": "r1", "title": "One", "modified": 100},
|
||||
{"id": "r2", "title": "Two", "modified": 200},
|
||||
{"id": "r3", "title": "Three", "modified": 300},
|
||||
{"id": "r4", "title": "Four", "modified": 400},
|
||||
]
|
||||
harness.scanner.duplicate_groups_override = {"abc:0.8\x1fa girl": ["r1", "r2"]}
|
||||
harness.scanner.duplicate_source_groups_override = {
|
||||
"civitai.com/images/9": ["r3", "r4"]
|
||||
|
||||
@@ -560,6 +560,131 @@ async def test_relink_metadata_raises_when_version_missing():
|
||||
model_version_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_uses_named_civarchive_provider(tmp_path):
|
||||
default_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(),
|
||||
)
|
||||
civarchive_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(
|
||||
return_value={
|
||||
"files": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "Model",
|
||||
"hashes": {"SHA256": "ABCDEF"},
|
||||
}
|
||||
],
|
||||
"model": {"name": "Archived"},
|
||||
"images": [],
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
async def select_provider(name: str):
|
||||
return civarchive_provider if name == "civarchive_api" else default_provider
|
||||
|
||||
provider_selector = AsyncMock(side_effect=select_provider)
|
||||
helpers = build_service(
|
||||
default_provider=default_provider,
|
||||
provider_selector=provider_selector,
|
||||
)
|
||||
|
||||
metadata = {"model_name": "Local", "sha256": "original"}
|
||||
result = await helpers.service.relink_metadata(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
metadata=metadata,
|
||||
model_id=1,
|
||||
model_version_id=2,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
assert result["model_name"] == "Archived"
|
||||
assert result["sha256"] == "original"
|
||||
provider_selector.assert_awaited_with("civarchive_api")
|
||||
civarchive_provider.get_model_version.assert_awaited_once_with(1, 2)
|
||||
helpers.default_provider_factory.assert_not_awaited()
|
||||
helpers.metadata_manager.save_metadata.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_raises_when_version_missing_with_civarchive():
|
||||
default_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(),
|
||||
)
|
||||
civarchive_provider = SimpleNamespace(
|
||||
get_model_by_hash=AsyncMock(),
|
||||
get_model_version=AsyncMock(return_value=None),
|
||||
)
|
||||
|
||||
async def select_provider(name: str):
|
||||
return civarchive_provider if name == "civarchive_api" else default_provider
|
||||
|
||||
provider_selector = AsyncMock(side_effect=select_provider)
|
||||
helpers = build_service(
|
||||
default_provider=default_provider,
|
||||
provider_selector=provider_selector,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="CivitArchive"):
|
||||
await helpers.service.relink_metadata(
|
||||
file_path="/tmp/model.safetensors",
|
||||
metadata={},
|
||||
model_id=9,
|
||||
model_version_id=None,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_raises_friendly_error_when_provider_unavailable():
|
||||
provider_selector = AsyncMock(
|
||||
side_effect=ValueError("Provider 'civarchive_api' is not registered")
|
||||
)
|
||||
helpers = build_service(provider_selector=provider_selector)
|
||||
|
||||
with pytest.raises(ValueError, match="CivitArchive is not available or not enabled"):
|
||||
await helpers.service.relink_metadata(
|
||||
file_path="/tmp/model.safetensors",
|
||||
metadata={},
|
||||
model_id=9,
|
||||
model_version_id=None,
|
||||
provider_name="civarchive_api",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_relink_metadata_default_call_uses_default_provider_factory(tmp_path):
|
||||
helpers = build_service()
|
||||
helpers.default_provider.get_model_version.return_value = {
|
||||
"files": [
|
||||
{
|
||||
"primary": True,
|
||||
"type": "Model",
|
||||
"hashes": {"SHA256": "ABCDEF"},
|
||||
}
|
||||
],
|
||||
"model": {"name": "Remote"},
|
||||
"images": [],
|
||||
}
|
||||
|
||||
result = await helpers.service.relink_metadata(
|
||||
file_path=str(tmp_path / "model.safetensors"),
|
||||
metadata={"model_name": "Local", "sha256": "original"},
|
||||
model_id=1,
|
||||
model_version_id=None,
|
||||
)
|
||||
|
||||
assert result["model_name"] == "Remote"
|
||||
assert result["sha256"] == "original"
|
||||
helpers.default_provider_factory.assert_awaited_once()
|
||||
helpers.provider_selector.assert_not_awaited()
|
||||
helpers.metadata_manager.save_metadata.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fetch_and_update_model_persists_db_checked_when_sqlite_fails(tmp_path):
|
||||
"""
|
||||
|
||||
@@ -293,6 +293,36 @@ def test_switching_back_to_user_config_moves_subdirectories(tmp_path, monkeypatc
|
||||
) == "project_wildcard"
|
||||
|
||||
|
||||
def test_portable_switch_ignored_when_settings_dir_pinned(tmp_path, monkeypatch):
|
||||
"""An explicit settings dir (--settings-path) must never trigger the
|
||||
portable-mode directory migration between project root and user config."""
|
||||
project_root, user_dir, user_settings = _setup_storage_paths(tmp_path, monkeypatch)
|
||||
_populate_settings_dir(user_dir)
|
||||
|
||||
custom_dir = tmp_path / "custom_settings"
|
||||
custom_dir.mkdir()
|
||||
monkeypatch.setattr(
|
||||
"py.services.settings_manager.ensure_settings_file",
|
||||
lambda logger=None: str(custom_dir / "settings.json"),
|
||||
)
|
||||
settings_paths.set_settings_dir_override(str(custom_dir))
|
||||
try:
|
||||
manager = SettingsManager()
|
||||
manager.settings_file = str(custom_dir / "settings.json")
|
||||
|
||||
manager.set("use_portable_settings", True)
|
||||
|
||||
# Settings file stays pinned; no directories are migrated anywhere and
|
||||
# the settings file is not mirrored to the user config dir.
|
||||
assert manager.settings_file == str(custom_dir / "settings.json")
|
||||
assert not (project_root / "cache").exists()
|
||||
assert not (project_root / "backups").exists()
|
||||
assert not (project_root / "settings.json").exists()
|
||||
assert not user_settings.exists()
|
||||
finally:
|
||||
settings_paths.set_settings_dir_override(None)
|
||||
|
||||
|
||||
def test_download_path_template_parses_json_string(manager):
|
||||
templates = {"lora": "{author}", "checkpoint": "{author}", "embedding": "{author}"}
|
||||
manager.settings["download_path_templates"] = json.dumps(templates)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -112,3 +114,44 @@ def test_validate_settings_logs_warnings(tmp_path, monkeypatch, caplog):
|
||||
|
||||
messages = [record.message for record in caplog.records]
|
||||
assert any("Standalone mode is using fallback configuration values." in message for message in messages)
|
||||
|
||||
|
||||
@pytest.mark.no_settings_dir_isolation
|
||||
def test_explicit_settings_dir_env_used_by_manager(tmp_path, monkeypatch):
|
||||
"""LORA_MANAGER_SETTINGS_DIR pins the settings file for the manager."""
|
||||
custom_dir = tmp_path / "custom"
|
||||
monkeypatch.setenv("LORA_MANAGER_SETTINGS_DIR", str(custom_dir))
|
||||
reset_settings_manager()
|
||||
|
||||
manager = get_settings_manager()
|
||||
|
||||
assert settings_paths.is_settings_dir_pinned()
|
||||
assert Path(manager.settings_file) == custom_dir / "settings.json"
|
||||
assert settings_paths.get_settings_dir() == str(custom_dir)
|
||||
|
||||
|
||||
def test_apply_settings_dir_from_argv():
|
||||
"""standalone's argv pre-scan publishes --settings-path into the env."""
|
||||
import standalone
|
||||
|
||||
# The helper writes to os.environ directly; manage the variable manually so
|
||||
# monkeypatch's undo stack cannot restore a stale value after the test.
|
||||
previous = os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
||||
try:
|
||||
standalone._apply_settings_dir_from_argv(
|
||||
["--port", "8199", "--settings-path", "/tmp/xyz-e2e-settings"]
|
||||
)
|
||||
assert os.environ["LORA_MANAGER_SETTINGS_DIR"] == "/tmp/xyz-e2e-settings"
|
||||
|
||||
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
||||
standalone._apply_settings_dir_from_argv(["--settings-path=/tmp/abc-e2e"])
|
||||
assert os.environ["LORA_MANAGER_SETTINGS_DIR"] == "/tmp/abc-e2e"
|
||||
|
||||
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
||||
standalone._apply_settings_dir_from_argv(["--port", "8199"])
|
||||
assert "LORA_MANAGER_SETTINGS_DIR" not in os.environ
|
||||
finally:
|
||||
if previous is None:
|
||||
os.environ.pop("LORA_MANAGER_SETTINGS_DIR", None)
|
||||
else:
|
||||
os.environ["LORA_MANAGER_SETTINGS_DIR"] = previous
|
||||
|
||||
@@ -6,7 +6,26 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from py.utils.settings_paths import _should_use_portable_settings
|
||||
from py.utils.settings_paths import (
|
||||
SETTINGS_DIR_ENV,
|
||||
_should_use_portable_settings,
|
||||
ensure_settings_file,
|
||||
get_settings_dir,
|
||||
get_settings_dir_override,
|
||||
is_settings_dir_pinned,
|
||||
set_settings_dir_override,
|
||||
)
|
||||
|
||||
|
||||
def _redirect_paths(tmp_path, monkeypatch):
|
||||
"""Pin project root and user config dir resolution to temp paths."""
|
||||
monkeypatch.setattr(
|
||||
"py.utils.settings_paths.get_project_root", lambda: str(tmp_path / "repo")
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"py.utils.settings_paths.user_config_dir",
|
||||
lambda *args, **kwargs: str(tmp_path / "user_config"),
|
||||
)
|
||||
|
||||
|
||||
class TestShouldUsePortableSettings:
|
||||
@@ -54,3 +73,69 @@ class TestShouldUsePortableSettings:
|
||||
mp.setenv("LORA_MANAGER_PORTABLE", "1")
|
||||
result = _should_use_portable_settings(str(missing), logging.getLogger())
|
||||
assert result is True
|
||||
|
||||
|
||||
class TestExplicitSettingsDirOverride:
|
||||
"""Tests for the LORA_MANAGER_SETTINGS_DIR / --settings-path override."""
|
||||
|
||||
def test_env_override_wins_over_portable_and_user_config(self, tmp_path, monkeypatch):
|
||||
_redirect_paths(tmp_path, monkeypatch)
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / "settings.json").write_text(
|
||||
json.dumps({"use_portable_settings": True}), encoding="utf-8"
|
||||
)
|
||||
custom_dir = tmp_path / "custom" / "e2e"
|
||||
|
||||
monkeypatch.setenv(SETTINGS_DIR_ENV, str(custom_dir))
|
||||
monkeypatch.delenv("LORA_MANAGER_PORTABLE", raising=False)
|
||||
|
||||
assert is_settings_dir_pinned()
|
||||
target = get_settings_dir(create=True)
|
||||
assert target == str(custom_dir.resolve())
|
||||
assert target == os.path.abspath(str(custom_dir))
|
||||
assert custom_dir.is_dir()
|
||||
|
||||
# The override is independent of the effective use_portable flag.
|
||||
with pytest.MonkeyPatch.context() as mp:
|
||||
mp.setenv("LORA_MANAGER_PORTABLE", "1")
|
||||
assert get_settings_dir(create=False) == os.path.abspath(str(custom_dir))
|
||||
|
||||
def test_env_override_normalizes_tilde(self, monkeypatch):
|
||||
monkeypatch.setenv(SETTINGS_DIR_ENV, "~/lm-e2e-settings")
|
||||
override = get_settings_dir_override()
|
||||
assert override == os.path.abspath(os.path.expanduser("~/lm-e2e-settings"))
|
||||
|
||||
def test_ensure_settings_file_pins_path_and_skips_migration(self, tmp_path, monkeypatch):
|
||||
_redirect_paths(tmp_path, monkeypatch)
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
legacy = repo / "settings.json"
|
||||
legacy.write_text(json.dumps({"language": "ja"}), encoding="utf-8")
|
||||
custom_dir = tmp_path / "custom"
|
||||
|
||||
monkeypatch.setenv(SETTINGS_DIR_ENV, str(custom_dir))
|
||||
|
||||
settings_file = ensure_settings_file()
|
||||
assert settings_file == os.path.join(os.path.abspath(str(custom_dir)), "settings.json")
|
||||
assert os.path.isdir(str(custom_dir))
|
||||
# The legacy (project-root) file must NOT be migrated into the custom dir.
|
||||
assert legacy.exists()
|
||||
assert not (custom_dir / "settings.json").exists()
|
||||
assert not (tmp_path / "user_config" / "settings.json").exists()
|
||||
|
||||
def test_programmatic_override_and_clear(self, tmp_path, monkeypatch):
|
||||
_redirect_paths(tmp_path, monkeypatch)
|
||||
monkeypatch.delenv(SETTINGS_DIR_ENV, raising=False)
|
||||
|
||||
custom_dir = tmp_path / "prog"
|
||||
previous = set_settings_dir_override(str(custom_dir))
|
||||
assert previous is None
|
||||
assert is_settings_dir_pinned()
|
||||
assert get_settings_dir(create=False) == os.path.abspath(str(custom_dir))
|
||||
|
||||
previous = set_settings_dir_override(None)
|
||||
assert previous == os.path.abspath(str(custom_dir))
|
||||
assert not is_settings_dir_pinned()
|
||||
# Falls back to the (redirected) platform user config dir.
|
||||
assert get_settings_dir(create=False) == str(tmp_path / "user_config")
|
||||
|
||||
Reference in New Issue
Block a user