Compare commits

...

6 Commits

Author SHA1 Message Date
Will Miao 574dfbbe55 feat(settings): add explicit settings dir override for sandboxed runs
Add LORA_MANAGER_SETTINGS_DIR env var and standalone --settings-path to pin
the settings location (settings.json, cache/, wildcards/, backups/, logs/,
stats/) to an arbitrary directory. The override takes precedence over
portable mode and the platform user config dir, and skips legacy migration,
so sandboxed dev/E2E runs no longer need to write settings.json in the repo
root or collide with the real instance.

standalone.py pre-scans argv for --settings-path at import time because the
settings location is resolved before main() parses arguments. SettingsManager
portable-switch migration is a no-op while the directory is pinned.

Update the lora-manager-e2e skill (prefer --settings-path sandboxing;
start_server.py passes it through) and the lora-manager-runtime-context
skill (document precedence; inspect script honors the override).
2026-08-27 00:03:50 +08:00
Will Miao 1d3bcdfe47 fix(skill): quote lora-manager-e2e description so YAML frontmatter parses 2026-08-26 22:56:54 +08:00
Will Miao 74369940bf fix(recipes): log batch import progress only when it changes (#1084) 2026-08-26 22:39:51 +08:00
Will Miao d188cec306 fix(recipes): restore batch import modal on reopen and log recipe ingest progress (#1084) 2026-08-26 22:32:20 +08:00
Will Miao 641a61f804 feat(relink): accept CivitArchive URLs when linking models 2026-08-26 21:31:30 +08:00
Will Miao 3025c64fea fix(recipes): serve duplicate scan from cache and guard against re-entry 2026-08-26 20:34:50 +08:00
41 changed files with 1604 additions and 215 deletions
+31 -19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.comcivitai.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
View File
@@ -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": "模型雜湊不可用"
+32 -7
View File
@@ -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(
+35 -52
View File
@@ -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(
+40
View File
@@ -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)
+26 -3
View File
@@ -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 "")
)
+15 -1
View File
@@ -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")
+81 -7
View File
@@ -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
View File
@@ -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;
+25 -4
View File
@@ -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();
}
}
+112 -3
View File
@@ -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) {
+5
View File
@@ -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');
+8 -3
View File
@@ -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) {
+41
View File
@@ -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([]);
});
});
+45
View File
@@ -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 });
});
});
});
+113 -1
View File
@@ -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"]
+10 -10
View File
@@ -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):
"""
+30
View File
@@ -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)
+43
View File
@@ -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
+86 -1
View File
@@ -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")