mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
113 Commits
v1.2.1
...
6b41c3bbb4
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b41c3bbb4 | |||
| b37238d790 | |||
| bc33e32c6f | |||
| 49704d801c | |||
| 34ca14d7fc | |||
| f7b247f9e8 | |||
| 3005d2877e | |||
| ed2a17970f | |||
| 9584fa85c9 | |||
| 1fd7cc0123 | |||
| 39e7c1376c | |||
| 2a3c632dc5 | |||
| 8d46d26abe | |||
| d761ac77f7 | |||
| c8b9db5bf4 | |||
| bce7d1d30c | |||
| bccd494a56 | |||
| 3fd29f6943 | |||
| 838a374a56 | |||
| 6e31da7a70 | |||
| fc9088bfd6 | |||
| 675421ea84 | |||
| 2ff98ae089 | |||
| c972c755fc | |||
| ebe3df7d22 | |||
| be44a75b74 | |||
| fd1227d3b8 | |||
| 3a9e02137d | |||
| d8a2be8edc | |||
| 1c46b2e8c3 | |||
| 3c3ac49f2f | |||
| 1a1be95a64 | |||
| 7a36659a20 | |||
| cb18281b14 | |||
| 856c9a87ac | |||
| a7d65fe84a | |||
| 15bf079af2 | |||
| 65ba750634 | |||
| 17dcbd3d4f | |||
| e914a0e19d | |||
| 2ba04bb1bd | |||
| 1b7314591a | |||
| 2bfb987312 | |||
| df34efafbc | |||
| c2a2048c8b | |||
| 1e1921cabb | |||
| ee233548e5 | |||
| 574dfbbe55 | |||
| 1d3bcdfe47 | |||
| 74369940bf | |||
| d188cec306 | |||
| 641a61f804 | |||
| 3025c64fea | |||
| c52cfc7e7a | |||
| 4ed9f775f6 | |||
| 0b08ad283a | |||
| 08895f77ff | |||
| 74f889f160 | |||
| c51090ab16 | |||
| cdb044cb45 | |||
| c83b26b556 | |||
| a202c666bc | |||
| e05046af10 | |||
| 41ed03e5c6 | |||
| da071e8452 | |||
| a0bb6df2b8 | |||
| 6f5c444ec5 | |||
| 20f66a4fe1 | |||
| 879745da53 | |||
| 3afec0a0be | |||
| 06c270a6e1 | |||
| 87e93636dc | |||
| 074d1f2e51 | |||
| 40f922b0e8 | |||
| a7214b6cff | |||
| 8ca66e72eb | |||
| 90be5799e4 | |||
| 1a93b0eca2 | |||
| c2360a35ad | |||
| 030a32f8fa | |||
| 25e72b43ce | |||
| 41e9883daa | |||
| ae461ebc81 | |||
| 3ebf256c5d | |||
| 0905e2be6e | |||
| bd380bc1a1 | |||
| cb4fd3a0e6 | |||
| bbe0acac5c | |||
| 45e7c25308 | |||
| 86aa1d8059 | |||
| 74254756ef | |||
| 259e08e47c | |||
| 6647c45731 | |||
| b614a5c447 | |||
| b80830913c | |||
| e57e11897e | |||
| 8a16034135 | |||
| 7fc3b7e5be | |||
| b0c7a1baae | |||
| 6411d83d46 | |||
| 74a063b0e5 | |||
| 96376e5cce | |||
| e7c26bf722 | |||
| cef4129fc9 | |||
| 0a28500848 | |||
| fc3f3f3bdb | |||
| fa58297973 | |||
| 5d1a22fb8f | |||
| d2f50f26f1 | |||
| 4a6042d0b4 | |||
| 846206d958 | |||
| 0daf4924f0 | |||
| d38a3d091d |
@@ -1,373 +1,146 @@
|
||||
---
|
||||
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 ONLY for sandboxed E2E validation of LoRa Manager standalone mode: start the standalone server on a free port with --settings-path, drive the web UI (http://127.0.0.1:{PORT}/loras) via Chrome DevTools MCP, and verify frontend-to-backend integration. NOT for UI behavior checks that unit tests (Vitest/jsdom) can cover. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox."
|
||||
---
|
||||
|
||||
# LoRa Manager E2E Testing
|
||||
|
||||
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
|
||||
End-to-end testing of LoRa Manager standalone mode using Chrome DevTools MCP.
|
||||
|
||||
## Conventions Used in This Document
|
||||
## When to Use — and When NOT To
|
||||
|
||||
- **`{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>`.
|
||||
E2E runs are slow and token-heavy. Reach for them only when the question genuinely
|
||||
spans server + browser (routing, scan persistence, websocket updates, EXIF writes).
|
||||
|
||||
- **Default to unit/component tests first**: `npm run test:js` (Vitest/jsdom) covers
|
||||
DOM rendering, modal behavior, event handling and API-client calls deterministically
|
||||
in seconds. Backend logic goes through `pytest`. A UI-behavior question answered by
|
||||
jsdom MUST NOT be escalated to E2E.
|
||||
- **Use E2E only when** the behavior cannot be observed without a live server and a
|
||||
real browser, e.g. template rendering through the aiohttp server, scanner → SQLite
|
||||
persistence → API → DOM round-trips, or real EXIF/image writes.
|
||||
- If you start an E2E and realize a unit test would answer the question, stop and
|
||||
switch.
|
||||
|
||||
**Browser driver is fixed: Chrome DevTools MCP.** Do not substitute kimi-webbridge —
|
||||
it operates on the user's real browser (real tabs, real sessions, synthetic
|
||||
`isTrusted=false` events), which breaks the isolation this skill requires and lacks
|
||||
the console/network inspection E2E debugging relies on. kimi-webbridge is for
|
||||
interactive browsing with the user's real login sessions, not for sandboxed E2E.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **`{PORT}`**: default candidate `8188`, but it is **commonly occupied by a live
|
||||
ComfyUI** — always check first (`ss -tlnp | grep ':{PORT}'`) and use a free port
|
||||
(e.g. `8199`). Substitute the chosen port everywhere below. Never kill a process
|
||||
you did not start for this E2E.
|
||||
- **`<repo-root>`**: the repository/worktree root; run all commands from there.
|
||||
- **`<sandbox>`**: a throwaway dir, e.g. `/tmp/opencode/<plan>-e2e`.
|
||||
|
||||
## 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.
|
||||
> Every E2E run MUST target a throwaway sandbox, never real user data.
|
||||
|
||||
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.
|
||||
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
|
||||
# BEFORE: snapshot real config + recipe library state
|
||||
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > /tmp/opencode/<plan>-e2e/settings.before.sha256
|
||||
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > /tmp/opencode/<plan>-e2e/recipes-count.before.txt
|
||||
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.
|
||||
1. **Explicit settings directory**: always launch with `--settings-path <sandbox>/settings`.
|
||||
This pins ALL runtime data (`settings.json`, `cache/`, `backups/`, `logs/`, `stats/`,
|
||||
`wildcards/`) under the sandbox. **Never** create `<repo-root>/settings.json` — the repo
|
||||
folder is usually the real ComfyUI plugin folder and a portable settings file there is
|
||||
read by the real instance.
|
||||
2. **Sandboxed library paths**: point `folder_paths` / `recipes_path` /
|
||||
`example_images_path` at disposable dirs under `<sandbox>` — never the real library,
|
||||
real recipe dir, or real settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"folder_paths": {
|
||||
"loras": ["<sandbox>/models/loras"],
|
||||
"checkpoints": ["<sandbox>/models/checkpoints"],
|
||||
"unet": ["<sandbox>/models/checkpoints"],
|
||||
"diffusers": []
|
||||
},
|
||||
"recipes_path": "<sandbox>/recipes",
|
||||
"example_images_path": "<sandbox>/example_images"
|
||||
}
|
||||
```
|
||||
Also confirm `<repo-root>/git status` stays clean for `settings.json`/`cache/` (both are gitignored).
|
||||
|
||||
### Portable Settings Example
|
||||
3. **Real-data protection proof**: before starting and after finishing, snapshot the real
|
||||
config and recipe library and confirm they are byte-identical; also confirm
|
||||
`<repo-root>` gained no `settings.json` or `cache/`:
|
||||
|
||||
```json
|
||||
{
|
||||
"use_portable_settings": true,
|
||||
"folder_paths": {
|
||||
"loras": ["/tmp/opencode/<plan>-e2e/models/loras"],
|
||||
"checkpoints": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
|
||||
"unet": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
|
||||
"diffusers": []
|
||||
},
|
||||
"recipes_path": "/tmp/opencode/<plan>-e2e/recipes",
|
||||
"example_images_path": "/tmp/opencode/<plan>-e2e/example_images"
|
||||
}
|
||||
```
|
||||
```bash
|
||||
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > <sandbox>/settings.before.sha256
|
||||
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > <sandbox>/recipes-count.before.txt
|
||||
# AFTER the run: record again and diff. Any change = the run leaked into real data.
|
||||
```
|
||||
|
||||
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)).
|
||||
|
||||
## Time Budgets & Abort Guidance
|
||||
|
||||
A fresh subagent should complete a sandboxed standalone E2E **in well under 30 minutes**. Budget each phase:
|
||||
|
||||
| Phase | Expected duration | Abort if |
|
||||
| --- | --- | --- |
|
||||
| Port check + sandbox setup | < 2 min | — |
|
||||
| Server start (detached) + readiness | < 30 s | > 60 s (2x) → stop |
|
||||
| Chrome DevTools MCP connect | < 1 min | > 2 min → stop |
|
||||
| Per entry-point run (after fixtures ready) | < 5 min | > 10 min (2x) → stop |
|
||||
| Fixture reset + cache clear between runs | < 1 min | > 2 min → stop |
|
||||
|
||||
**Abort rule**: if a phase exceeds ~2x its budget, OR any single tool call fails/retries 3+ times in a row, **STOP**. Do not loop or retry blindly. Report `BLOCKED` with: the phase, the last observed state (server PID + `ss -tlnp` output, page snapshot, last API response), and the suspected cause. Record the partial state as evidence; a clean BLOCKED report is more valuable than an hour of retries.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) — run everything from `<repo-root>`
|
||||
- Chrome browser available for debugging
|
||||
- Chrome DevTools MCP connected
|
||||
- `ss` (or `lsof`/`netstat`) available for port checks: `ss -tlnp`
|
||||
|
||||
## Port Selection
|
||||
|
||||
`8188` is only the *default candidate*. Verify it is actually free before every run:
|
||||
|
||||
```bash
|
||||
# Is anything listening on 8188?
|
||||
ss -tlnp | grep ':8188' || echo "8188 is free"
|
||||
```
|
||||
|
||||
- If a process holds `8188` (e.g. a live ComfyUI — pid 6575 on this machine), pick a different free port, e.g. `8199`:
|
||||
```bash
|
||||
ss -tlnp | grep ':8199' || echo "8199 is free"
|
||||
```
|
||||
- **Never** kill a process you did not start for this E2E. The live ComfyUI is off-limits. Pick a free port instead.
|
||||
- Use your chosen port for **all** subsequent commands (server, Chrome launch, browser URLs).
|
||||
|
||||
## Quick Start Workflow (sandboxed)
|
||||
|
||||
### 1. Prepare the sandbox
|
||||
|
||||
```bash
|
||||
cd <repo-root> # ALWAYS run from the repo/worktree root
|
||||
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
|
||||
# record real-data protection proof (see SANDBOX section)
|
||||
```
|
||||
|
||||
### 2. Check port availability
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd <repo-root>
|
||||
# 1. Sandbox
|
||||
mkdir -p <sandbox>/settings <sandbox>/models/{loras,checkpoints} <sandbox>/{recipes,example_images}
|
||||
# write <sandbox>/settings/settings.json per the SANDBOX example
|
||||
# 2. Port
|
||||
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
|
||||
```
|
||||
|
||||
If `{PORT}` is occupied by an unrelated process, pick a free one and use it everywhere below. When in doubt use `8199`.
|
||||
|
||||
### 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:
|
||||
|
||||
```bash
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --wait --timeout 30 --detach
|
||||
```
|
||||
|
||||
Or manually (equivalent detached form):
|
||||
|
||||
```bash
|
||||
setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 < /dev/null \
|
||||
>> /tmp/opencode/<plan>-e2e/server.log 2>&1 &
|
||||
echo "started" # record the printed/pidfile PID for cleanup
|
||||
```
|
||||
|
||||
Verify it is listening **before** proceeding (readiness poll is not a substitute for this):
|
||||
|
||||
```bash
|
||||
ss -tlnp | grep ':{PORT}'
|
||||
```
|
||||
|
||||
Record the server PID for cleanup: the helper script writes it to `/tmp/lora-manager-e2e-server-{PORT}.pid`; a manual `setsid` launch has no pidfile, so capture it explicitly (e.g. from `ss -tlnp`).
|
||||
|
||||
### 4. Open Chrome Debug Mode
|
||||
|
||||
```bash
|
||||
# Chrome with remote debugging on port 9222 (note the {PORT} URL)
|
||||
# 3. Server — MUST be fully detached (a plain background & dies with the shell);
|
||||
# the helper enforces this and manages its own pidfile
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
|
||||
--port {PORT} --settings-path <sandbox>/settings --wait --timeout 30 --detach
|
||||
ss -tlnp | grep ':{PORT}' # verify listening BEFORE proceeding
|
||||
# 4. Chrome with remote debugging, then connect Chrome DevTools MCP (verify via list_pages)
|
||||
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
|
||||
```
|
||||
|
||||
### 5. Connect Chrome DevTools MCP
|
||||
Then drive the UI with the MCP tools (`take_snapshot`, `click`, `fill`, `fill_form`,
|
||||
`evaluate_script`, `wait_for`, `list_network_requests`, `list_console_messages`) —
|
||||
see [references/mcp-cheatsheet.md](references/mcp-cheatsheet.md) for patterns.
|
||||
|
||||
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. Verify with `list_pages` — if it fails with "browser is already running", see [Chrome DevTools MCP Troubleshooting](#chrome-devtools-mcp-troubleshooting).
|
||||
|
||||
### 6. Navigate and Interact
|
||||
|
||||
Use Chrome DevTools MCP tools to:
|
||||
- Take snapshots: `take_snapshot`
|
||||
- Click elements: `click`
|
||||
- Fill forms: `fill` or `fill_form`
|
||||
- Evaluate scripts: `evaluate_script`
|
||||
- Wait for elements: `wait_for`
|
||||
|
||||
## Common E2E Test Patterns
|
||||
|
||||
### Pattern: Full Page Load Verification
|
||||
|
||||
```python
|
||||
# Navigate to LoRA list page
|
||||
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||
|
||||
# Wait for page to load
|
||||
wait_for(text="LoRAs", timeout=10000)
|
||||
|
||||
# Take snapshot to verify UI state
|
||||
snapshot = take_snapshot()
|
||||
```
|
||||
|
||||
### Pattern: Restart Server for Configuration Changes
|
||||
|
||||
```python
|
||||
# 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
|
||||
|
||||
# Wait and refresh browser
|
||||
navigate_page(type="reload", ignoreCache=True)
|
||||
wait_for(text="LoRAs", timeout=15000)
|
||||
```
|
||||
|
||||
### Pattern: Verify Backend API via Frontend
|
||||
|
||||
```python
|
||||
# Execute script in browser to call backend API
|
||||
result = evaluate_script(function="""
|
||||
async () => {
|
||||
const response = await fetch('/loras/api/list');
|
||||
const data = await response.json();
|
||||
return { count: data.length, firstItem: data[0]?.name };
|
||||
}
|
||||
""")
|
||||
```
|
||||
|
||||
### Pattern: Form Submission Flow
|
||||
|
||||
```python
|
||||
# Fill a form (e.g., search or filter)
|
||||
fill_form(elements=[
|
||||
{"uid": "search-input", "value": "character"},
|
||||
])
|
||||
|
||||
# Click submit button
|
||||
click(uid="search-button")
|
||||
|
||||
# Wait for results
|
||||
wait_for(text="Results", timeout=5000)
|
||||
|
||||
# Verify results via snapshot
|
||||
snapshot = take_snapshot()
|
||||
```
|
||||
|
||||
### Pattern: Modal Dialog Interaction
|
||||
|
||||
```python
|
||||
# Open modal (e.g., add LoRA)
|
||||
click(uid="add-lora-button")
|
||||
|
||||
# Wait for modal to appear
|
||||
wait_for(text="Add LoRA", timeout=3000)
|
||||
|
||||
# Fill modal form
|
||||
fill_form(elements=[
|
||||
{"uid": "lora-name", "value": "Test LoRA"},
|
||||
{"uid": "lora-path", "value": "/path/to/lora.safetensors"},
|
||||
])
|
||||
|
||||
# Submit
|
||||
click(uid="modal-submit-button")
|
||||
|
||||
# Wait for success message or close
|
||||
wait_for(text="Success", timeout=5000)
|
||||
```
|
||||
|
||||
## Fixture + Fresh-State Guidance
|
||||
|
||||
For rematch/repair E2E runs, seed the **sandboxed** `recipes_path` with hand-written fixture recipes. Rules (validated by the task-8 E2E):
|
||||
|
||||
1. **Filename constraint**: each file MUST be named `f"{id}.recipe.json"` **and** the in-JSON `id` field MUST equal the filename. Discovery accepts any `*.recipe.json`, but persistence resolves the path via `get_recipe_json_path` and `_save_recipe_persistently` returns `False` on a mismatch → the fixture would be counted as an error.
|
||||
- `recipe-a.recipe.json` → in-JSON `"id": "recipe-a"`
|
||||
2. **File format**: mirror an existing recipe JSON — top-level `id`, `file_path`, `title`, `loras`, `fingerprint`, `gen_params`; lora entries per the persistence conventions (`hash`, `file_name`, `modelVersionId`, `isDeleted`, ...).
|
||||
3. **Companion image**: each recipe needs an image (e.g. a `.webp` generated with PIL) referenced by `file_path`, used for EXIF verification (`ExifUtils.append_recipe_metadata` writes a `"Recipe metadata: ..."` marker; a freshly generated `.webp` with no marker is the clean "untouched" control).
|
||||
4. **autov3 three-state contract**: for L3 (autov3-only, renamed-file) fixtures the local model's `.metadata.json` sidecar MUST have the `autov3` key **ABSENT** (the "unchecked" state), NOT `""` — `""` is the TERMINAL "checked but unavailable" state that L3 deliberately skips. The scanner computes + persists `autov3` from the file header during the normal library scan (`model_scanner.py` `_process_model_file`), so the live L3 match resolves through the local autov3/hash cache; the computed-autov3 branch for unchecked items is covered by the unit suite.
|
||||
5. **Fixture design for a rematch run** (mirrors the task-8 E2E):
|
||||
- `recipe-a`: lora entry `isDeleted=True`, `hash` = 12-char autov3 computed from the local model (`calculate_autov3`, `py/utils/file_utils.py`), whose local model file was RENAMED after the recipe was written so `file_name` differs (proves L3 match without filename).
|
||||
- `recipe-b`: parser-convention checkpoint entry (uses `id`, no `modelVersionId`) matching a local checkpoint via L2 — the local checkpoint's `.metadata.json` MUST carry civitai version data with that `id` so `version_index` contains it (L2 cannot match otherwise).
|
||||
- `recipe-c`: healthy recipe (no deleted entries) → must remain untouched.
|
||||
|
||||
### Fresh state between entry-point runs
|
||||
|
||||
Each entry point (global / per-recipe / selection-bulk) must start from the same deleted state. Between runs:
|
||||
Server restart after config/fixture changes:
|
||||
|
||||
```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/*
|
||||
# 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
|
||||
# 4. Re-verify server listening + reload the browser page
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
|
||||
--port {PORT} --settings-path <sandbox>/settings --restart --wait --detach
|
||||
# then reload the browser page (ignoreCache=True)
|
||||
```
|
||||
|
||||
## Server Lifecycle
|
||||
`--restart` only kills the E2E server the script itself started (via its pidfile) and
|
||||
aborts instead of killing unrelated processes on the port.
|
||||
|
||||
- **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 &`.
|
||||
- **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).
|
||||
## Abort Rule
|
||||
|
||||
## Chrome DevTools MCP Troubleshooting
|
||||
A sandboxed E2E should finish in well under 30 minutes. If any phase exceeds ~2x its
|
||||
expected duration (server readiness > 60 s, MCP connect > 2 min, a single scenario >
|
||||
10 min), or any single tool call fails 3+ times in a row, **STOP** — do not retry
|
||||
blindly. Report `BLOCKED` with the phase, last observed state (server PID,
|
||||
`ss -tlnp` output, page snapshot, last API response) and suspected cause. A clean
|
||||
BLOCKED report beats an hour of retries.
|
||||
|
||||
### Stale profile lock ("browser is already running" / `list_pages` fails)
|
||||
## Troubleshooting
|
||||
|
||||
A Chrome profile can be held by a stale Chrome from a prior MCP session, which makes `list_pages` fail with "browser is already running":
|
||||
|
||||
1. Identify the stale Chrome — it owns the profile dir in `--user-data-dir` (e.g. `~/.config/chrome-dev-profile`). Find its process:
|
||||
```bash
|
||||
ps -ef | grep -i '[c]hrome.*user-data-dir'
|
||||
```
|
||||
2. Confirm it is a QA Chrome from a completed task (its parent is an old MCP/browser process, it is NOT the live ComfyUI server, and it is NOT your current MCP instance).
|
||||
3. Kill ONLY that stale Chrome:
|
||||
```bash
|
||||
kill <stale-chrome-pid>
|
||||
```
|
||||
Never kill the live server or unrelated processes.
|
||||
4. Retry `list_pages`. The current MCP will spawn a fresh browser.
|
||||
|
||||
### Screenshot-write restrictions
|
||||
|
||||
The chrome-devtools MCP may refuse to write into paths outside its configured workspace roots (e.g. the worktree `.omo/evidence/...` canonicalizing to an unmapped path). Workaround:
|
||||
|
||||
```bash
|
||||
# 1. Save the screenshot to /tmp via the MCP
|
||||
# take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
|
||||
# 2. Copy it into the evidence dir from the shell
|
||||
mkdir -p <repo-root>/.omo/evidence/screenshots
|
||||
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
|
||||
```
|
||||
|
||||
## Cancellation Testing (KNOWN GAP)
|
||||
|
||||
Testing the rematch-cancel path E2E requires a run long enough to cancel mid-flight. A tiny 3-recipe fixture set completes in **seconds** — too fast to reliably cancel. The cancel path is currently **unit-covered only** (`rematch_all_recipes` cancellation tests); do not block an E2E run on cancel-path verification. If you must attempt it, you would need an artificially large/deferred fixture set to create a cancellable window — treat this as a research task, not part of the standard E2E.
|
||||
|
||||
## Available Scripts
|
||||
|
||||
### scripts/start_server.py
|
||||
|
||||
Starts or restarts the LoRa Manager standalone server for E2E testing.
|
||||
|
||||
```bash
|
||||
python scripts/start_server.py [--port PORT] [--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.
|
||||
- `--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).
|
||||
- `--detach`: Launch the server fully detached (`setsid`-style, survives shell death — REQUIRED for E2E). Default off: a normal background process that dies with the shell.
|
||||
|
||||
### scripts/wait_for_server.py
|
||||
|
||||
Polls the server until ready or timeout.
|
||||
|
||||
```bash
|
||||
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
|
||||
```
|
||||
|
||||
## Test Scenarios Reference
|
||||
|
||||
See [references/test-scenarios.md](references/test-scenarios.md) for detailed test scenarios including:
|
||||
- LoRA list display and filtering
|
||||
- Model metadata editing
|
||||
- Recipe creation and management
|
||||
- Settings configuration
|
||||
- Import/export functionality
|
||||
|
||||
## Network Request Verification
|
||||
|
||||
Use `list_network_requests` and `get_network_request` to verify API calls:
|
||||
|
||||
```python
|
||||
# List recent XHR/fetch requests
|
||||
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
||||
|
||||
# Get details of specific request
|
||||
details = get_network_request(reqid=123)
|
||||
```
|
||||
|
||||
## Console Message Monitoring
|
||||
|
||||
```python
|
||||
# Check for errors or warnings
|
||||
messages = list_console_messages(types=["error", "warn"])
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
```python
|
||||
# Start performance trace
|
||||
performance_start_trace(reload=True, autoStop=False)
|
||||
|
||||
# Perform actions...
|
||||
|
||||
# Stop and analyze
|
||||
results = performance_stop_trace()
|
||||
```
|
||||
- **"browser is already running" / `list_pages` fails**: a stale Chrome holds the
|
||||
profile dir. Find it (`ps -ef | grep -i '[c]hrome.*user-data-dir'`), confirm it is a
|
||||
leftover QA Chrome (not the live ComfyUI, not your current MCP browser), kill only
|
||||
that PID, then retry `list_pages`.
|
||||
- **MCP refuses to write screenshots into the worktree**: save to `/tmp` via
|
||||
`take_screenshot(filePath="/tmp/...")` and copy into the evidence dir from the shell.
|
||||
|
||||
## Cleanup
|
||||
|
||||
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.
|
||||
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).
|
||||
4. Re-run the real-data protection check from the SANDBOX section and record the result in your evidence.
|
||||
3. `rm -rf <sandbox>`; verify `<repo-root>` gained no `settings.json` or `cache/`.
|
||||
4. Re-run the real-data protection check from the SANDBOX section and record the result.
|
||||
|
||||
## References & Scripts
|
||||
|
||||
- [references/mcp-cheatsheet.md](references/mcp-cheatsheet.md) — Chrome DevTools MCP
|
||||
command patterns (navigation, waiting, snapshots, forms, network, console, performance).
|
||||
- [references/test-scenarios.md](references/test-scenarios.md) — detailed test scenarios
|
||||
(list display, metadata editing, recipes, settings, import/export).
|
||||
- [references/recipe-rematch-fixtures.md](references/recipe-rematch-fixtures.md) —
|
||||
fixture format, fresh-state reset and known gaps for recipe rematch/repair E2E runs.
|
||||
- `scripts/start_server.py` — start/restart the standalone server
|
||||
(`--port --settings-path --restart --wait --timeout --detach`); refuses to touch
|
||||
unrelated processes on the port.
|
||||
- `scripts/wait_for_server.py` — poll readiness (`--port --timeout`).
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# Recipe Rematch/Repair E2E — Fixtures, Fresh State, Known Gaps
|
||||
|
||||
Specialized guidance for recipe rematch/repair E2E runs, extracted from the SKILL.md
|
||||
main flow. Read the SKILL.md SANDBOX section first — everything here assumes a
|
||||
sandboxed run.
|
||||
|
||||
## Fixture Rules (validated by the task-8 E2E)
|
||||
|
||||
Seed the **sandboxed** `recipes_path` with hand-written fixture recipes:
|
||||
|
||||
1. **Filename constraint**: each file MUST be named `f"{id}.recipe.json"` **and** the
|
||||
in-JSON `id` field MUST equal the filename. Discovery accepts any `*.recipe.json`,
|
||||
but persistence resolves the path via `get_recipe_json_path` and
|
||||
`_save_recipe_persistently` returns `False` on a mismatch → the fixture would be
|
||||
counted as an error.
|
||||
- `recipe-a.recipe.json` → in-JSON `"id": "recipe-a"`
|
||||
2. **File format**: mirror an existing recipe JSON — top-level `id`, `file_path`,
|
||||
`title`, `loras`, `fingerprint`, `gen_params`; lora entries per the persistence
|
||||
conventions (`hash`, `file_name`, `modelVersionId`, `isDeleted`, ...).
|
||||
3. **Companion image**: each recipe needs an image (e.g. a `.webp` generated with PIL)
|
||||
referenced by `file_path`, used for EXIF verification
|
||||
(`ExifUtils.append_recipe_metadata` writes a `"Recipe metadata: ..."` marker; a
|
||||
freshly generated `.webp` with no marker is the clean "untouched" control).
|
||||
4. **autov3 three-state contract**: for L3 (autov3-only, renamed-file) fixtures the
|
||||
local model's `.metadata.json` sidecar MUST have the `autov3` key **ABSENT** (the
|
||||
"unchecked" state), NOT `""` — `""` is the TERMINAL "checked but unavailable" state
|
||||
that L3 deliberately skips. The scanner computes + persists `autov3` from the file
|
||||
header during the normal library scan (`model_scanner.py` `_process_model_file`), so
|
||||
the live L3 match resolves through the local autov3/hash cache; the
|
||||
computed-autov3 branch for unchecked items is covered by the unit suite.
|
||||
5. **Fixture design for a rematch run** (mirrors the task-8 E2E):
|
||||
- `recipe-a`: lora entry `isDeleted=True`, `hash` = 12-char autov3 computed from the
|
||||
local model (`calculate_autov3`, `py/utils/file_utils.py`), whose local model file
|
||||
was RENAMED after the recipe was written so `file_name` differs (proves L3 match
|
||||
without filename).
|
||||
- `recipe-b`: parser-convention checkpoint entry (uses `id`, no `modelVersionId`)
|
||||
matching a local checkpoint via L2 — the local checkpoint's `.metadata.json` MUST
|
||||
carry civitai version data with that `id` so `version_index` contains it (L2
|
||||
cannot match otherwise).
|
||||
- `recipe-c`: healthy recipe (no deleted entries) → must remain untouched.
|
||||
|
||||
The scanner computes and persists model hashes during the library scan, so the sandbox
|
||||
model dirs just need the model files + `.metadata.json` sidecars. With
|
||||
`--settings-path`, all derived data lands under the sandbox settings dir (`cache/`,
|
||||
`backups/`, `logs/`, `stats/`, `wildcards/`), and NO `cache/` appears in the repo root.
|
||||
|
||||
## Fresh State Between Entry-Point Runs
|
||||
|
||||
Each entry point (global / per-recipe / selection-bulk) must start from the same
|
||||
deleted state. Between runs (keep a pristine copy in `<sandbox>/recipes-before/`):
|
||||
|
||||
```bash
|
||||
# 1. Reset fixtures to the before-state snapshot
|
||||
cp <sandbox>/recipes-before/*.recipe.json <sandbox>/recipes/
|
||||
# 2. Clear the recipe/FTS caches (with --settings-path these live under the sandbox
|
||||
# settings dir, NOT <repo-root>/cache)
|
||||
rm -f <sandbox>/settings/cache/recipe/*.sqlite
|
||||
rm -rf <sandbox>/settings/cache/fts/*
|
||||
# 3. Restart the server (fresh process, fresh scan)
|
||||
python .agents/skills/lora-manager-e2e/scripts/start_server.py \
|
||||
--port {PORT} --settings-path <sandbox>/settings --restart --wait --timeout 30 --detach
|
||||
# 4. Re-verify the server is listening + reload the browser page
|
||||
```
|
||||
|
||||
## Cancellation Testing (KNOWN GAP)
|
||||
|
||||
Testing the rematch-cancel path E2E requires a run long enough to cancel mid-flight. A
|
||||
tiny 3-recipe fixture set completes in **seconds** — too fast to reliably cancel. The
|
||||
cancel path is currently **unit-covered only** (`rematch_all_recipes` cancellation
|
||||
tests); do not block an E2E run on cancel-path verification. If you must attempt it,
|
||||
you would need an artificially large/deferred fixture set to create a cancellable
|
||||
window — treat this as a research task, not part of the standard E2E.
|
||||
@@ -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():
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
This file provides guidance for agentic coding assistants working in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend Development
|
||||
@@ -28,16 +32,21 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py --cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=xml:coverage/backend/coverage.xml
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
```
|
||||
|
||||
### Frontend Development (LoRA Manager Web UI)
|
||||
|
||||
```bash
|
||||
# Install dependencies (root and Vue widgets)
|
||||
npm install
|
||||
cd vue-widgets && npm install && cd ..
|
||||
|
||||
npm test # Run all tests (JS + Vue)
|
||||
npm run test:js # Run JS tests only
|
||||
npm run test:watch # Watch mode
|
||||
npm run test:vue # Run Vue widget tests only
|
||||
npm run test:watch # Watch mode (JS tests only)
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
@@ -54,88 +63,169 @@ npm run test:watch # Watch mode
|
||||
npm run test:coverage # Generate coverage report
|
||||
```
|
||||
|
||||
## Python Code Style
|
||||
### Localization
|
||||
|
||||
### Imports & Formatting
|
||||
```bash
|
||||
# Sync translation keys after UI string updates
|
||||
python scripts/sync_translation_keys.py
|
||||
```
|
||||
|
||||
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
||||
|
||||
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
|
||||
the other locales are the expected end state during feature development. Do NOT translate
|
||||
proactively — translate only when the feature owner explicitly asks (see
|
||||
`docs/i18n-translation-guidelines.md` §7).
|
||||
|
||||
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
|
||||
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
|
||||
brand names are never translated), per-locale preferred renderings, placeholder rules, and
|
||||
the known confusion hot-spots.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python
|
||||
|
||||
#### Imports & Formatting
|
||||
|
||||
- Use `from __future__ import annotations` for forward references
|
||||
- Group imports: standard library, third-party, local (blank line separated)
|
||||
- Use `TYPE_CHECKING` guard for type-checking-only imports
|
||||
- Absolute imports within `py/`: `from ..services import X`
|
||||
- PEP 8 with 4-space indentation, type hints required
|
||||
|
||||
### Naming Conventions
|
||||
#### Naming Conventions
|
||||
|
||||
- Files: `snake_case.py`, Classes: `PascalCase`, Functions/vars: `snake_case`
|
||||
- Constants: `UPPER_SNAKE_CASE`, Private: `_protected`, `__mangled`
|
||||
|
||||
### Error Handling & Async
|
||||
#### Error Handling & Async
|
||||
|
||||
- Use `logging.getLogger(__name__)`, define custom exceptions in `py/services/errors.py`
|
||||
- `async def` for I/O, `@pytest.mark.asyncio` for async tests
|
||||
- Singleton with `asyncio.Lock`: see `ModelScanner.get_instance()`
|
||||
- Return `aiohttp.web.json_response` or `web.Response`
|
||||
|
||||
### Testing
|
||||
### JavaScript/TypeScript
|
||||
|
||||
- `pytest` with `--import-mode=importlib`
|
||||
- Fixtures in `tests/conftest.py`, use `tmp_path_factory` for isolation
|
||||
- Mark tests needing real paths: `@pytest.mark.no_settings_dir_isolation`
|
||||
- Mock ComfyUI dependencies via conftest patterns
|
||||
|
||||
## JavaScript/TypeScript Code Style
|
||||
|
||||
### Imports & Modules
|
||||
#### Imports & Modules
|
||||
|
||||
- ES modules: `import { app } from "../../scripts/app.js"` for ComfyUI
|
||||
- Vue: `import { ref, computed } from 'vue'`, type imports: `import type { Foo }`
|
||||
- Export named functions: `export function foo() {}`
|
||||
|
||||
### Naming & Formatting
|
||||
#### Naming & Formatting
|
||||
|
||||
- camelCase for functions/vars/props, PascalCase for classes
|
||||
- Constants: `UPPER_SNAKE_CASE`, Files: `snake_case.js` or `kebab-case.js`
|
||||
- 2-space indentation preferred (follow existing file conventions)
|
||||
- Vue Single File Components: `<script setup lang="ts">` preferred
|
||||
|
||||
### Widget Development
|
||||
#### Widget Development
|
||||
|
||||
- Prefer vanilla JS for `web/comfyui/` widgets; avoid framework dependencies (except the Vue widgets in `vue-widgets/`)
|
||||
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
|
||||
- Event handlers via `addEventListener` or widget callbacks
|
||||
- Shared utilities: `web/comfyui/utils.js`
|
||||
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
|
||||
|
||||
### Vue Composables Pattern
|
||||
#### Vue Composables Pattern
|
||||
|
||||
- Use composition API: `useXxxState(widget)`, return reactive refs and methods
|
||||
- Guard restoration loops with flag: `let isRestoring = false`
|
||||
- Build config from state: `const buildConfig = (): Config => { ... }`
|
||||
|
||||
## Architecture Patterns
|
||||
## Architecture
|
||||
|
||||
### Dual Mode Operation
|
||||
|
||||
The system runs in two modes:
|
||||
- **ComfyUI plugin mode**: Integrates with ComfyUI's PromptServer, uses `folder_paths` for model discovery
|
||||
- **Standalone mode**: `standalone.py` mocks ComfyUI dependencies, reads paths from `settings.json`
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
|
||||
### Backend Entry Points
|
||||
|
||||
- `__init__.py` — ComfyUI plugin entry: registers nodes via `NODE_CLASS_MAPPINGS`, sets `WEB_DIRECTORY`, calls `LoraManager.add_routes()`
|
||||
- `standalone.py` — Standalone server: mocks `folder_paths` and node modules, starts aiohttp server
|
||||
- `py/lora_manager.py` — Main `LoraManager` class that registers all HTTP routes
|
||||
|
||||
### Service Layer
|
||||
|
||||
- `ServiceRegistry` singleton for DI, services use `get_instance()` classmethod
|
||||
- `BaseModelService` abstract base → `LoraService`, `CheckpointService`, `EmbeddingService`
|
||||
- `ModelScanner` base → `LoraScanner`, `CheckpointScanner`, `EmbeddingScanner` for file discovery with hash-based deduplication
|
||||
- `PersistentModelCache` (SQLite) for metadata persistence
|
||||
- `MetadataSyncService` — background sync from CivitAI/CivArchive APIs
|
||||
- `SettingsManager` — settings with schema migration support
|
||||
- `WebSocketManager` — real-time progress broadcasting
|
||||
- `ModelServiceFactory` — creates the right service for each model type
|
||||
- Use cases in `py/services/use_cases/` orchestrate complex business logic (auto-organize, bulk refresh, downloads)
|
||||
- Separate scanners (discovery) from services (business logic)
|
||||
- Handlers in `py/routes/handlers/` are pure functions with deps as params
|
||||
|
||||
### Model Types & Routes
|
||||
|
||||
- `BaseModelService` base for LoRA, Checkpoint, Embedding
|
||||
- `ModelScanner` for file discovery, hash deduplication
|
||||
- `PersistentModelCache` (SQLite) for persistence
|
||||
- Route registrars: `ModelRouteRegistrar`, endpoints: `/loras/*`, `/checkpoints/*`, `/embeddings/*`
|
||||
- WebSocket via `WebSocketManager` for real-time updates
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||
- Request handlers in `py/routes/handlers/` implement route logic
|
||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||
|
||||
### Recipe System
|
||||
|
||||
- Base: `py/recipes/base.py`, Enrichment: `RecipeEnrichmentService`
|
||||
- Parsers: `py/recipes/parsers/`
|
||||
- Base: `py/recipes/base.py`, Enrichment: `RecipeEnrichmentService` in `py/recipes/enrichment.py`
|
||||
- Parsers: `py/recipes/parsers/` for PNG metadata, JSON, and workflow formats
|
||||
|
||||
### Custom Nodes
|
||||
|
||||
- Location: `py/nodes/`, all nodes registered in `__init__.py`
|
||||
- Each node class has a `NAME` class attribute used as key in `NODE_CLASS_MAPPINGS`
|
||||
- Standard ComfyUI node pattern: `INPUT_TYPES()` classmethod, `RETURN_TYPES`, `FUNCTION`
|
||||
|
||||
### Configuration
|
||||
|
||||
- `py/config.py` manages folder paths for models and handles symlink mappings
|
||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
||||
|
||||
### Frontend UI Architecture
|
||||
|
||||
#### 1. LoRA Manager Web UI
|
||||
- Location: `./static/` (JS/CSS) and `./templates/` (HTML)
|
||||
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
|
||||
- Tests: `tests/frontend/**/*.test.js` (vitest + jsdom)
|
||||
|
||||
#### 2. ComfyUI Custom Node Widgets
|
||||
- Location: `./web/comfyui/` (Vanilla JS) + `./vue-widgets/` (Vue)
|
||||
- Primary styles: `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
|
||||
- Vue widgets: Vue 3 + TypeScript + PrimeVue + vue-i18n, e.g. `LoraPoolWidget`, `LoraRandomizerWidget`, `LoraCyclerWidget`, `AutocompleteTextWidget`
|
||||
- Vue builds to `./web/comfyui/vue-widgets/`; auto-built on ComfyUI startup via `py/vue_widget_builder.py`, typecheck via `vue-tsc`
|
||||
- Widget registration: `app.registerExtension()` and `getCustomWidgets` hooks; `node.addDOMWidget(...)` embeds HTML in LiteGraph nodes
|
||||
- See `docs/dom_widget_dev_guide.md` for the DOMWidget development guide
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend (pytest)
|
||||
|
||||
- Config in `pytest.ini`: `--import-mode=importlib`, testpaths=`tests`
|
||||
- Fixtures in `tests/conftest.py` mock ComfyUI dependencies; use `tmp_path_factory` for isolation
|
||||
- Markers: `@pytest.mark.asyncio`, `@pytest.mark.no_settings_dir_isolation` (tests needing real settings paths)
|
||||
|
||||
### Frontend (vitest)
|
||||
|
||||
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom; setup in `tests/frontend/setup.js`
|
||||
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + `@vue/test-utils`
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
|
||||
- **CivitAI/CivArchive:** API clients for metadata sync and model downloads; CivitAI API key stored in settings
|
||||
- **Symlinks:** Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans
|
||||
- **WebSocket:** Broadcasts real-time progress for downloads, scans, and metadata sync
|
||||
- **Model scanning flow:** Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
|
||||
|
||||
## Important Notes
|
||||
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||
- Symlinks require normalized paths.
|
||||
**Business paths vs real paths**: All stored paths and operation routing use the
|
||||
@@ -143,23 +233,4 @@ npm run test:coverage # Generate coverage report
|
||||
resolved. `os.path.realpath` is only for scanner dedup and the symlink cache.
|
||||
Any path passed to `os.remove`/`os.rename`/`shutil.move` or validated by a
|
||||
containment check MUST use the business path (i.e. `os.path.abspath`, not
|
||||
`realpath`).
|
||||
|
||||
## Git / Commit Messages
|
||||
|
||||
- Follow the style of recent repository commits when writing commit messages
|
||||
- Prefer the repo's existing `feat(...)`, `fix(...)`, `chore:` style where applicable
|
||||
- If the user has provided a GitHub issue link or issue ID for the task, mention that issue in the commit message, for example `(#871)`
|
||||
- When unrelated local changes exist, stage and commit only the files relevant to the requested task
|
||||
|
||||
## Frontend UI Architecture
|
||||
|
||||
### 1. LoRA Manager Web UI
|
||||
- Location: `./static/` and `./templates/`
|
||||
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
|
||||
- Tests via npm in root directory
|
||||
|
||||
### 2. ComfyUI Custom Node Widgets
|
||||
- Location: `./web/comfyui/` (Vanilla JS) + `./vue-widgets/` (Vue)
|
||||
- Primary styles: `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
|
||||
- Vue builds to `./web/comfyui/vue-widgets/`, typecheck via `vue-tsc`
|
||||
`realpath`).
|
||||
@@ -1,189 +0,0 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Overview
|
||||
|
||||
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
# Run standalone server (port 8188 by default)
|
||||
python standalone.py --port 8188
|
||||
|
||||
# Run all backend tests
|
||||
pytest
|
||||
|
||||
# Run specific test file or function
|
||||
pytest tests/test_recipes.py
|
||||
pytest tests/test_recipes.py::test_function_name
|
||||
|
||||
# Run backend tests with coverage
|
||||
COVERAGE_FILE=coverage/backend/.coverage pytest \
|
||||
--cov=py \
|
||||
--cov=standalone \
|
||||
--cov-report=term-missing \
|
||||
--cov-report=html:coverage/backend/html \
|
||||
--cov-report=xml:coverage/backend/coverage.xml \
|
||||
--cov-report=json:coverage/backend/coverage.json
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
There are three test suites run by `npm test`: vanilla JS tests (vitest at root) and Vue widget tests (`vue-widgets/` vitest).
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cd vue-widgets && npm install && cd ..
|
||||
|
||||
# Run all frontend tests (JS + Vue)
|
||||
npm test
|
||||
|
||||
# Run only vanilla JS tests
|
||||
npm run test:js
|
||||
|
||||
# Run only Vue widget tests
|
||||
npm run test:vue
|
||||
|
||||
# Watch mode (JS tests only)
|
||||
npm run test:watch
|
||||
|
||||
# Frontend coverage
|
||||
npm run test:coverage
|
||||
|
||||
# Build Vue widgets (output to web/comfyui/vue-widgets/)
|
||||
cd vue-widgets && npm run build
|
||||
|
||||
# Vue widget dev mode (watch + rebuild)
|
||||
cd vue-widgets && npm run dev
|
||||
|
||||
# Typecheck Vue widgets
|
||||
cd vue-widgets && npm run typecheck
|
||||
```
|
||||
|
||||
### Localization
|
||||
|
||||
```bash
|
||||
# Sync translation keys after UI string updates
|
||||
python scripts/sync_translation_keys.py
|
||||
```
|
||||
|
||||
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Dual Mode Operation
|
||||
|
||||
The system runs in two modes:
|
||||
- **ComfyUI plugin mode**: Integrates with ComfyUI's PromptServer, uses `folder_paths` for model discovery
|
||||
- **Standalone mode**: `standalone.py` mocks ComfyUI dependencies, reads paths from `settings.json`
|
||||
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
**Entry points:**
|
||||
- `__init__.py` — ComfyUI plugin entry: registers nodes via `NODE_CLASS_MAPPINGS`, sets `WEB_DIRECTORY`, calls `LoraManager.add_routes()`
|
||||
- `standalone.py` — Standalone server: mocks `folder_paths` and node modules, starts aiohttp server
|
||||
- `py/lora_manager.py` — Main `LoraManager` class that registers all HTTP routes
|
||||
|
||||
**Service layer** (`py/services/`):
|
||||
- `ServiceRegistry` singleton for dependency injection; services follow `get_instance()` singleton pattern
|
||||
- `BaseModelService` abstract base → `LoraService`, `CheckpointService`, `EmbeddingService`
|
||||
- `ModelScanner` base → `LoraScanner`, `CheckpointScanner`, `EmbeddingScanner` for file discovery with hash-based deduplication
|
||||
- `PersistentModelCache` — SQLite-based metadata cache
|
||||
- `MetadataSyncService` — Background sync from CivitAI/CivArchive APIs
|
||||
- `SettingsManager` — Settings with schema migration support
|
||||
- `WebSocketManager` — Real-time progress broadcasting
|
||||
- `ModelServiceFactory` — Creates the right service for each model type
|
||||
- Use cases in `py/services/use_cases/` orchestrate complex business logic (auto-organize, bulk refresh, downloads)
|
||||
|
||||
**Routes** (`py/routes/`):
|
||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||
- Request handlers in `py/routes/handlers/` implement route logic
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||
|
||||
**Recipe system** (`py/recipes/`):
|
||||
- `base.py` — Recipe metadata structure
|
||||
- `enrichment.py` — Enriches recipes with model metadata
|
||||
- `parsers/` — Parsers for PNG metadata, JSON, and workflow formats
|
||||
|
||||
**Custom nodes** (`py/nodes/`):
|
||||
- Each node class has a `NAME` class attribute used as key in `NODE_CLASS_MAPPINGS`
|
||||
- Standard ComfyUI node pattern: `INPUT_TYPES()` classmethod, `RETURN_TYPES`, `FUNCTION`
|
||||
- All nodes registered in `__init__.py`
|
||||
|
||||
**Configuration** (`py/config.py`):
|
||||
- Manages folder paths for models, handles symlink mappings
|
||||
- Auto-saves paths to settings.json in ComfyUI mode
|
||||
|
||||
### Frontend — Two Distinct UI Systems
|
||||
|
||||
#### 1. Standalone Manager Web UI
|
||||
- **Location:** `static/` (JS/CSS) and `templates/` (HTML)
|
||||
- **Tech:** Vanilla JS + CSS, served by standalone server
|
||||
- **Structure:** `static/js/core.js` (shared), `loras.js`, `checkpoints.js`, `embeddings.js`, `recipes.js`, `statistics.js`
|
||||
- **Tests:** `tests/frontend/**/*.test.js` (vitest + jsdom)
|
||||
|
||||
#### 2. ComfyUI Custom Node Widgets
|
||||
- **Vanilla JS widgets:** `web/comfyui/*.js` — ES modules extending ComfyUI's LiteGraph UI
|
||||
- `loras_widget.js` / `loras_widget_events.js` — Main LoRA selection widget
|
||||
- `autocomplete.js` — Trigger word and embedding autocomplete
|
||||
- `preview_tooltip.js` — Model card preview tooltips
|
||||
- `top_menu_extension.js` — "Launch LoRA Manager" menu item
|
||||
- `utils.js` — Shared utilities and API helpers
|
||||
- Widget styling in `web/comfyui/lm_styles.css` (NOT `static/css/`)
|
||||
- **Vue widgets:** `vue-widgets/src/` → built to `web/comfyui/vue-widgets/`
|
||||
- Vue 3 + TypeScript + PrimeVue + vue-i18n
|
||||
- Vite build with CSS-injected-by-JS plugin
|
||||
- Components: `LoraPoolWidget`, `LoraRandomizerWidget`, `LoraCyclerWidget`, `AutocompleteTextWidget`
|
||||
- Auto-built on ComfyUI startup via `py/vue_widget_builder.py`
|
||||
- Tests: `vue-widgets/tests/**/*.test.ts` (vitest)
|
||||
|
||||
**Widget registration pattern:**
|
||||
- Widgets use `app.registerExtension()` and `getCustomWidgets` hooks
|
||||
- `node.addDOMWidget(name, type, element, options)` embeds HTML in LiteGraph nodes
|
||||
- See `docs/dom_widget_dev_guide.md` for DOMWidget development guide
|
||||
|
||||
## Code Style
|
||||
|
||||
**Python:**
|
||||
- PEP 8, 4-space indentation, English comments only
|
||||
- Use `from __future__ import annotations` for forward references
|
||||
- Use `TYPE_CHECKING` guard for type-checking-only imports
|
||||
- Loggers via `logging.getLogger(__name__)`
|
||||
- Custom exceptions in `py/services/errors.py`
|
||||
- Async patterns: `async def` for I/O, `@pytest.mark.asyncio` for async tests
|
||||
- Singleton pattern with class-level `asyncio.Lock` (see `ModelScanner.get_instance()`)
|
||||
|
||||
**JavaScript:**
|
||||
- ES modules, camelCase functions/variables, PascalCase classes
|
||||
- Widget files use `*_widget.js` suffix
|
||||
- Prefer vanilla JS for `web/comfyui/` widgets, avoid framework dependencies (except Vue widgets)
|
||||
|
||||
## Testing
|
||||
|
||||
**Backend (pytest):**
|
||||
- Config in `pytest.ini`: `--import-mode=importlib`, testpaths=`tests`
|
||||
- Fixtures in `tests/conftest.py` handle ComfyUI dependency mocking
|
||||
- Markers: `@pytest.mark.asyncio`, `@pytest.mark.no_settings_dir_isolation`
|
||||
- Uses `tmp_path_factory` for directory isolation
|
||||
|
||||
**Frontend (vitest):**
|
||||
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom
|
||||
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + @vue/test-utils
|
||||
- Setup in `tests/frontend/setup.js`
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
- **Settings:** Stored in user directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
|
||||
- **CivitAI/CivArchive:** API clients for metadata sync and model downloads; CivitAI API key in settings
|
||||
- **Symlink handling:** Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans
|
||||
- **WebSocket:** Broadcasts real-time progress for downloads, scans, and metadata sync
|
||||
- **Model scanning flow:** Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
|
||||
-10
@@ -3,8 +3,6 @@ try: # pragma: no cover - import fallback for pytest collection
|
||||
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
|
||||
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
|
||||
from .py.nodes.unet_loader import UNETLoaderLM
|
||||
from .py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
|
||||
from .py.nodes.random_unet_loader import RandomUNETLoaderLM
|
||||
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
|
||||
from .py.nodes.prompt import PromptLM
|
||||
from .py.nodes.text import TextLM
|
||||
@@ -42,12 +40,6 @@ except (
|
||||
"py.nodes.checkpoint_loader"
|
||||
).CheckpointLoaderLM
|
||||
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
|
||||
RandomCheckpointLoaderLM = importlib.import_module(
|
||||
"py.nodes.random_checkpoint_loader"
|
||||
).RandomCheckpointLoaderLM
|
||||
RandomUNETLoaderLM = importlib.import_module(
|
||||
"py.nodes.random_unet_loader"
|
||||
).RandomUNETLoaderLM
|
||||
TriggerWordToggleLM = importlib.import_module(
|
||||
"py.nodes.trigger_word_toggle"
|
||||
).TriggerWordToggleLM
|
||||
@@ -87,8 +79,6 @@ NODE_CLASS_MAPPINGS = {
|
||||
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
||||
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
|
||||
UNETLoaderLM.NAME: UNETLoaderLM,
|
||||
RandomCheckpointLoaderLM.NAME: RandomCheckpointLoaderLM,
|
||||
RandomUNETLoaderLM.NAME: RandomUNETLoaderLM,
|
||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||
LoraStackerLM.NAME: LoraStackerLM,
|
||||
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
|
||||
|
||||
@@ -54,7 +54,7 @@ The dedicated services encapsulate long-running work so handlers stay thin.
|
||||
| Use case | Entry point | Dependencies | Guarantees |
|
||||
| --- | --- | --- | --- |
|
||||
| `RecipeAnalysisService` | `analyze_uploaded_image`, `analyze_remote_image`, `analyze_local_image`, `analyze_widget_metadata` | `ExifUtils`, `RecipeParserFactory`, downloader factory, optional metadata collector/processor | Normalises missing/invalid payloads into `RecipeValidationError`; generates consistent fingerprint data to keep duplicate detection stable; temporary files are cleaned up after every analysis path. |
|
||||
| `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. |
|
||||
| `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `get_reconnect_suggestions`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. |
|
||||
| `RecipeSharingService` | `share_recipe`, `prepare_download` | `tempfile`, recipe scanner | Copies originals to TTL-managed temp files; metadata lookups re-use the scanner; expired shares trigger cleanup and `RecipeNotFoundError`. |
|
||||
|
||||
## Maintaining critical invariants
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
# i18n Translation Guidelines
|
||||
|
||||
This document is the canonical set of conventions for translating LoRA Manager UI strings.
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 1810 leaf keys; all locales share the exact
|
||||
same key structure).
|
||||
|
||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
|
||||
> **Status (2026-08 sweep):** a full audit was executed and the terminology, placeholder,
|
||||
> stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales
|
||||
> (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**,
|
||||
> not a to-do list — future edits should preserve these renderings and only add what is new.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard rules (do not violate)
|
||||
|
||||
### R1 — Key structure is sacred
|
||||
- Only `locales/en.json` may add/remove/rename keys. All other locales must keep the exact
|
||||
same nested key set. `tests/i18n/test_i18n.py` enforces this.
|
||||
- When a new UI string is added to `en.json`, run
|
||||
`python scripts/sync_translation_keys.py` (adds the missing keys to all locales with
|
||||
`[TODO: Translate]` placeholder copies) — **then stop**. Do NOT translate proactively:
|
||||
placeholders are the expected end state during feature development, and translations are
|
||||
filled in only when the feature owner explicitly asks (workflow details in §7).
|
||||
- Never reorder, re-indent, or reformat a locale file "for tidiness". The sync script
|
||||
preserves formatting; manual reformatting creates noisy diffs.
|
||||
|
||||
### R2 — Placeholders and HTML must be preserved verbatim
|
||||
- `{name}`-style placeholders must appear in the translation exactly as in `en.json`.
|
||||
Do not invent placeholders the source string does not have — the caller may not pass them
|
||||
(example bug: `zh-CN recipes.controls.import.downloadLocationPreview` added `{path}`; the
|
||||
template renders this key with no parameters, so the literal text `{path}` shows in the UI).
|
||||
- `{{...}}` in a locale value is an escaped literal brace — keep it identical.
|
||||
- Keep embedded HTML tags (e.g. `<strong>...</strong>`, `<code>...</code>`) intact.
|
||||
You may move the tag around the sentence if the target language needs different word order.
|
||||
|
||||
### R3 — Never translate or transliterate these
|
||||
- Model types: **LoRA, Checkpoint, Embedding, Diffusion Model**
|
||||
- Products/brands: **LoRA Manager, ComfyUI, CivitAI, CivArchive, HuggingFace, Ko-fi**
|
||||
- Ecosystem names: **LyCORIS, DoRA**, trigger-adjacent jargon **Prompt, Workflow**
|
||||
(these are used as-is in the target-language SD community; see §2 per-language policy)
|
||||
- Theme names: **Nord, Midnight, Monokai, Dracula, Solarized**
|
||||
|
||||
### R4 — The "Recipe" convention (the most important domain term)
|
||||
Product intent: a *Recipe* records a **LoRA combination + generation parameters**
|
||||
(prompt, seed, sampler, …) that reproduces an image style. The metaphor is a **cooking
|
||||
recipe** — "follow it and you get a similar dish". It is **not** a menu, not a dish list,
|
||||
not a prescription.
|
||||
|
||||
Decision per language — translate only into a word whose everyday primary meaning is a
|
||||
cooking recipe; where that word would mislead users, **keep the English "Recipe(s)"**:
|
||||
|
||||
| Locale | Use | Never use |
|
||||
|---|---|---|
|
||||
| fr | **Recipe / Recipes** (keep English) | recette(s) — cooking reading is secondary and it was explicitly judged misleading |
|
||||
| zh-CN / zh-TW | 配方 | 食谱 (reads as "food cookbook") |
|
||||
| ja | レシピ | — (leftover English "Recipe" in `initialization.recipes.title` / `toast.recipes.recipeSaved` → translate) |
|
||||
| ko | 레시피 | — |
|
||||
| de | Rezept / Rezepte | — (cooking meaning dominant; prescription reading acceptable) |
|
||||
| es | receta / recetas | — (cooking meaning dominant) |
|
||||
| ru | рецепт / рецепты | — (leftover English "Recipe" in `initialization.recipes.title` / `toast.recipes.recipeSaved` → translate) |
|
||||
| he | מתכון / מתכונים | — (cooking meaning dominant) |
|
||||
|
||||
Whatever the choice, **one concept = one noun within a locale**. Currently violated in:
|
||||
- `fr` — "Recipe" (~97 keys, incl. nav) mixed with "recette" (~58 keys)
|
||||
- `zh-CN` / `zh-TW` — 配方 (126/122 keys) mixed with 食谱 / 食譜 (14/17 keys, all in the
|
||||
*rematch* flow: `globalContextMenu.rematchRecipes.*`, `toast.recipes.rematch*`)
|
||||
- `de` — "Rezept" (136 keys) mixed with leftover English "Recipe" (5 keys)
|
||||
- `ja` / `ru` — leftover English "Recipe" in `initialization.recipes.title` ("Recipe Manager
|
||||
zu initialisieren" / «Инициализация Recipe Manager») and `toast.recipes.recipeSaved`
|
||||
|
||||
### R5 — One term, one rendering (within each locale)
|
||||
Same source word must not be translated several ways in one file. Known offender areas
|
||||
(see §5 for the full fix list): recipe, Checkpoint, Embedding, prompt, base model, preset,
|
||||
workflow, hash, metadata, tags, bulk. Every locale currently mixes variants of at least one
|
||||
of these — pick the preferred form in the §2 tables and normalize.
|
||||
|
||||
### R6 — Register consistency
|
||||
- `zh-CN` / `zh-TW`: pick 你 or 您 once. Do not mix (zh-CN has 44×你 + 5×您; zh-TW has
|
||||
27×您 + 18×你).
|
||||
- `de`: pick "du" or "Sie" once (currently 143×Sie + ~7×du).
|
||||
- `es`: pick "tú" or "usted" once.
|
||||
|
||||
### R7 — Punctuation per script
|
||||
- Full-width punctuation `:()` is correct **only in CJK locales** (zh-CN, zh-TW, ja, ko).
|
||||
- Latin/Cyrillic/Hebrew locales must use ASCII `: ()` — full-width colons leaked in there
|
||||
are machine-translation artifacts. Known: `fr toast.recipes.createError/createFailed`,
|
||||
`es toast.recipes.createError/createFailed` (e.g. "…de la receta:" should be "…de la receta:").
|
||||
- `fr` apostrophes must be U+2019 `'` / ASCII `'`, never a straight double quote:
|
||||
`fr header.filter.allowSellingGeneratedContentTooltip` currently reads
|
||||
`vendre d"images` → fix to `d'images`. Do not mix `'` and `'` in one file (fr has 299 vs 15).
|
||||
- Ellipsis: use ASCII `...` (project style). Don't introduce `…`.
|
||||
- Keep the sentence-ending period/omission consistent with the source string where the
|
||||
language allows it.
|
||||
- `he` is RTL: mix of Hebrew and Latin scripts is normal; keep Latin term ordering natural.
|
||||
|
||||
### R8 — No untranslated English leftovers
|
||||
Full sentences left byte-identical to `en.json` are bugs (brand names and URL placeholders
|
||||
are the exception). Every locale has them; see §6 for the per-locale checklist.
|
||||
`[TODO: Translate]` placeholders are the sanctioned intermediate state during feature
|
||||
development (see §7) — do not "fix" them unless the feature owner asked for translations.
|
||||
|
||||
### R9 — Mirror the source even when the source is wrong
|
||||
If `en.json` itself contains an inconsistency (e.g. the `Civitai` vs `CivitAI` casing split,
|
||||
or the `CivitArchive` typo in `modals.relinkCivitai.helpText.format4`), translate/transcribe
|
||||
it as-is in your locale and instead **fix the source** in `en.json` (then propagate by
|
||||
re-syncing and re-translating affected keys). Do not silently diverge in one locale only.
|
||||
|
||||
---
|
||||
|
||||
## 2. Per-language term maps
|
||||
|
||||
Preferred rendering per term. "Fix" means the locale currently contains the wrong variant
|
||||
and must be normalized. `en` = keep the English word as-is.
|
||||
|
||||
### fr
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | Recipe(s) | Replace all "recette(s)" (58 keys, e.g. `recipes.actions.deleteRecipeWithShortcut`, `toast.recipes.rematchComplete`) with "Recipe(s)" |
|
||||
| Checkpoint | Checkpoint | `statistics.modelTypes.checkpoint` = "Point de contrôle" → "Checkpoint" |
|
||||
| trigger words | mot(s)-clé(s) | unify: `modals.model.triggerWords.editWord` uses "mot déclencheur" — pick one |
|
||||
| prompt / negative prompt | Prompt / prompt négatif | — |
|
||||
| base model | modèle(s) de base | — |
|
||||
| preset | préréglage | unify: `modals.model.usageTips.addPresetParameter` "prédéfini", `toast.presets.restored` "par défaut" |
|
||||
| hash | hash | `conflictConfirm.message` "hachage" → "hash" |
|
||||
| tags | tags | `settings.sections.priorityTags` "Étiquettes" → "Tags" |
|
||||
| metadata | métadonnées | `loras.controls.refresh.fullTooltip` keeps English "metadata" |
|
||||
| duplicates | doublon(s) | unify with "dupliqué(e)s" |
|
||||
| bulk | groupé(e) | unify with "par lot / mode lot" variants |
|
||||
|
||||
### de
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | Rezept/Rezepte | 5 leftover English "Recipe" keys → Rezept (e.g. `globalContextMenu.repairRecipes.label`, `toast.recipes.recipeSaved`) |
|
||||
| base model | pick Basis-Modell or Basismodell | currently 27× hyphenated vs 15× closed |
|
||||
| metadata | Metadaten | 4 keys use "Modelldaten" (`onboarding.steps.fetch.title/content`) → Metadaten |
|
||||
| bulk | pick Massen- or Sammelmodus | `loras.controls.bulk.action` = "Massen" reads as "crowds" — use "Massenbearbeitung"/"Mehrfachauswahl" |
|
||||
| register | Sie (formal) | 7 keys use "du/dein" (`settings.backup.managementHelp`, `modals.checkUpdates.message/tip`, `doctor.footer`, …) |
|
||||
|
||||
### es
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | receta(s) | — |
|
||||
| Checkpoint | Checkpoint | 5 statistics keys "Punto(s) de control" → "Checkpoints" (`statistics.metrics.checkpoints`, `statistics.insights.unusedCheckpoints.*`, `statistics.modelTypes.checkpoint`) |
|
||||
| trigger words | palabra(s) de activación | 2 keys already use it; ~15 keys "palabra(s) clave" (reads as search keyword) → unify |
|
||||
| base model | modelo base | — |
|
||||
| preset | preajuste | 3 keys keep English "preset", 1 "preestablecido" → preajuste |
|
||||
| workflow | pick flujo de trabajo or workflow | currently 21× "flujo de trabajo" vs 10× "workflow" |
|
||||
| bulk | masivo / por lotes | unify; "Batch Import" → traducción |
|
||||
| tags | etiquetas | — |
|
||||
|
||||
### ru
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | рецепт(ы) | English leftovers: `initialization.recipes.title`, `recipes.batchImport.*`, `toast.recipes.recipeSaved` → translate |
|
||||
| Checkpoint | Checkpoint (recommended) | 3 variants today: "Checkpoint" (17 keys), «Чекпойнт», «Контрольная точка» (statistics, 6 keys) — statistics MUST drop «Контрольная точка» |
|
||||
| Embedding | Embedding | «Эмбеддинг» variant exists in `settings.priorityTags.modelTypes.embedding` — unify |
|
||||
| prompt | промпт | 8 keys use «запрос» (reads as "database/HTTP request") → «промпт» |
|
||||
| base model | базовая модель | — |
|
||||
| preset | пресет | `header.theme.presets` "Предустановки" → пресеты |
|
||||
| workflow | Workflow (recommended) | «рабочий процесс» used in 4 keys — unify |
|
||||
| hash | pick хеш or хэш | both spellings co-occur |
|
||||
| tag(s) | тег(и) | — |
|
||||
| typos | — | `settings.misc.loraSyntaxFormatHelp`: «безпотерьного» → «беспотерьного» |
|
||||
|
||||
### he
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | מתכון / מתכונים | — |
|
||||
| Checkpoint | Checkpoint | 5 statistics keys «נקודת/נקודות ביקורת» (road/security checkpoint) → "Checkpoint(s)" (`statistics.metrics.checkpoints`, `statistics.modelTypes.checkpoint`, `statistics.insights.unusedCheckpoints.*`) |
|
||||
| Embedding | Embedding | `statistics` keys use הטמעות → Embedding |
|
||||
| prompt | pick הנחיה or פרומפט | 9 keys הנחיה vs 3 פרומפט — unify (recommend פרומפט, SD-community loanword) |
|
||||
| preset | קביעה מראש | `header.filter.presetOverwriteConfirm` uses פריסט → unify |
|
||||
| hash | pick one of האש / גיבוב / hash | 3 variants co-occur — unify (recommend hash or גיבוב) |
|
||||
| metadata | pick מטא-דאטה or מטא-נתונים | 38 vs 17 keys — unify |
|
||||
| model | מודל | 13 keys use דגם/דגמים — unify |
|
||||
| bulk | pick one of 5 variants | 5 different renderings ("כמות גדולה", "המוני", "קבוצתי", "אצווה", …) — unify; `loras.controls.bulk.action` "כמות גדולה" reads as "large quantity" |
|
||||
|
||||
### ja
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | レシピ | `initialization.recipes.title` keeps English "Recipe Manager" — translate to レシピマネージャー |
|
||||
| Checkpoint | Checkpoint or チェックポイント (pick one) | 3 variants: Checkpoint (~14), checkpoint lowercase (4), チェックポイント (4, e.g. `settings.priorityTags.modelTypes.checkpoint`) |
|
||||
| Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence |
|
||||
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
|
||||
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
||||
|
||||
### ko
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | 레시피 | — |
|
||||
| Checkpoint | Checkpoint (recommended) | 4 keys transliterate 체크포인트 (`settings.priorityTags.modelTypes.checkpoint`, `toast.recipes.missingCheckpointPath/missingCheckpointInfo/downloadCheckpointFailed`) |
|
||||
| Embedding | Embedding | 3 keys 임베딩 (`settings.priorityTags.modelTypes.embedding`, `uiHelpers.nodeSelector.embedding`) |
|
||||
| base model | 베이스 모델 | 6 keys «기본 모델» read as "default model" → 베이스 모델 (`settings.downloadSkipBaseModels.*`, `toast.loras.downloadSkippedByBaseModel`) |
|
||||
| workflow | pick 워크플로 or 워크플로우 | 26 vs 6 keys — unify |
|
||||
| bulk | 일괄 | `modals.checkUpdates.tip` "벌크 모드" → 일괄 모드 |
|
||||
| tag logic | — | `header.filter.tagLogicAny` = "모든 태그 일치 (OR)" is **inverted** (should be "하나 이상의 태그 일치") and identical to `tagLogicAll` |
|
||||
| particle | — | `modelCard.sendToWorkflow.checkpointNotImplemented`: "Checkpoint을" → "Checkpoint를" |
|
||||
|
||||
### zh-CN / zh-TW
|
||||
|
||||
| Term | zh-CN | zh-TW |
|
||||
|---|---|---|
|
||||
| recipe | 配方 (fix 食谱 → 配方, 14 keys in rematch flow) | 配方 (fix 食譜 → 配方, 17 keys in rematch flow) |
|
||||
| Checkpoint | Checkpoint (fix 检查点 → Checkpoint, 5 keys: `toast.recipes.missingCheckpointPath/missingCheckpointInfo/downloadCheckpointFailed`, `modelCard.actions.checkpointNameCopied`, `modelCard.sendToWorkflow.checkpointNotImplemented`) | Checkpoint (fix 檢查點 → Checkpoint, 4 keys: `modelCard.actions.copyCheckpointName`, `toast.recipes.missing*`×2, `toast.recipes.downloadCheckpointFailed`) |
|
||||
| base model | 基础模型 (fix 基模型 → 基础模型, 3 keys in `modals.model.versions.filters.*`) | 基礎模型 ✓ consistent |
|
||||
| prompt | 提示词 ✓ | 提示詞 ✓ |
|
||||
| preset | 预设 ✓ | 預設 ✓ |
|
||||
| workflow | 工作流 ✓ | 工作流 ✓ |
|
||||
| trigger words | 触发词 ✓ | 觸發詞 ✓ |
|
||||
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
||||
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||
|
||||
All items below were **resolved** in the 2026-08 sweep — treat them as a regression
|
||||
watch-list: do not reintroduce these renderings.
|
||||
|
||||
1. **Checkpoint rendered as a literal security/road checkpoint** — fr, es, ru, he, zh-CN,
|
||||
zh-TW all had 4–6 keys in the `statistics.*` domain reading as "control point"; reverted
|
||||
to "Checkpoint".
|
||||
2. **"recipe" variants that break the one-noun rule** — fr "recette" → "Recipe", zh
|
||||
食谱/食譜 → 配方, de/ja/ru leftover English "Recipe" translated.
|
||||
3. **ko `header.filter.tagLogicAny`** — was inverted ("모든 태그 일치 (OR)") and identical
|
||||
to `tagLogicAll`; now "어느 하나의 태그와 일치 (OR)".
|
||||
4. **ja `modals.model.versions.actions.viewLocalTooltip`** — was the stale "近日対応予定"
|
||||
("coming soon"); all 9 locales now describe the actual action.
|
||||
5. **Stale help texts** — `settings.downloadSkipBaseModels.help`,
|
||||
`settings.aiProvider.apiBaseHelp`, `settings.hideEarlyAccessUpdates.help` retranslated
|
||||
in all locales to the current `en.json` wording.
|
||||
6. **en.json source bugs** (fixed in source, then mirrored):
|
||||
- "Civitai" → "CivitAI" brand casing (values only; key names `relinkCivitai` etc. keep
|
||||
their lowercase form and must not be renamed)
|
||||
- `modals.relinkCivitai.helpText.format4` "CivitArchive" typo → "CivArchive"
|
||||
- `zh-CN recipes.controls.import.downloadLocationPreview` invented `{path}` removed
|
||||
|
||||
---
|
||||
|
||||
## 4. Placeholder contract deviations (current)
|
||||
|
||||
`{...}` token sets must match `en.json` per key. All deviations found in the 2026-08 sweep
|
||||
were fixed, with one *intentional* exception:
|
||||
|
||||
**`toast.settings.mappingsUpdated`** — the caller passes a hardcoded English inflection
|
||||
(`plural: count !== 1 ? 's' : ''`). Languages that cannot build a plural by appending that
|
||||
`s` (zh-CN/zh-TW, ja, ko, de, ru, he) **drop `{plural}`** and render a count-friendly form
|
||||
(`({count})` or a measure word); fr and es keep it (`mappage{plural}`, `mapeo{plural}`).
|
||||
|
||||
```python
|
||||
# keep a copy of this rule next to the key if it ever moves:
|
||||
# fr/es: "... ({count} mappage{plural})"
|
||||
# de/ru/he: "... ({count})"
|
||||
# zh-CN: "({count} 条映射)" / zh-TW: "({count} 個對應)" / ja: "({count} マッピング)"
|
||||
```
|
||||
|
||||
Do NOT add `{...}` tokens the source lacks (the caller will not supply them, and the literal
|
||||
text renders in the UI), and do NOT rename source tokens (`{typePlural}` stays `{typePlural}`).
|
||||
|
||||
---
|
||||
|
||||
## 5. One term, one rendering — offender matrix
|
||||
|
||||
Cross-locale summary of §2 inconsistencies. "✓" = already consistent. All ✗ cells were
|
||||
resolved in the 2026-08 sweep; the row shows the single rendering now in force per locale.
|
||||
|
||||
| Term | fr | de | es | ru | he | ja | ko | zh-CN | zh-TW |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| recipe | Recipe | Rezept | receta | рецепт | מתכון | レシピ | 레시피 | 配方 | 配方 |
|
||||
| Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint |
|
||||
| Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding |
|
||||
| prompt | Prompt | Prompt | prompt | промпт | פרומפט | プロンプト | 프롬프트 | 提示词 | 提示詞 |
|
||||
| base model | modèle de base | Basismodell | modelo base | базовая модель | מודל בסיס | ベースモデル | 베이스 모델 | 基础模型 | 基礎模型 |
|
||||
| preset | préréglage | Voreinstellung | preajuste | пресет | קביעה מראש | プリセット | 프리셋 | 预设 | 預設 |
|
||||
| workflow | Workflow | Workflow | workflow | Workflow | workflow | ワークフロー | 워크플로 | 工作流 | 工作流 |
|
||||
| hash | hash | Hash | hash | хеш | hash | ハッシュ | 해시 | 哈希 | 雜湊 |
|
||||
| metadata | métadonnées | Metadaten | metadatos | метаданные | מטא-נתונים | メタデータ | 메타데이터 | 元数据 | 中繼資料 |
|
||||
| tags | Tags | Tags | etiquetas | теги | תגיות | タグ | 태그 | 标签 | 標籤 |
|
||||
| duplicates | en double | Duplikate | duplicados | дубликаты | כפילויות | 重複 | 중복 | 重复项 | 重複項 |
|
||||
| bulk | groupé | Massen- | por lotes | пакетный | בכמות גדולה | 一括 | 일괄 | 批量 | 批量 |
|
||||
|
||||
Watch: ja/ko keep the model-type names **Checkpoint/Embedding** and `Diffusion Model` in
|
||||
Latin (consistent with their model-type sections) — do not transliterate them as
|
||||
チェックポイント/체크포인트.
|
||||
|
||||
---
|
||||
|
||||
## 6. Untranslated English leftovers (status)
|
||||
|
||||
Values byte-identical to `en.json` that are actual UI sentences are bugs (brand names and
|
||||
URL placeholders are the exception). As of the 2026-08 sweep, **all previously untranslated
|
||||
blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipes.batchImport*`
|
||||
(fr/de/es/ru/he/ja/ko), `banners.communitySupport.*`, `modals.model.license.*`,
|
||||
`globalContextMenu.fetchMissingLicenses.*`, the `doctor.*` issue/action/label subset,
|
||||
`toast.settings.libraryLoadFailed` / `libraryActivateFailed`, `toast.api.moveFailed`,
|
||||
`settings.extraFolderPaths.restartRequired`, `toast.recipes.recipeSaved`,
|
||||
`sidebar.dragDrop.moveUnsupported`, `checkpoints.modelTypes.diffusion_model`
|
||||
(ja/ko keep the English loanword), `initialization.recipes.title`.
|
||||
|
||||
The only values that remain intentionally identical to `en.json` are non-translatable:
|
||||
URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`),
|
||||
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
|
||||
(`CivitAI → CivArchive → Archive DB`), and the external playlist title
|
||||
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
||||
|
||||
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
|
||||
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
|
||||
every locale because that is what the user actually sees in ComfyUI.
|
||||
|
||||
License labels (`modals.model.license.*`): the restriction labels are now translated in all
|
||||
locales (the sibling `creditRequired` has always been translated).
|
||||
|
||||
---
|
||||
|
||||
## 7. Workflow for agents and translators
|
||||
|
||||
### Adding a new UI string
|
||||
1. Add the key to `locales/en.json` only.
|
||||
2. Run `python scripts/sync_translation_keys.py` — it inserts the key into the other 9
|
||||
locales (as a `[TODO: Translate]` placeholder) preserving formatting.
|
||||
3. **During feature development, stop here.** While the UI copy is still in flux, leave the
|
||||
`[TODO: Translate]` placeholders as-is — translating churning strings into 9 locales is
|
||||
wasted work. Placeholders are a normal intermediate state, not a bug.
|
||||
4. Once the wording is final and the feature owner explicitly asks for translations,
|
||||
translate **all** pending `[TODO: Translate]` keys in every locale (not just the latest
|
||||
feature's), applying §1–§3 (placeholders verbatim, Recipe rule, term maps, register).
|
||||
Find pending keys with: `grep -c "TODO: Translate" locales/*.json`
|
||||
5. If the new string contains new terminology, extend §2 tables.
|
||||
|
||||
### Fixing a translation bug
|
||||
1. Locate the key (dotted path) in the relevant locale file.
|
||||
2. Check the corresponding `en.json` value and the actual caller (grep `static/js` or
|
||||
`web/comfyui` for the key) to learn which placeholders are passed.
|
||||
3. Fix trivially; for normalization sweeps (e.g. "recette" → "Recipe"), do it file-wide for
|
||||
the offending keys only — do not touch unrelated lines.
|
||||
4. If the bug is in `en.json` itself (R9), fix the source first, then re-sync and update all
|
||||
locales.
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
pytest tests/i18n/test_i18n.py # key parity + JSON validity + JS key references
|
||||
python scripts/sync_translation_keys.py --dry-run # shows which keys would change; add --verbose for per-key detail
|
||||
npm test # frontend tests incl. i18n helpers
|
||||
```
|
||||
|
||||
`pytest tests/i18n` only checks structure. Quality conventions in this document are not
|
||||
machine-enforced — a human/agent review pass is required.
|
||||
|
||||
### Anti-patterns checklist
|
||||
- [ ] Placeholders `{x}` / `{{x}}` differ from `en.json`
|
||||
- [ ] Same source term translated 2+ ways in the same file (see §5)
|
||||
- [ ] "Checkpoint" became a literal checkpoint; "recipe" became menu/prescription/food-cookbook
|
||||
- [ ] Brand names translated or transliterated (LoRA, CivitAI, ComfyUI, …)
|
||||
- [ ] Latin locale using full-width `:()`; fr using `"` as apostrophe
|
||||
- [ ] Mixed 你/您, du/Sie, tú/usted
|
||||
- [ ] Full English sentences left behind (see §6)
|
||||
- [ ] Register/typos/mojibake; source string is stale vs `en.json` (compare semantics, not
|
||||
just words)
|
||||
@@ -0,0 +1,206 @@
|
||||
# Plan: Multi-File Downloads Within a Single CivitAI Model Version
|
||||
|
||||
**Issue:** [#1058 — Cannot download multiple file variants from the same model version](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1058)
|
||||
**Status:** v2 — revised after adversarial review (backend correctness + frontend/tests)
|
||||
**Scope:** CivitAI/CivArchive downloads of `lora`, `checkpoint`, `embedding` model types. HuggingFace downloads are out of scope (already per-file).
|
||||
|
||||
> v2 changelog: incorporated 18 review findings. Key changes vs v1:
|
||||
> shared file resolver + `resolved_version_id` for the gate (R1); `file_params` normalization at API boundary (R2); D2 hash-matching rule fixed for empty-hash cases (R6/R7); D3 extended to re-point `version_index` on removal (R4); D4 replaced with a child table (R3); `delete_model_version` interaction documented (R5); `ModelVersionsTab` surface added to phase 2 (F6); phase-2 multi-file loop requires a reload-deferred download variant (F7); queue-retry `file_params=NULL` known issue recorded (R9); test-fixture gaps and revised estimates (F10).
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
A CivitAI model version can contain multiple downloadable weight files (e.g. fp16/fp32, safetensors/ckpt, different sizes). LoRA Manager already has a working file-selection pipeline (frontend file dialog → `fileParams` → backend file matching), but downloaded state is tracked at the **model-version** level. After any single file of a version is downloaded:
|
||||
|
||||
1. The version is marked **In Library** and the file-selection entry point disappears.
|
||||
2. The backend rejects further download attempts for that version.
|
||||
|
||||
There is no way to download the remaining files of the same version through LoRA Manager.
|
||||
|
||||
## 2. Current State (verified against code; all references confirmed by review)
|
||||
|
||||
### 2.1 Download gating — backend (`py/services/download_manager.py`)
|
||||
|
||||
`_execute_original_download` enforces two version-level gates:
|
||||
|
||||
- **Library gate, early** (lines 1157–1184, before metadata fetch, fires when `model_version_id` given) and **late** (lines 1350–1376, fires only when `model_version_id is None`): `scanner.check_model_version_exists(version_id)` across lora/checkpoint/embedding scanners → hard error `"Model version already exists in ... library"`.
|
||||
- **History gate** (lines 1238–1279): when `skip_previously_downloaded_model_versions` setting is on, `_has_been_downloaded(model_type, version_id)` → silent skip. History DB primary key is `(model_type, version_id)` (`py/services/downloaded_version_history_service.py:61`).
|
||||
|
||||
File selection works: `file_params {id, type, format, size, fp}` is matched against `version_info.files` (lines 1498–1569), **but only under `if file_params and model_version_id:` (line 1499)** — with `model_id`-only requests the selection silently falls back to the primary file (1571–1619). `file_params` currently carries no file `name` or hash.
|
||||
|
||||
### 2.2 Downloaded-state surfacing — backend (`py/routes/handlers/model_handlers.py`)
|
||||
|
||||
`get_civitai_versions` (lines 2148–2188) sets per-version `existsLocally` via `cache.version_index.get(version_id)` (plus a single `localPath` from that entry) and `hasBeenDownloaded` via the history service. No per-file granularity.
|
||||
|
||||
### 2.3 Frontend blockers (`static/js/managers/DownloadManager.js`)
|
||||
|
||||
Three independent gates prevent re-entering the file dialog:
|
||||
|
||||
1. **Line 598:** file-select badge rendered only when `modelFiles.length > 1 && !existsLocally`.
|
||||
2. **Lines 666–681 (`updateNextButtonState`):** Next button disabled with "Already in Library" when `currentVersion.existsLocally`.
|
||||
3. **Lines 784–787 (`proceedToLocation`):** toast + abort when `currentVersion.existsLocally`.
|
||||
|
||||
The badge path (`confirmFileSelection` lines 737–759 → `proceedToLocationContent` → `startDownload` single mode → `executeDownloadWithProgress` → POST `file_params`, `static/js/api/baseModelApi.js:1236–1250`) has **zero** `existsLocally` guards (all 12 occurrences enumerated; none on this path; `import/DownloadManager.js` has none either). The `.exists-locally` CSS class is purely visual (`download-modal.css:496–499`). **Making the badge visible again is sufficient to unlock the flow** for phase 1.
|
||||
|
||||
Post-download refresh is clean: the modal closes and `resetAndReload(true)` performs a full library refetch (`DownloadManager.js:1063`); dialog reopen resets state and refetches versions with no client-side cache. No same-session staleness.
|
||||
|
||||
### 2.4 Local identity of the downloaded file
|
||||
|
||||
`LoraMetadata/CheckpointMetadata/EmbeddingMetadata.from_civitai_info(version_info, file_info, ...)` (`py/utils/models.py:245–369`) persists:
|
||||
|
||||
- `sha256` = `file_info.hashes.SHA256` (lowercased, defaults to `""`) — a stable per-file identity;
|
||||
- `civitai` = the full `version_info` payload (including the `files` list).
|
||||
|
||||
Metadata refresh (`metadata_sync_service.py:104–105`) replaces the `civitai` blob wholesale but never overwrites top-level `sha256`; `verify_duplicate_hashes` (481–526) corrects it to the on-disk hash. Top-level-sha256 matching is refresh-robust.
|
||||
|
||||
**Caveats (review R6/R7):**
|
||||
- SHA256 is not guaranteed: CivArchive's transform only sets `hashes` when source data carries it (`civarchive_client.py:185–189`); `from_civitai_info` defaults to `""`.
|
||||
- Name fallback is unreliable exactly when it matters: local `file_name` is extension-less (`models.py:264`) and `generate_unique_filename` rewrites it with a hash suffix on conflict (`download_manager.py:1125–1136`); checkpoints with `hash_status='pending'` keep empty sha256 until on-demand hashing (`model_scanner.py:1232–1240`).
|
||||
|
||||
### 2.5 Version index collision (pre-existing hazard)
|
||||
|
||||
`ModelCache.version_index` is single-valued (`model_cache.py:133`: `version_index[version_id] = item`). Two files of the same version in the library → second entry overwrites the first; `remove_from_version_index` (lines 151–181) drops the whole version key when the indexed entry is removed, even if a sibling file remains. ~10 read sites depend on this index (48 grep touch points total; readers include `recipe_scanner.py:2682–2726`, `recipe_format.py:37–40`, `misc_handlers.py:2440–2444`, `model_handlers.py`, `model_scanner.check_model_version_exists:2444`).
|
||||
|
||||
Review correction (F3): bulk paths `remove_models` (`model_scanner.py:2376`) and `update_single_model_cache` (`:1689`) call `rebuild_version_index()` right after, so a sibling re-enters the index in those flows — the hazard is narrower than v1 stated, but direct `remove_from_version_index` callers (e.g. `model_scanner.py:1018`) still drop the key, and the user-visible artifact in phase 1 is real: `localPath` in the dialog flips to whichever file was indexed last.
|
||||
|
||||
### 2.6 Entry points that send / don't send `file_params` (fully enumerated by review)
|
||||
|
||||
**Send `file_params` (user-initiated dialog flows only):** `DownloadManager.js:1611–1639` (single mode). API surface accepting arbitrary JSON `file_params`: GET `/api/lm/download-model-get` (`model_handlers.py:1634–1686`), POST `/api/lm/downloads/queue/add` (`model_handlers.py:1799–1832`).
|
||||
|
||||
**Never send `file_params` (keep version-level semantics):** batch download (`DownloadManager.js:1756–1766`; batch also filters out in-library versions at `:1648`), `downloadVersionWithDefaults` (`:1810–1830`), recipe import (`import/DownloadManager.js:269–276`), bulk missing-LoRA (`BulkMissingLoraDownloadManager.js:292–299`), `RecipeModal.js:1728–1736`, `ModelVersionsTab.js:1427`. `web/comfyui/` and `vue-widgets/src` contain **no** download triggers at all (grep-verified). `py/services/use_cases/` has only `download_model_use_case.py` (pass-through).
|
||||
|
||||
### 2.7 Paths that do NOT need changes (verified)
|
||||
|
||||
- **aria2 pause/resume** (`_resume_restored_aria2_download`, line 754+): resumes from persisted `resume_context`; never re-runs existence gates.
|
||||
- **`download_coordinator.py:90`**: pure pass-through of `file_params`.
|
||||
- **Update checker / plugin self-update** (`update_routes.py:496–501`): only closes the history DB handle.
|
||||
- **History delete semantics**: `mark_as_deleted` sets `is_deleted_override=1` and `has_been_downloaded` then returns False (`downloaded_version_history_service.py:276`) — LM-initiated deletes already reset the history skip.
|
||||
|
||||
### 2.8 Related pre-existing issues (record, not necessarily fix)
|
||||
|
||||
- **Queue retry drops file selection** (R9): `download_queue_service.retry_from_history` / `retry_all_failed` re-queue with `file_params=NULL` (`download_queue_service.py:705, 758`) although the queue table has a `file_params` column (`:43`) — a retried non-primary download silently reverts to the primary file. Fix alongside phase 1 (small: persist and reuse the column).
|
||||
- **`delete_model_version`** (`misc_handlers.py:2410–2487`): resolves the file via the single-valued `version_index` (2440–2444), deletes only that one file, and `mark_as_deleted` flags the **entire version** as deleted in history (2479) even when a sibling file remains in the library. See phase 2 item 6.1.5.
|
||||
|
||||
## 3. Goals / Non-Goals
|
||||
|
||||
**Goals**
|
||||
|
||||
- G1: A user can download any not-yet-downloaded file of a version already partially in the library (issue repro steps 6–8).
|
||||
- G2: True duplicates stay blocked: downloading the *same* file of the same version twice is rejected.
|
||||
- G3: Per-file downloaded state visible in the file dialog; multiple files selectable and downloadable in one pass.
|
||||
- G4: No regression for version-level semantics relied on by batch download, recipe missing-LoRA detection, and `skip_previously_downloaded_model_versions`.
|
||||
|
||||
**Non-Goals**
|
||||
|
||||
- No change to recipe `inLibrary` semantics ("any file of the version present" remains sufficient).
|
||||
- No change to the update-checker (version-level comparison).
|
||||
- No primary-key rebuild of the history database.
|
||||
- HuggingFace download flow untouched.
|
||||
|
||||
## 4. Design Decisions
|
||||
|
||||
- **D1 — Explicit file selection bypasses the history gate, version-level gates stay for everyone else.** The history skip exists to dedupe automated flows. A user explicitly picking a file is unambiguous intent; the file-level library gate (G2) still prevents real duplicates. **Guard conditions use normalized truthiness** (see D1a). All confirmed `file_params` senders are user-initiated dialog flows (2.6), and LM-initiated deletes already reset history (2.7), so the bypass only affects "downloaded but not LM-deleted" versions with the setting on — intended.
|
||||
- **D1a — `file_params` normalization at the boundary (R2).** `download-model-get` and `downloads/queue/add` accept arbitrary JSON; `{}` is `not None` but falsy and would bypass gates while downloading the primary file. Normalize `file_params = file_params or None` in the coordinator/handlers, and treat the bypass as active only when a target file id is resolvable.
|
||||
- **D2 — File identity matching rule (R6/R7):** hash-compare **only when both sides are non-empty** (lowercase SHA256 equality); name-compare when either side is empty. Never let `"" == ""` match. Name fallback caveats from 2.4 apply (renamed files, pending checkpoint hashes) — acceptable residual risk, worst case is a blocked re-download the user can retry after hashing completes.
|
||||
- **D3 — Cache indexes: additive multi-index + removal re-pointing (R4).** Add `version_files_index: Dict[int, List[dict]]` maintained alongside `version_index` by the same add/remove/rebuild methods; existing readers of `version_index` untouched. Additionally fix `remove_from_version_index`: when the popped entry has a surviving sibling (per the multi-index), re-point `version_index[version_id]` to the sibling instead of dropping the key; same for the `model_id_index` descriptor. This closes the 2.5 hazard for existing readers (`check_model_version_exists`, `existsLocally`, recipe matching) without restructuring anything.
|
||||
- **D4 — Per-file history via a child table (R3).** v1's additive-column approach is structurally impossible on a `(model_type, version_id)` PK (`ON CONFLICT DO UPDATE` would keep only the last file). Instead add `downloaded_version_files(model_type, version_id, file_id, file_name, downloaded_at, PRIMARY KEY(model_type, version_id, file_id))` — additive, no PK rebuild, honors the Non-Goal. Existing version-level table and queries unchanged. New per-file queries are opt-in. `_initialize_schema` uses `CREATE TABLE IF NOT EXISTS`, so the new table is created for existing DBs without any ALTER.
|
||||
- **D5 — UI flow reuse, with an extracted inner download function for multi-file (F7).** Phase 1 unlocks the existing badge → file dialog → location → download pipeline. Phase 2 upgrades the dialog to multi-select; iterating `executeDownloadWithProgress` as-is would produce N full library reloads, N toasts, and competing failure-summary modals — so phase 2 extracts a reload-deferred, failure-aggregating inner variant and runs one reload + one summary at the end.
|
||||
|
||||
## 5. Implementation — Phase 1 (fix the issue; independently shippable)
|
||||
|
||||
### 5.1 Backend — `py/services/download_manager.py`
|
||||
|
||||
1. **Normalize `file_params`** at the boundary (D1a): `download_coordinator.schedule_download` and the two API handlers (`model_handlers.py:1649–1666`, `1810–1832`) apply `file_params = file_params or None`.
|
||||
2. **Extract a shared file resolver** (R1): pull the matching logic at 1498–1569 into `_resolve_target_file(version_info, file_params) -> Optional[dict]`, used by **both** the new gate and the download-selection path. The selection path's condition (line 1499) switches from `model_version_id` to `resolved_version_id` (already computed at 1230–1236 from `version_info.id`), so gate and download always agree on the target file — including the `model_id`-only case.
|
||||
3. **New helper** `_find_local_file_entry(version_id, target_file) -> Optional[dict]`: iterate the three scanners' cached `raw_data` (NOT `version_index` — single-valued); candidates = entries whose `civitai.id` normalizes to `version_id`; match per D2.
|
||||
4. **Gate restructure in `_execute_original_download`**:
|
||||
- Early scanner gate (1157–1184): add `file_params is None` guard; with normalized `file_params`, defer (file identity not resolvable before metadata fetch).
|
||||
- After `version_info` fetch + `resolved_version_id` (~1229): when `file_params` present, resolve target file via the shared resolver; unresolvable → hard error "No matching file" (fail closed, prevents empty-dict bypass). Resolvable → `_find_local_file_entry`; hit → same hard error shape as today with the file name in the message.
|
||||
- History gate (1238–1279): add `file_params is None` (D1). Base-model skip (1281–1324) unchanged — still applies.
|
||||
- Late gate (1350–1376): add `file_params is None` guard (F2) — the post-fetch file-level check above already covers this case.
|
||||
- Nothing between the early gate and the post-fetch point assumes the version is absent (review task 6: only provider selection + metadata fetch; no DB writes; `_persist_aria2_state` runs only when actually downloading at 1659).
|
||||
5. **Queue retry fix** (2.8, small): persist `file_params` into the queue table on enqueue and reuse it in `retry_from_history` / `retry_all_failed`.
|
||||
6. Logging: `[download]` lines for file-level allow/block, consistent with existing style.
|
||||
|
||||
**Estimated:** ~150–220 LOC + resolver extraction.
|
||||
|
||||
### 5.2 Frontend — `static/js/managers/DownloadManager.js`
|
||||
|
||||
1. Line 598: drop `&& !existsLocally` from the badge condition (badge shows whenever `modelFiles.length > 1`).
|
||||
2. `fileParams` construction (1611–1616): add `name: this.selectedFile.name`.
|
||||
3. Surface the backend "file already in library" hard error as a toast instead of only the batch-summary modal (R10/F12 nit; reuse existing error message field).
|
||||
4. No changes to `updateNextButtonState` / `proceedToLocation` in phase 1; no template or CSS changes.
|
||||
|
||||
**Known phase-1 UX limitations (acknowledged, fixed in phase 2):** with all files downloaded the badge still renders and re-picking a downloaded file fails late (backend error after the location step); `localPath` may point at a sibling file; batch-preview "In Library" badge stays version-level and gives no hint of remaining files.
|
||||
|
||||
**Estimated:** ~10–30 LOC (confirmed realistic by review).
|
||||
|
||||
### 5.3 Phase 1 tests
|
||||
|
||||
Backend — extend `tests/services/test_download_manager_basic.py` (1694 lines; all fixture patterns exist):
|
||||
|
||||
- **Fixture gaps to add (F10):** `DummyScanner.get_cached_data()`/`raw_data` stub (~10 lines); `hashes.SHA256` in the metadata-provider payload's `files`.
|
||||
- Cases: same version + different SHA256 in library + `file_params` → proceeds; same SHA256 → hard error; `file_params=None` + version in library → hard error (unchanged); history-skip on + `file_params` → not skipped; without → skipped (unchanged); empty-dict `file_params` normalized → version-level behavior; `model_id`-only + `file_params` → gate and selection resolve the same file; legacy metadata (empty local sha256) matched by name; target file with empty SHA256 → name fallback, no `""==""` false positive.
|
||||
- Queue retry: `file_params` survives retry.
|
||||
- Assert proceed/abort via the existing `_execute_download` mock pattern.
|
||||
|
||||
Frontend (`tests/frontend/`): badge renders for multi-file version with `existsLocally=true` (pattern from `downloadManager.history.test.js`).
|
||||
|
||||
**Estimated:** ~150–250 LOC (confirmed realistic).
|
||||
|
||||
## 6. Implementation — Phase 2 (per-file status + multi-select + index hardening)
|
||||
|
||||
### 6.1 Backend
|
||||
|
||||
1. **`py/services/model_cache.py`** (D3): add `version_files_index`; maintain in `add_to_version_index` / `remove_from_version_index` / `rebuild_version_index`; removal re-points `version_index[version_id]` (and the `model_id_index` descriptor) to a surviving sibling instead of dropping the key.
|
||||
2. **`py/services/model_scanner.py`**: expose `get_files_for_version(version_id) -> List[dict]`.
|
||||
3. **`py/routes/handlers/model_handlers.py` `get_civitai_versions`**: annotate each version with `downloadedFiles: [{fileId, fileName, filePath}]` via `version_files_index` + D2 matching against `version.files`.
|
||||
4. **`py/services/downloaded_version_history_service.py`** (D4): new child table `downloaded_version_files`; `mark_downloaded` also upserts the child row when `file_id` known; `mark_as_deleted` clears the version's child rows only when no sibling remains in the library; new `get_downloaded_file_ids(model_type, version_id) -> set[int]`. `_record_downloaded_version_history` passes `file_info` through.
|
||||
5. **`delete_model_version`** (`misc_handlers.py:2410–2487`, R5): resolve **all** local files of the version via `version_files_index`; delete all (current endpoint semantics are version-level) or — if kept per-file — only `mark_as_deleted` when no sibling remains. Decide at implementation time; minimum is documenting current behavior.
|
||||
6. **`ModelVersionsTab` backend support**: none needed beyond item 3 (`downloadedFiles`); the tab consumes the same versions payload.
|
||||
|
||||
### 6.2 Frontend
|
||||
|
||||
1. **File dialog multi-select** — change surface (F8): option markup (`DownloadManager.js:712–724`), the single-select click handler (`727–734`), the `input[type="radio"]:checked` selector in `confirmFileSelection` (`738`); template `templates/components/modals/download_modal.html:48–60` (confirm-button label only); CSS `download-modal.css` — checkbox variant of `.file-option-radio input` (595–604) and a **new** `.file-option.disabled` style (does not exist). Files whose id ∈ `downloadedFiles` render disabled with an "In Library" tag.
|
||||
2. **Mixed-type guard (F8):** multi-select is restricted to files sharing the same routing target (`_isDiffusionModel` is computed once from a single `selectedFile` at 798–803; e.g. "Model" + "UNet" files route to different roots). Disallow mixed-type multi-select (simplest, predictable); single-file selection unchanged.
|
||||
3. **Multi-file download loop (D5/F7):** extract from `executeDownloadWithProgress` a reload-deferred, no-toast inner function; iterate per selected file with per-file progress; one `resetAndReload(true)` + one aggregated success/failure summary at the end (reuse `showDownloadBatchSummary`).
|
||||
4. **`updateNextButtonState` / `proceedToLocation`:** for multi-file versions, Next routes into the file dialog; hard block only when *every* weight file is downloaded.
|
||||
5. **`ModelVersionsTab.js` (F6):** the Download action (`:576` hidden when `isInLibrary`) — for multi-file versions with remaining files, show it and route into the download modal's file dialog; keep hidden when all files present.
|
||||
6. **Batch preview (F5):** `batch-preview-local-badge` (`:1320`) gains a "partially downloaded" hint for multi-file versions with remaining files.
|
||||
7. New i18n keys (`modals.download.fileSelection.inLibrary`, `downloadSelected`, partial-download tooltip, etc.) → run `python scripts/sync_translation_keys.py`.
|
||||
|
||||
### 6.3 Phase 2 tests
|
||||
|
||||
- `model_cache` (`tests/services/test_model_cache.py` already covers add/remove at 44–55): multi-valued index; sibling re-point on removal; rebuild.
|
||||
- `get_civitai_versions`: `downloadedFiles` correctness (hash match, name fallback, no match, CivArchive no-hash payload).
|
||||
- History service (`tests/services/test_downloaded_version_history_service.py` uses real SQLite on tmp_path): child-table creation on a legacy DB; per-file record/query; `mark_as_deleted` sibling semantics.
|
||||
- Frontend: dialog checkbox rendering/disabled state and multi-file confirm — **greenfield behavior coverage** (F10: no existing test exercises `showFileSelectionStep`/`confirmFileSelection`; infra exists, patterns must be built).
|
||||
|
||||
## 7. Risks and Mitigations
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| History-gate bypass (D1) causes unwanted re-downloads in automated flows | Large checkpoint files re-downloaded | Bypass only with normalized, resolvable `file_params` (D1a); all such senders are user-initiated dialog flows (2.6, verified); tests pin batch/recipe/bulk behavior. |
|
||||
| Empty-hash matching edge cases (R6) | Duplicate download of the same file, or false block | D2 rule: hash only when both non-empty; name otherwise; never `""==""`. Residual risk documented (2.4). |
|
||||
| Phase-1 late-failure UX (F12) | User picks a downloaded file, fails only after location step | Toast surfacing (5.2.3); phase 2 disables downloaded files up front. |
|
||||
| Phase-2 index change corrupts existing behavior | Recipe matching, delete flows | Additive index + re-point only; `version_index` read semantics unchanged; `remove_models`/`update_single_model_cache` already rebuild (F3); tests. |
|
||||
| `delete_model_version` marks whole version deleted while sibling remains (R5) | History wrongly suppresses re-download of the surviving sibling's version | Phase 2 item 6.1.5; documented until then. |
|
||||
| History child-table migration failure on user installs | Service init crash | `CREATE TABLE IF NOT EXISTS` in `_initialize_schema`; failure degrades to version-level behavior (per-file queries return empty). |
|
||||
| Batch-preview badge misleading for partial versions (F5) | Minor UX confusion | Acknowledged in phase 1; fixed in phase 2 item 6.2.6. |
|
||||
| UI confusion: version shows "In Library" while files remain downloadable | Support burden | Phase 2: per-file disabled state + partial-download tooltip. |
|
||||
| Hash-identical sibling files (repacked content) | Second file blocked | Acceptable: scanner hash dedup already collapses them. |
|
||||
|
||||
## 8. Rollout
|
||||
|
||||
1. **Commit 1** — `fix(download): allow downloading additional files of an in-library model version (#1058)` → Phase 1 (5.1–5.3).
|
||||
2. **Commit 2** — `feat(download): per-file download status and multi-file selection (#1058)` → Phase 2 (6.1–6.3).
|
||||
|
||||
Phase 1 alone resolves the issue as reported; phase 2 can ship in a later release if review prefers smaller increments.
|
||||
|
||||
## 9. Effort Estimate (revised after review)
|
||||
|
||||
| Phase | Backend | Frontend | Tests | Risk |
|
||||
|---|---|---|---|---|
|
||||
| 1 | ~150–220 LOC (+ queue-retry fix ~30) | ~10–30 LOC | ~150–250 LOC | Low |
|
||||
| 2 | ~250–350 LOC | ~250–350 LOC (multi-file loop refactor + ModelVersionsTab + batch badge) | ~250–350 LOC (dialog tests greenfield) | Medium |
|
||||
@@ -0,0 +1,337 @@
|
||||
# Plan: Global Rate-Limit Abidance for Recipe Ingest & Metadata Fetching
|
||||
|
||||
**Issue:** [#1085 — Large Recipe Ingest Appears to not abide by vendor rate limits, possibly a few other errors?](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1085)
|
||||
**Status:** v2 — reviewed; decisions recorded in §10. **Phase 1 implemented**
|
||||
(2026-08-27, commit `c2a2048c`): coordinator + downloader gate + Fix C
|
||||
failover semantics + helper double-wait fix + settings. **Phase 2
|
||||
implemented** (2026-08-27): batch-import rate-limit failures map to
|
||||
`SKIPPED` + `rate_limited` WebSocket flag + UI slowdown hint (toast + status
|
||||
text, i18n keys synced); `download_to_memory` / `get_response_headers` /
|
||||
`download_file` register 429 cooldowns. Changes vs v1: Fix C moved to
|
||||
Phase 1, helper double-wait resolved in Phase 1, gate/guard ordering
|
||||
specified.
|
||||
**Scope:** HTTP API traffic to CivitAI (`civitai.red`) and CivArchive (`civarchive.com`) from metadata fetching (bulk refresh, metadata sync, recipe analysis/enrichment, usage-control lookups). Large binary downloads (model files / preview images via `download_file`) are out of scope for *pacing* (they are already single-connection transfers) but their 429 responses should still be *registered*.
|
||||
|
||||
> Context: a first batch of fixes for this issue was already committed as
|
||||
> `ee233548` ("fix(recipes): enforce batch-import concurrency bound and harden
|
||||
> ingest errors (#1085)"): the batch-import concurrency controller now shares a
|
||||
> real semaphore (bounds 1–5 actually apply), the Comfy parser tolerates
|
||||
> list/`None` `ckpt_name`, CivArchive treats empty error payloads as failures,
|
||||
> and offline-cooldown short-circuits log at DEBUG. This plan covers the two
|
||||
> remaining orchestration-level fixes:
|
||||
> **Fix 2** — slow down globally when a vendor rate limit is hit (respect
|
||||
> `Retry-After`, queue instead of hammering); **Fix 3** — stop immediately
|
||||
> failing over to CivArchive when CivitAI is rate-limited.
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
During a large recipe ingest (e.g. importing the example-images directory,
|
||||
which can be thousands of images), the manager fires one metadata request per
|
||||
checkpoint + per LoRA per image through the fallback provider chain
|
||||
(`civitai_api → civarchive_api → sqlite`). Consequences observed in #1085:
|
||||
|
||||
1. **CivitAI gets hammered** → 429s. The consumer then *immediately* tries
|
||||
CivArchive for the same lookup, so **CivArchive gets hammered too** before
|
||||
it was ever naturally needed (its only real job is recovering metadata for
|
||||
models deleted from CivitAI).
|
||||
2. Requests are retried per-call after `Retry-After`, but **each concurrent
|
||||
call sleeps independently** → thundering herd: thousands of coroutines wake
|
||||
at the same moment and re-flood the vendor.
|
||||
3. While CivArchive is in the `ConnectivityGuard` cooldown, every batch item
|
||||
short-circuits and is marked `FAILED` — the batch import's success/failure
|
||||
accounting is polluted by a transient vendor state (log spam was fixed in
|
||||
`ee233548`; the item-failure accounting is not).
|
||||
4. `ConnectivityGuard` (`py/services/connectivity_guard.py`) only treats
|
||||
transport-level unreachability as offline; **HTTP 429 is invisible to it**,
|
||||
so nothing ever intentionally paces request rate.
|
||||
|
||||
User expectation from the issue: *"once a vendor rate limit time out is hit,
|
||||
you should trigger a slow down with intentional reduction in request rate"*.
|
||||
|
||||
## 2. Current State (verified against code)
|
||||
|
||||
### 2.1 Where 429s are surfaced
|
||||
|
||||
- `Downloader.make_request` (`py/services/downloader.py:1120-1132`): HTTP 429 →
|
||||
returns `RateLimitError(message, retry_after=…)` parsed from `Retry-After`
|
||||
(missing header defaults to `None`).
|
||||
- `CivitaiClient._make_request` (`py/services/civitai_client.py:97-100`):
|
||||
converts `RateLimitError` to a raise immediately; no waiting. Transient
|
||||
5xx/connection errors are retried 3× with 1s/2s/4s backoff.
|
||||
- `CivArchiveClient._make_request` (`py/services/civarchive_client.py`):
|
||||
raises `RateLimitError` with `provider="civarchive_api"` when not set.
|
||||
- `_RateLimitRetryHelper` (`py/services/model_metadata_provider.py:45-102`):
|
||||
per-call retry loop — sleeps `retry_after` (capped at 1800 s; `≥120 s` ⇒ no
|
||||
retry), then re-raises. Because every concurrent call runs its own helper,
|
||||
they sleep in parallel and re-fire in parallel.
|
||||
- `FallbackMetadataProvider` (`py/services/model_metadata_provider.py:488-508,
|
||||
564-584` etc.): on a final `RateLimitError` from one provider it logs
|
||||
"skipping to next provider" and **continues to the next network provider** —
|
||||
this is the direct cause of the CivArchive flood.
|
||||
- `MetadataSyncService.fetch_and_update_model`
|
||||
(`py/services/metadata_sync_service.py:248-333`): manually iterates
|
||||
`provider_attempts`; on `RateLimitError` it `continue`s to the next provider
|
||||
(same failover problem), then reports `"Rate limited"` when nothing
|
||||
succeeded.
|
||||
- `Downloader.make_request` has a per-destination scope already available:
|
||||
`_guard_destination(url)` returns the hostname (`downloader.py:1194-1199`),
|
||||
used by `ConnectivityGuard`.
|
||||
|
||||
### 2.2 What pacing exists today
|
||||
|
||||
- `ConnectivityGuard`: per-destination cooldown (30 s base, ×2 per extra
|
||||
failure batch, 300 s cap) triggered only by transport errors
|
||||
(`connectivity_guard.py:168-197`).
|
||||
- `AdaptiveConcurrencyController` (batch import, fixed in `ee233548`): shared
|
||||
semaphore enforces 1–5 concurrent items; *duration*-based adjustment only —
|
||||
it never sees HTTP statuses, so it cannot distinguish "slow because rate
|
||||
limited" from "slow because big image".
|
||||
- No token bucket, no minimum inter-request interval, no shared
|
||||
`Retry-After` gate anywhere (`grep` for throttle/token-bucket/rate-limiter:
|
||||
0 hits).
|
||||
|
||||
## 3. Requirements & Constraints
|
||||
|
||||
R1. **Respect `Retry-After`.** After a 429, no further request to that
|
||||
destination may be sent before the vendor's retry window elapses.
|
||||
R2. **No thundering herd.** Concurrent waiters must share one wake-up (gate),
|
||||
not sleep independently.
|
||||
R3. **No double load.** A CivitAI 429 must not trigger a CivArchive request
|
||||
for the same lookup. CivArchive should only be consulted when CivitAI
|
||||
legitimately has no answer (404 / "not found"), or when CivitAI is
|
||||
unreachable long-term.
|
||||
R4. **No spurious item failures.** A rate-limited request must not turn a
|
||||
batch-import item into `FAILED`; it should wait (bounded) and retry, or at
|
||||
worst be `SKIPPED` with a clear "rate limited" reason (re-runnable import).
|
||||
R5. **Never hang forever.** All waiting is bounded by a configurable cap; on
|
||||
expiry the caller receives the `RateLimitError` and can decide.
|
||||
R6. **Keep legitimate failover.** Deleted-model recovery via CivArchive/sqlite
|
||||
must keep working (404 paths unchanged).
|
||||
R7. **Single choke point.** The pacing gate should live where every API call
|
||||
passes (the `Downloader`), so bulk refresh, metadata sync, recipe
|
||||
analysis, and usage-control lookups all benefit without per-feature work.
|
||||
|
||||
## 4. Approach Comparison
|
||||
|
||||
### A. Reactive gate — shared `Retry-After` deadman clock (recommended core)
|
||||
|
||||
A process-wide, per-destination coordinator records the *next-allowed-send*
|
||||
timestamp from each 429 (`now + max(retry_after, backoff)`). Every request
|
||||
through `Downloader.make_request` consults the gate *before sending* and *when
|
||||
a 429 arrives*; waiters block on a shared `asyncio.Event` that fires when the
|
||||
cooldown expires.
|
||||
|
||||
- Pros: single choke point (R7); herd-free (R2); honors server guidance (R1);
|
||||
no guessing at vendor limits; covers all providers automatically; reuses
|
||||
existing per-destination scoping.
|
||||
- Cons: still experiences 429s before slowing down (reactive); long
|
||||
`Retry-After` windows (CivArchive has been observed at ~1500 s) need a sane
|
||||
wait cap + skip/retry UX.
|
||||
|
||||
### B. Preemptive pacing — minimum inter-request interval (recommended companion)
|
||||
|
||||
Per-destination token bucket (simplest form: capacity 1 — at least `N` seconds
|
||||
between consecutive API requests; `N` configurable, default ~0.75 s ≈ 80
|
||||
r/min ceiling).
|
||||
|
||||
- Pros: prevents most 429s before they happen — exactly the "intentional
|
||||
reduction in request rate" the issue asks for; trivial to implement on top
|
||||
of A's coordinator.
|
||||
- Cons: adds latency to bulk operations (thousands of models × `N`); the *exact*
|
||||
vendor limits are unknown (CivitAI anonymous vs keyed vs `civitai.red`
|
||||
mirror differ), so the default must be conservative-but-not-crippling and
|
||||
settings-tunable.
|
||||
|
||||
### C. Fallback semantics change — stop network→network failover on 429 (must-do, low risk)
|
||||
|
||||
`FallbackMetadataProvider` (and `MetadataSyncService.fetch_and_update_model`'s
|
||||
manual loop) must treat a final `RateLimitError` as a **terminal, non-failover
|
||||
result** for network providers. Local-only providers (sqlite archive DB) may
|
||||
stay as a last resort (no vendor cost).
|
||||
|
||||
- Pros: directly removes the CivArchive flood; small, surgical change.
|
||||
- Cons: none significant; requires care to keep 404-failover intact (R6).
|
||||
|
||||
### Rejected / deferred
|
||||
|
||||
- **Per-feature retry queues** (batch import pauses & resumes whole batches):
|
||||
richer UX but much larger change (batch state machine, WebSocket states);
|
||||
unnecessary once A+B make requests wait at the choke point. Defer unless
|
||||
review finds the bounded-wait UX insufficient.
|
||||
- **Full token bucket with burst credit**: overkill; capacity-1 interval is
|
||||
enough given the shared semaphore already caps concurrency at 5.
|
||||
- **Retrying in `connectivity_guard`**: wrong layer — the guard is about
|
||||
transport reachability, not vendor quota.
|
||||
|
||||
## 5. Recommended Architecture
|
||||
|
||||
New singleton **`RateLimitCoordinator`** (`py/services/rate_limit_coordinator.py`,
|
||||
mirroring `ConnectivityGuard`'s singleton + per-destination patterns):
|
||||
|
||||
```
|
||||
state per destination (hostname):
|
||||
next_allowed_send: float (monotonic) # from 429 Retry-After + backoff
|
||||
consecutive_429: int # for backoff growth
|
||||
last_send_at: float # for min-interval pacing
|
||||
waiters: list[Future] | asyncio.Event # shared wake-up per cooldown cycle
|
||||
```
|
||||
|
||||
API:
|
||||
|
||||
- `async wait_for_slot(destination, request_started_within_window: bool)`
|
||||
— called by `Downloader.make_request` *before* sending (blocks until
|
||||
`min(now >= next_allowed_send)` and inter-request interval elapses) and
|
||||
re-armable after a 429.
|
||||
- `register_rate_limit(destination, retry_after: float | None)`
|
||||
— called on 429: `next_allowed_send = max(now + retry_after_or_backoff, current)`;
|
||||
`consecutive_429 += 1`; backoff = `retry_after` honored, else exponential
|
||||
`30 · 2^(n-1)` capped at 1800 s; creates/re-arms the shared wake-up event.
|
||||
- `register_success(destination)` — resets `consecutive_429` (called from the
|
||||
existing 200 path in `make_request`).
|
||||
- `remaining_seconds(destination)`, `in_cooldown(destination)` — for tests and
|
||||
diagnostics.
|
||||
|
||||
Enforcement points:
|
||||
|
||||
1. **`Downloader.make_request`** (`downloader.py:1102-1132`): ordering inside
|
||||
the method is **connectivity-guard fail-fast first** (offline short-circuit
|
||||
costs nothing to check), **then** `await coordinator.wait_for_slot(destination)`
|
||||
before `session.request`. On 429: `coordinator.register_rate_limit(...)`,
|
||||
then *wait for the gate and re-send* (loop, bounded by
|
||||
`rate_limit_max_wait_seconds`, default 300; `retry_after ≥ cap` ⇒ fail
|
||||
immediately). After the loop, return the `RateLimitError` to the caller
|
||||
(unchanged contract) **with `exc.gate_handled = True` set** so downstream
|
||||
retry helpers know the wait already happened. 200 path calls
|
||||
`register_success`.
|
||||
2. **`Downloader.download_to_memory` / `get_response_headers`** (phase 2):
|
||||
register 429s (so API calls queue); waiting only in `make_request`
|
||||
initially.
|
||||
3. **`FallbackMetadataProvider`** (`model_metadata_provider.py`): remove
|
||||
network→network failover on `RateLimitError` — re-raise; only sqlite stays
|
||||
as a local last resort (implementation: per-method `except RateLimitError`
|
||||
handler that marks the chain rate-limited and stops iterating).
|
||||
4. **`MetadataSyncService.fetch_and_update_model`**
|
||||
(`metadata_sync_service.py:248-333`): on `RateLimitError` from the default
|
||||
provider, stop appending further network providers (sqlite may remain);
|
||||
the existing `any_rate_limited` merge already produces `"Rate limited"`.
|
||||
5. **Batch import** (`batch_import_service.py`): no structural change needed —
|
||||
items now wait inside `make_request`; optionally (phase 2) map residual
|
||||
rate-limit failures (after the wait cap) to `SKIPPED` with
|
||||
`"rate limited (retry_after=…s); re-run the import later"` instead of
|
||||
`FAILED`, and surface a `rate_limited` flag in the WebSocket progress
|
||||
broadcast.
|
||||
6. **`_RateLimitRetryHelper` retries** (`model_metadata_provider.py`):
|
||||
**Phase 1** — when the raised `RateLimitError` carries `gate_handled = True`
|
||||
(set by the downloader after honoring the gate), the helper skips its own
|
||||
`retry_after` sleep and re-raises immediately, eliminating the double wait.
|
||||
The wiring stays so a `RateLimitError` still propagates cleanly; full
|
||||
demotion/removal can follow once the gate proves out.
|
||||
|
||||
Settings (`settings.json`, schema extension in `SettingsManager`):
|
||||
|
||||
| key | default | meaning |
|
||||
|---|---|---|
|
||||
| `rate_limit_gate_enabled` | `true` | master switch for the coordinator |
|
||||
| `rate_limit_max_wait_seconds` | `300` | how long `make_request` waits on a 429 gate before returning the error |
|
||||
| `rate_limit_min_interval_seconds` | `0.75` | minimum seconds between API requests per destination (pacing, R6-friendly conservative default) |
|
||||
|
||||
## 6. Changes by File
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `py/services/rate_limit_coordinator.py` (new) | coordinator singleton + per-destination state + tests seam |
|
||||
| `py/services/downloader.py` | gate pre-check + 429 register/wait/retry loop + `register_success`; log the 429 notice at INFO once per cooldown, then DEBUG |
|
||||
| `py/services/model_metadata_provider.py` | `FallbackMetadataProvider`: stop network failover on `RateLimitError`; helper skips its sleep when the error is marked `gate_handled` |
|
||||
| `py/services/metadata_sync_service.py` | `fetch_and_update_model`: same failover semantics; keep sqlite last resort |
|
||||
| `py/services/batch_import_service.py` | (phase 2) rate-limit failures → `SKIPPED` + `rate_limited` progress flag |
|
||||
| `py/services/settings_manager.py` | new settings keys + defaults |
|
||||
| `tests/services/test_rate_limit_coordinator.py` (new) | gate unit tests |
|
||||
| `tests/services/test_civitai_client.py` / `test_civarchive_client.py` | provider-level 429 behavior |
|
||||
| `tests/services/test_metadata_service.py` | failover-chain tests |
|
||||
| `tests/services/test_batch_import_service.py` | SKIPPED-on-rate-limit |
|
||||
|
||||
## 7. Impact, Risks, Open Questions
|
||||
|
||||
- **Behavior change**: with the gate in `make_request`, any request can block
|
||||
up to the wait cap — UI actions that call the API (e.g. a model-details
|
||||
fetch) may take longer during cooldowns. Mitigation: bounded cap + INFO log
|
||||
+ the existing async request handling already tolerates slow responses.
|
||||
**Decided (§10): interactive requests take the same bounded wait** — one
|
||||
behavior, no call-source plumbing; cooldowns are usually short.
|
||||
- **Gate waits occupy batch slots**: with the 1–5 batch semaphore, all slots
|
||||
can park on a gate simultaneously, freezing visible progress for up to one
|
||||
wait cap per wave. Bounded and acceptable; the phase-2 `SKIPPED` mapping +
|
||||
WebSocket `rate_limited` flag (both confirmed in scope, §10) make the stall
|
||||
visible and recoverable.
|
||||
- **Rate limit reality check**: CivitAI anonymous vs keyed limits, and whether
|
||||
`civitai.red` differs, is unverified. Default pacing `0.75 s/req` is a
|
||||
conservative guess (R6). Open question for maintainer: preferred default
|
||||
and whether an API-keyed ceiling should be higher.
|
||||
- **Long CivArchive windows**: `Retry-After ~1500 s` observed in code
|
||||
comments. **Decided (§10): keep the 300 s default cap** — such lookups
|
||||
fail/skip rather than park a request path for 25 minutes; batch import maps
|
||||
them to `SKIPPED` (phase 2) so the user can re-run later.
|
||||
- **Double waiting**: `_RateLimitRetryHelper` + gate could stack waits.
|
||||
**Resolved in Phase 1**: the downloader marks gate-honored errors with
|
||||
`gate_handled = True` and the helper skips its own sleep for those.
|
||||
- **Downloads**: `download_file` 429s return an error to download managers
|
||||
unchanged (already handled); only *registration* is proposed, so future
|
||||
API calls queue behind a large `Retry-After` from a download burst.
|
||||
|
||||
## 8. Test Plan
|
||||
|
||||
1. **Coordinator unit tests** (new file):
|
||||
- 429 with `retry_after` → `wait_for_slot` blocks ~that long, then passes.
|
||||
- N concurrent waiters all wake together (herd test, wall-clock ≈ one
|
||||
window, not N windows).
|
||||
- Consecutive 429s grow backoff; `register_success` resets.
|
||||
- Missing `Retry-After` → default backoff path.
|
||||
- Wait cap: request fails after `rate_limit_max_wait_seconds` with
|
||||
`RateLimitError`.
|
||||
2. **Downloader tests** (mock aiohttp session): 429 then 200 → `make_request`
|
||||
returns success after gate delay; two back-to-back calls to the same
|
||||
destination are spaced ≥ `min_interval`; different destinations are not
|
||||
spaced.
|
||||
3. **Provider tests**: `FallbackMetadataProvider.get_model_version_info` —
|
||||
Civitai raises `RateLimitError` → CivArchive mock **not called**; 404 still
|
||||
falls through to CivArchive; sqlite still tried after network 429.
|
||||
4. **Sync-service test**: `fetch_and_update_model` with a rate-limited default
|
||||
provider → result error contains `"Rate limited"` and sqlite attempt state
|
||||
unchanged.
|
||||
5. **Batch-import test**: analysis provider 429s first, then succeeds →
|
||||
item ends `SUCCESS` (wait path), and post-cap 429 → `SKIPPED` with
|
||||
rate-limit reason (phase 2).
|
||||
6. Full regression: `pytest tests/services tests/routes tests/standalone`
|
||||
(currently 1582 passing).
|
||||
|
||||
## 9. Implementation Phases
|
||||
|
||||
- **Phase 1 (this plan, reviewed):** `RateLimitCoordinator` +
|
||||
`Downloader.make_request` integration (guard fail-fast → gate pre-check
|
||||
pacing → 429 register/wait/retry loop with cap → `gate_handled` marking) +
|
||||
settings + **Fix C failover semantics** (`FallbackMetadataProvider`,
|
||||
`fetch_and_update_model` — moved up from phase 2: smallest diff, kills the
|
||||
CivArchive flood immediately, independent of coordinator correctness) +
|
||||
`_RateLimitRetryHelper` double-wait fix + coordinator/downloader/provider/
|
||||
sync tests.
|
||||
- **Phase 2:** batch-import `SKIPPED`-on-rate-limit + `rate_limited` WebSocket
|
||||
progress flag + slowdown hint (confirmed, §10),
|
||||
`download_to_memory`/HEAD 429 registration, batch tests.
|
||||
- **Phase 3:** full regression + docs + commit referencing `(#1085)`.
|
||||
|
||||
## 10. Review Checklist — Decisions (2026-08-27)
|
||||
|
||||
- [x] Default pacing interval `0.75 s` — **accepted** as conservative default;
|
||||
tunable via `rate_limit_min_interval_seconds`. Revisit if CivitAI
|
||||
publishes keyed/anonymous ceilings.
|
||||
- [x] Wait cap `300 s` — **accepted**; long-window CivArchive lookups fail →
|
||||
batch import marks them `SKIPPED` with a rate-limit reason (phase 2).
|
||||
- [x] Interactive API calls also wait (bounded) — **yes**, same behavior for
|
||||
all callers.
|
||||
- [x] Keep sqlite as last resort behind a network rate limit — **yes**
|
||||
(local-only, no vendor cost).
|
||||
- [x] UI hint — **yes**: WebSocket `rate_limited` flag + "rate limited —
|
||||
slowing down" hint in batch-import progress (phase 2); INFO logging
|
||||
regardless.
|
||||
+375
-203
File diff suppressed because it is too large
Load Diff
+236
-64
@@ -67,11 +67,11 @@
|
||||
"steps": {
|
||||
"fetch": {
|
||||
"title": "Fetch Models Metadata",
|
||||
"content": "Click the <strong>Fetch</strong> button to download model metadata and preview images from Civitai."
|
||||
"content": "Click the <strong>Fetch</strong> button to download model metadata and preview images from CivitAI."
|
||||
},
|
||||
"download": {
|
||||
"title": "Download New Models",
|
||||
"content": "Use the <strong>Download</strong> button to download models directly from Civitai URLs."
|
||||
"content": "Use the <strong>Download</strong> button to download models directly from CivitAI URLs."
|
||||
},
|
||||
"bulk": {
|
||||
"title": "Bulk Operations",
|
||||
@@ -103,8 +103,8 @@
|
||||
"actions": {
|
||||
"addToFavorites": "Add to favorites",
|
||||
"removeFromFavorites": "Remove from favorites",
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"notAvailableFromCivitai": "Not available from Civitai",
|
||||
"viewOnCivitai": "View on CivitAI",
|
||||
"notAvailableFromCivitai": "Not available from CivitAI",
|
||||
"viewOnHuggingFace": "View on Hugging Face",
|
||||
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
|
||||
"copyLoRASyntax": "Copy LoRA Syntax",
|
||||
@@ -137,7 +137,7 @@
|
||||
"exampleImages": {
|
||||
"checkError": "Error checking for example images",
|
||||
"missingHash": "Missing model hash information.",
|
||||
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
|
||||
"noRemoteImagesAvailable": "No remote example images available for this model on CivitAI"
|
||||
},
|
||||
"badges": {
|
||||
"update": "Update",
|
||||
@@ -222,6 +222,7 @@
|
||||
"modelname": "Model Name",
|
||||
"tags": "Tags",
|
||||
"creator": "Creator",
|
||||
"hash": "Hash",
|
||||
"title": "Recipe Title",
|
||||
"loraName": "LoRA Filename",
|
||||
"loraModel": "LoRA Model Name",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "Any",
|
||||
"all": "All",
|
||||
"tagLogicAny": "Match any tag (OR)",
|
||||
"tagLogicAll": "Match all tags (AND)"
|
||||
"tagLogicAll": "Match all tags (AND)",
|
||||
"loraAvailability": "Lora Availability",
|
||||
"availabilityReady": "Ready to use",
|
||||
"availabilityMissing": "Has missing",
|
||||
"availabilityDeleted": "Has deleted"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "Toggle theme",
|
||||
@@ -285,15 +290,15 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"civitaiApiKey": "Civitai API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
|
||||
"civitaiApiKey": "CivitAI API Key",
|
||||
"civitaiApiKeyPlaceholder": "Enter your CivitAI API key",
|
||||
"civitaiApiKeyHelp": "Used for authentication when downloading models from CivitAI",
|
||||
"civitaiApiKeyConfigured": "Configured",
|
||||
"civitaiApiKeyNotConfigured": "Not configured",
|
||||
"civitaiApiKeySet": "Set up",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai host",
|
||||
"help": "Choose which Civitai site opens when using View on Civitai links.",
|
||||
"label": "CivitAI host",
|
||||
"help": "Choose which CivitAI site opens when using View on CivitAI links.",
|
||||
"options": {
|
||||
"com": "civitai.com (SFW)",
|
||||
"red": "civitai.red (unrestricted)"
|
||||
@@ -314,8 +319,8 @@
|
||||
},
|
||||
"aria2HelpLink": "Learn how to set up the aria2 download backend",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai host preference available",
|
||||
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||
"title": "CivitAI host preference available",
|
||||
"content": "CivitAI now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
|
||||
"openSettings": "Open Settings"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
@@ -445,7 +450,7 @@
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Group by Model",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
|
||||
"groupByModelHelp": "When enabled, only the latest version of each CivitAI model is shown as a single card. Older versions are hidden.",
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
@@ -550,7 +555,7 @@
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "Download Path Templates",
|
||||
"help": "Configure folder structures for different model types when downloading from Civitai.",
|
||||
"help": "Configure folder structures for different model types when downloading from CivitAI.",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"templateOptions": {
|
||||
"flatStructure": "Flat Structure",
|
||||
@@ -587,7 +592,7 @@
|
||||
"exampleImages": {
|
||||
"downloadLocation": "Download Location",
|
||||
"downloadLocationPlaceholder": "Enter folder path for example images",
|
||||
"downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved",
|
||||
"downloadLocationHelp": "Enter the folder path where example images from CivitAI will be saved",
|
||||
"autoDownload": "Auto Download Example Images",
|
||||
"autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)",
|
||||
"openMode": "Open Example Images Action",
|
||||
@@ -642,7 +647,7 @@
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "Enable Metadata Archive Database",
|
||||
"enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from Civitai.",
|
||||
"enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from CivitAI.",
|
||||
"status": "Status",
|
||||
"statusAvailable": "Available",
|
||||
"statusUnavailable": "Not Available",
|
||||
@@ -745,7 +750,7 @@
|
||||
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Fetch metadata from Civitai",
|
||||
"title": "Fetch metadata from CivitAI",
|
||||
"action": "Fetch"
|
||||
},
|
||||
"download": {
|
||||
@@ -820,10 +825,10 @@
|
||||
"enrichHfAgent": "Enrich HF Metadata (AI)"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Refresh Civitai Data",
|
||||
"refreshMetadata": "Refresh CivitAI Data",
|
||||
"checkUpdates": "Check Updates",
|
||||
"linkModel": "Link Model",
|
||||
"linkCivitai": "Link to Civitai",
|
||||
"linkCivitai": "Link to CivitAI",
|
||||
"linkHuggingFace": "Link to HuggingFace",
|
||||
"copySyntax": "Copy LoRA Syntax",
|
||||
"copyFilename": "Copy Model Filename",
|
||||
@@ -853,20 +858,130 @@
|
||||
"recipes": {
|
||||
"title": "LoRA Recipes",
|
||||
"actions": {
|
||||
"sendCheckpoint": "Send to ComfyUI"
|
||||
"sendCheckpoint": "Send to ComfyUI",
|
||||
"sendRecipe": "Send to ComfyUI",
|
||||
"copyRecipeSyntax": "Copy Recipe Syntax",
|
||||
"deleteRecipeWithShortcut": "Delete recipe (Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "Recipe navigation",
|
||||
"previousWithShortcut": "Previous recipe (←)",
|
||||
"nextWithShortcut": "Next recipe (→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "Open File Location",
|
||||
"copyId": "Copy recipe ID"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
"failed": "Failed to open file location",
|
||||
"copied": "Path copied to clipboard: {{path}}",
|
||||
"clipboardFallback": "Path: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "Send Workflow to ComfyUI",
|
||||
"sent": "Workflow sent to ComfyUI",
|
||||
"sendFailed": "Failed to send workflow to ComfyUI",
|
||||
"noWorkflow": "No embedded workflow found in this recipe"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Ready to use",
|
||||
"missingCount": "{count} missing",
|
||||
"deletedCount": "{count} deleted",
|
||||
"downloadMissing": "Download {count} missing LoRAs",
|
||||
"downloadMissingTooltip": "Click to download missing LoRAs"
|
||||
},
|
||||
"loraStatus": {
|
||||
"none": "No LoRAs in this recipe",
|
||||
"allAvailable": "All LoRAs available - Ready to use",
|
||||
"missing": "{missing} of {total} LoRAs missing",
|
||||
"missingAndUnavailable": "{missing} of {total} LoRAs missing, {unavailable} unavailable (deleted from source or unresolvable hash)",
|
||||
"partial": "{unavailable} of {total} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used",
|
||||
"noneUsable": "No usable LoRAs - {unavailable} of {total} deleted from source or unresolvable hash"
|
||||
},
|
||||
"resources": {
|
||||
"inLibrary": "In Library",
|
||||
"notInLibrary": "Not in Library",
|
||||
"deleted": "Deleted",
|
||||
"hashInvalid": "Unresolvable Hash",
|
||||
"inLibraryTooltip": "This model exists in your local library",
|
||||
"notInLibraryTooltip": "This model is not in your library",
|
||||
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download",
|
||||
"hashInvalidTooltip": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"noLorasAssociated": "No LoRAs associated with this recipe",
|
||||
"noLorasWhyToggle": "Why no LoRAs?",
|
||||
"noLorasImportMethod": "Import method",
|
||||
"noLorasInferredNote": "Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "Batch import (image URL)",
|
||||
"batch_import_local": "Batch import (local file)",
|
||||
"url": "Image URL import",
|
||||
"local": "Local file import",
|
||||
"upload": "Image upload",
|
||||
"widget": "Saved from workflow",
|
||||
"reimport_url": "Re-import (image URL)",
|
||||
"reimport_local": "Re-import (local file)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "The generation metadata is complete and does not reference any LoRAs.",
|
||||
"api_meta_no_lora_resources": "The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.",
|
||||
"api_meta_missing": "The source API returned no generation metadata for this image.",
|
||||
"no_embedded_metadata": "The image has no embedded generation metadata, so LoRA information could not be recovered.",
|
||||
"workflow_metadata_limited": "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
|
||||
"video_no_metadata": "Video files do not carry embedded generation metadata.",
|
||||
"metadata_unsupported": "The image contains metadata in a format that could not be parsed.",
|
||||
"unknown": "The reason could not be determined from the stored recipe data."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API metadata fields",
|
||||
"modelVersionIds": "Model version IDs reported",
|
||||
"embeddedMetadata": "Embedded metadata",
|
||||
"present": "found",
|
||||
"absent": "none"
|
||||
},
|
||||
"download": "Download",
|
||||
"downloadLoraTooltip": "Download this LoRA",
|
||||
"preparingDownload": "Preparing download...",
|
||||
"reconnect": "Reconnect",
|
||||
"reconnectTooltip": "Reconnect with a local LoRA",
|
||||
"reconnectInstructions": "Enter LoRA syntax or name to reconnect:",
|
||||
"reconnectExample": "Example: <lora:name:1> or just the name",
|
||||
"reconnectPlaceholder": "Enter LoRA name or syntax",
|
||||
"reconnectSuggestionsLoading": "Searching local library...",
|
||||
"reconnectSuggestionsEmpty": "No matching LoRAs in your local library",
|
||||
"reconnectMatchSameHash": "Same hash",
|
||||
"reconnectMatchSameVersion": "Same model version",
|
||||
"reconnectMatchSimilarFilename": "Similar filename",
|
||||
"reconnectMatchSimilarName": "Similar name",
|
||||
"undoReconnect": "Undo",
|
||||
"undoReconnectTooltip": "Restore the association this entry had before reconnecting",
|
||||
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
|
||||
"viewOnCivitai": "View on CivitAI",
|
||||
"openLoraDetails": "View {name} in the LoRA library",
|
||||
"openCheckpointDetails": "View {name} in the model library",
|
||||
"checkpointDeletedTooltip": "This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model",
|
||||
"checkpointHashInvalidTooltip": "This checkpoint hash cannot be resolved on CivitAI - the model may have been updated",
|
||||
"reconnectCheckpoint": "Reconnect",
|
||||
"reconnectCheckpointTooltip": "Reconnect with a local checkpoint",
|
||||
"checkpointReconnectInstructions": "Enter checkpoint name to reconnect:",
|
||||
"checkpointReconnectPlaceholder": "Enter checkpoint name",
|
||||
"checkpointReconnectSuggestionsEmpty": "No matching checkpoints in your local library"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "Import",
|
||||
"title": "Import a recipe from image or URL",
|
||||
"urlLocalPath": "URL / Local Path",
|
||||
"uploadImage": "Upload Image",
|
||||
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
|
||||
"dropZoneLabel": "Upload image",
|
||||
"dropZoneHint": "Drag & drop an image here, paste from clipboard, or click to browse",
|
||||
"orDivider": "or drag & drop / paste an image",
|
||||
"imageUrlOrPath": "Image URL or File Path:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
|
||||
"fetchImage": "Fetch Image",
|
||||
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
|
||||
"selectImage": "Select Image",
|
||||
"recipeName": "Recipe Name",
|
||||
"recipeNamePlaceholder": "Enter recipe name",
|
||||
"tagsOptional": "Tags (optional)",
|
||||
@@ -894,7 +1009,7 @@
|
||||
"downloadingLoras": "Downloading LoRAs...",
|
||||
"savingRecipe": "Saving recipe...",
|
||||
"startingDownload": "Starting download for LoRA {current}/{total}",
|
||||
"deletedFromCivitai": "Deleted from Civitai",
|
||||
"deletedFromCivitai": "Deleted from CivitAI",
|
||||
"inLibrary": "In Library",
|
||||
"notInLibrary": "Not in Library",
|
||||
"earlyAccessRequired": "This LoRA requires early access payment to download.",
|
||||
@@ -911,6 +1026,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "Please select an image file",
|
||||
"enterUrlOrPath": "Please enter a URL or file path",
|
||||
"invalidUrl": "Please enter a valid URL",
|
||||
"invalidInputFormat": "Please enter an image URL or a local image file path",
|
||||
"selectLoraRoot": "Please select a LoRA root directory"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +1062,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",
|
||||
@@ -1014,6 +1132,8 @@
|
||||
"start": "Start Import",
|
||||
"startImport": "Start Import",
|
||||
"importing": "Importing...",
|
||||
"rateLimitedSlowdown": "Rate limited — slowing down...",
|
||||
"rateLimitedHint": "Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
|
||||
"progress": "Progress",
|
||||
"total": "Total",
|
||||
"success": "Success",
|
||||
@@ -1217,7 +1337,7 @@
|
||||
"download": {
|
||||
"title": "Download Model from URL",
|
||||
"titleWithType": "Download {type} from URL",
|
||||
"civitaiUrl": "Civitai URL(s):",
|
||||
"civitaiUrl": "CivitAI URL(s):",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
|
||||
"selectHfFiles": "Select file(s) to download from this repository:",
|
||||
@@ -1243,14 +1363,16 @@
|
||||
"downloaded": "Downloaded",
|
||||
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
|
||||
"alreadyInLibrary": "Already in Library",
|
||||
"partiallyDownloaded": "Partially downloaded",
|
||||
"autoOrganizedPath": "[Auto-organized by path template]",
|
||||
"fileSelection": {
|
||||
"title": "Select File Format",
|
||||
"files": "files",
|
||||
"select": "Select File"
|
||||
"select": "Select File",
|
||||
"inLibrary": "In Library"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Invalid Civitai URL format",
|
||||
"invalidUrl": "Invalid CivitAI URL format",
|
||||
"noVersions": "No versions available for this model",
|
||||
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
|
||||
"noModelFiles": "No model files found in this repository."
|
||||
@@ -1364,7 +1486,7 @@
|
||||
"title": "Local Example Images",
|
||||
"message": "No local example images found for this model. View options:",
|
||||
"downloadOption": {
|
||||
"title": "Download from Civitai",
|
||||
"title": "Download from CivitAI",
|
||||
"description": "Save remote examples locally for offline use and faster loading"
|
||||
},
|
||||
"importOption": {
|
||||
@@ -1391,7 +1513,7 @@
|
||||
"confirmAction": "Save & Link"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Re-link to Civitai",
|
||||
"title": "Re-link to CivitAI",
|
||||
"warning": "Warning:",
|
||||
"warningText": "This is a potentially destructive operation. Re-linking will:",
|
||||
"warningList": {
|
||||
@@ -1400,14 +1522,15 @@
|
||||
"unintendedConsequences": "May have other unintended consequences"
|
||||
},
|
||||
"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",
|
||||
"urlLabel": "CivitAI Model URL:",
|
||||
"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 (CivArchive)"
|
||||
},
|
||||
"confirmAction": "Confirm Re-link"
|
||||
},
|
||||
@@ -1417,14 +1540,16 @@
|
||||
"editFileName": "Edit file name",
|
||||
"editBaseModel": "Edit base model",
|
||||
"editVersionName": "Edit version name",
|
||||
"viewOnCivitai": "View on Civitai",
|
||||
"viewOnCivitaiText": "View on Civitai",
|
||||
"viewOnCivitai": "View on CivitAI",
|
||||
"viewOnCivitaiText": "View on CivitAI",
|
||||
"viewOnHuggingFace": "View on Hugging Face",
|
||||
"viewOnHuggingFaceText": "View on Hugging Face",
|
||||
"viewCreatorProfile": "View Creator Profile",
|
||||
"openFileLocation": "Open File Location",
|
||||
"sendToWorkflow": "Send to ComfyUI",
|
||||
"sendToWorkflowText": "Send to ComfyUI"
|
||||
"sendToWorkflowText": "Send to ComfyUI",
|
||||
"copyHash": "Copy hash",
|
||||
"deleteModelWithShortcut": "Delete model (Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "File location opened successfully",
|
||||
@@ -1441,6 +1566,7 @@
|
||||
"location": "Location",
|
||||
"baseModel": "Base Model",
|
||||
"size": "Size",
|
||||
"hashes": "Hashes",
|
||||
"unknown": "Unknown",
|
||||
"usageTips": "Usage Tips",
|
||||
"additionalNotes": "Additional Notes",
|
||||
@@ -1517,7 +1643,7 @@
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRentCivit": "No CivitAI generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"creditRequired": "Creator credit required",
|
||||
@@ -1532,6 +1658,30 @@
|
||||
"examples": "Loading examples...",
|
||||
"versions": "Loading versions..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} hidden by SFW-only setting",
|
||||
"showExamples": "Show examples",
|
||||
"showCount": "Show examples ({count})",
|
||||
"hideExamples": "Hide examples",
|
||||
"addExamples": "Add examples",
|
||||
"previousExample": "Previous example ([)",
|
||||
"nextExample": "Next example (])",
|
||||
"noExamples": "No example images available",
|
||||
"addMoreExamples": "Add more examples",
|
||||
"dragDrop": "Drag & drop images or videos here",
|
||||
"or": "or",
|
||||
"selectFiles": "Select Files",
|
||||
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "Importing files...",
|
||||
"noSupportedFiles": "No supported files selected. Please select image or video files.",
|
||||
"allFiltered": "All example images are filtered due to NSFW content settings",
|
||||
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
|
||||
"changeInSettings": "You can change this in Settings",
|
||||
"nsfwMature": "Mature Content",
|
||||
"nsfwR": "R-rated Content",
|
||||
"nsfwX": "X-rated Content",
|
||||
"nsfwXxx": "XXX-rated Content"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "Model versions",
|
||||
"copy": "Track and manage every version of this model in one place.",
|
||||
@@ -1558,27 +1708,28 @@
|
||||
"newer": "Newer Version",
|
||||
"newerTooltip": "This version is newer than your latest local version",
|
||||
"earlyAccess": "Early Access",
|
||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||
"earlyAccessTooltip": "This version currently requires CivitAI early access",
|
||||
"paid": "Paid",
|
||||
"paidTooltip": "This version requires payment to download",
|
||||
"ignored": "Ignored",
|
||||
"ignoredTooltip": "Update notifications are disabled for this version",
|
||||
"onSiteOnly": "On-Site Only",
|
||||
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
|
||||
"onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI"
|
||||
},
|
||||
"actions": {
|
||||
"download": "Download",
|
||||
"downloadTooltip": "Download this version",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
||||
"downloadPaidTooltip": "Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||
"downloadChooseFilesTooltip": "Choose which files to download",
|
||||
"downloadEarlyAccessTooltip": "Download this early access version from CivitAI",
|
||||
"downloadPaidTooltip": "Download this paid version from CivitAI",
|
||||
"downloadNotAllowedTooltip": "This version is only available for on-site generation on CivitAI",
|
||||
"delete": "Delete",
|
||||
"deleteTooltip": "Delete this local version",
|
||||
"ignore": "Ignore",
|
||||
"unignore": "Unignore",
|
||||
"ignoreTooltip": "Ignore update notifications for this version",
|
||||
"unignoreTooltip": "Resume update notifications for this version",
|
||||
"viewVersionOnCivitai": "View version on Civitai",
|
||||
"viewVersionOnCivitai": "View version on CivitAI",
|
||||
"earlyAccessTooltip": "Requires early access purchase",
|
||||
"resumeModelUpdates": "Resume updates for this model",
|
||||
"ignoreModelUpdates": "Ignore updates for this model",
|
||||
@@ -1599,7 +1750,7 @@
|
||||
},
|
||||
"empty": "No version history available for this model yet.",
|
||||
"error": "Failed to load versions.",
|
||||
"missingModelId": "This model is missing a Civitai model id.",
|
||||
"missingModelId": "This model is missing a CivitAI model id.",
|
||||
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
|
||||
"confirm": {
|
||||
"delete": "Delete this version from your library?"
|
||||
@@ -1683,14 +1834,14 @@
|
||||
"tips": {
|
||||
"title": "Tips & Tricks",
|
||||
"civitai": {
|
||||
"title": "Civitai Integration",
|
||||
"description": "Connect your Civitai account: Visit Profile Avatar → Settings → API Keys → Add API Key, then paste it in Lora Manager settings.",
|
||||
"alt": "Civitai API Setup"
|
||||
"title": "CivitAI Integration",
|
||||
"description": "Connect your CivitAI account: Visit Profile Avatar → Settings → API Keys → Add API Key, then paste it in Lora Manager settings.",
|
||||
"alt": "CivitAI API Setup"
|
||||
},
|
||||
"download": {
|
||||
"title": "Easy Download",
|
||||
"description": "Use Civitai URLs to quickly download and install new models.",
|
||||
"alt": "Civitai Download"
|
||||
"description": "Use CivitAI URLs to quickly download and install new models.",
|
||||
"alt": "CivitAI Download"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Save Recipes",
|
||||
@@ -1874,7 +2025,7 @@
|
||||
"submitGithubIssue": "Submit GitHub Issue",
|
||||
"joinDiscord": "Join Discord",
|
||||
"youtubeChannel": "YouTube Channel",
|
||||
"civitaiProfile": "Civitai Profile",
|
||||
"civitaiProfile": "CivitAI Profile",
|
||||
"supportKofi": "Support on Ko-fi",
|
||||
"supportPatreon": "Support on Patreon"
|
||||
},
|
||||
@@ -1917,6 +2068,7 @@
|
||||
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
|
||||
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
|
||||
"pleaseSelectVersion": "Please select a version",
|
||||
"pleaseSelectFile": "Please select at least one file",
|
||||
"versionExists": "This version already exists in your library",
|
||||
"downloadCompleted": "Download completed successfully",
|
||||
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
|
||||
@@ -1950,11 +2102,16 @@
|
||||
"createMissingData": "Missing required data to create recipe",
|
||||
"created": "Recipe created successfully",
|
||||
"noMissingLoras": "No missing LoRAs to download",
|
||||
"noPreviousRecipe": "No previous recipe available",
|
||||
"noNextRecipe": "No next recipe available",
|
||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||
"preparingForDownloadFailed": "Error preparing LoRAs for download",
|
||||
"enterLoraName": "Please enter a LoRA name or syntax",
|
||||
"reconnectedSuccessfully": "LoRA reconnected successfully",
|
||||
"reconnectBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, LoRA: {lora}) — they are architecture-compatible",
|
||||
"reconnectFailed": "Error reconnecting LoRA: {message}",
|
||||
"loraRestored": "LoRA restored to its previous association",
|
||||
"loraRestoreFailed": "Error restoring LoRA: {message}",
|
||||
"noPromptToSend": "No prompt to send",
|
||||
"cannotSend": "Cannot send recipe: Missing recipe ID",
|
||||
"sendFailed": "Failed to send recipe to workflow",
|
||||
@@ -1962,6 +2119,16 @@
|
||||
"missingCheckpointPath": "Checkpoint path not available",
|
||||
"missingCheckpointInfo": "Missing checkpoint information",
|
||||
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
|
||||
"enterCheckpointName": "Please enter a checkpoint name",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint reconnected successfully",
|
||||
"reconnectCheckpointBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, checkpoint: {checkpoint}) — they are architecture-compatible",
|
||||
"checkpointReconnectFailed": "Error reconnecting checkpoint: {message}",
|
||||
"checkpointRestored": "Checkpoint restored to its previous association",
|
||||
"checkpointRestoreFailed": "Error restoring checkpoint: {message}",
|
||||
"checkpointDownloadUnavailable": "This checkpoint cannot be downloaded without CivitAI identifiers - try reconnecting it with a local checkpoint",
|
||||
"missingLoraDownloadInfo": "Missing download information for this LoRA",
|
||||
"hashNotFoundOnCivitai": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated or the hash is invalid",
|
||||
"downloadLoraFailed": "Failed to download LoRA: {message}",
|
||||
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
|
||||
"deleteConfirmationError": "Error showing delete confirmation",
|
||||
"deletedSuccessfully": "Recipe deleted successfully",
|
||||
@@ -1985,6 +2152,7 @@
|
||||
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportRateLimited": "Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No recipes selected",
|
||||
@@ -2002,7 +2170,10 @@
|
||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||
"reimportBulkFailed": "Failed to re-import some recipes",
|
||||
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
|
||||
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings.",
|
||||
"workflowSent": "Workflow sent to ComfyUI",
|
||||
"workflowSendFailed": "Failed to send workflow to ComfyUI: {error}",
|
||||
"workflowNoWorkflow": "No embedded workflow found in this recipe"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "No models selected",
|
||||
@@ -2039,8 +2210,8 @@
|
||||
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
|
||||
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
|
||||
"bulkUpdatesNone": "No updates found for selected {type}(s)",
|
||||
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
|
||||
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
|
||||
"bulkUpdatesMissing": "Selected {type}(s) are not linked to CivitAI updates",
|
||||
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without CivitAI links",
|
||||
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
|
||||
"invalidCharactersRemoved": "Invalid characters removed from filename",
|
||||
"filenameCannotBeEmpty": "File name cannot be empty",
|
||||
@@ -2166,10 +2337,11 @@
|
||||
"contextMenu": {
|
||||
"contentRatingSet": "Content rating set to {level}",
|
||||
"contentRatingFailed": "Failed to set content rating: {message}",
|
||||
"relinkSuccess": "Model successfully re-linked to Civitai",
|
||||
"relinkSuccess": "Model successfully re-linked to CivitAI",
|
||||
"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"
|
||||
@@ -2259,7 +2431,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API Key"
|
||||
"title": "CivitAI API Key"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "Model Cache Health"
|
||||
@@ -2313,9 +2485,9 @@
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM CivitAI Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
"learnMore": "LM CivitAI Extension Tutorial"
|
||||
},
|
||||
"cacheHealth": {
|
||||
"corrupted": {
|
||||
@@ -2332,4 +2504,4 @@
|
||||
"retry": "Retry"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+382
-210
File diff suppressed because it is too large
Load Diff
+403
-231
File diff suppressed because it is too large
Load Diff
+410
-238
File diff suppressed because it is too large
Load Diff
+339
-167
@@ -67,11 +67,11 @@
|
||||
"steps": {
|
||||
"fetch": {
|
||||
"title": "モデルメタデータの取得",
|
||||
"content": "<strong>取得</strong>ボタンをクリックして、Civitaiからモデルのメタデータとプレビュー画像をダウンロードします。"
|
||||
"content": "<strong>取得</strong>ボタンをクリックして、CivitAIからモデルのメタデータとプレビュー画像をダウンロードします。"
|
||||
},
|
||||
"download": {
|
||||
"title": "新しいモデルのダウンロード",
|
||||
"content": "<strong>ダウンロード</strong>ボタンを使って、CivitaiのURLから直接モデルをダウンロードできます。"
|
||||
"content": "<strong>ダウンロード</strong>ボタンを使って、CivitAIのURLから直接モデルをダウンロードできます。"
|
||||
},
|
||||
"bulk": {
|
||||
"title": "一括操作",
|
||||
@@ -103,17 +103,17 @@
|
||||
"actions": {
|
||||
"addToFavorites": "お気に入りに追加",
|
||||
"removeFromFavorites": "お気に入りから削除",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"notAvailableFromCivitai": "Civitaiでは利用できません",
|
||||
"viewOnCivitai": "CivitAIで表示",
|
||||
"notAvailableFromCivitai": "CivitAIでは利用できません",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
|
||||
"copyLoRASyntax": "LoRA構文をコピー",
|
||||
"checkpointNameCopied": "checkpointの名前をコピーしました",
|
||||
"checkpointNameCopied": "Checkpointの名前をコピーしました",
|
||||
"toggleBlur": "ぼかしの切り替え",
|
||||
"show": "表示",
|
||||
"openExampleImages": "例画像フォルダを開く",
|
||||
"replacePreview": "プレビューを置換",
|
||||
"copyCheckpointName": "checkpoint名をコピー",
|
||||
"copyCheckpointName": "Checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
@@ -131,13 +131,13 @@
|
||||
"updateFailed": "お気に入り状態の更新に失敗しました"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
|
||||
"checkpointNotImplemented": "Checkpointをワークフローに送信 - 実装予定の機能",
|
||||
"missingPath": "このカードのモデルパスを特定できません"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "例画像の確認中にエラーが発生しました",
|
||||
"missingHash": "モデルハッシュ情報がありません。",
|
||||
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
|
||||
"noRemoteImagesAvailable": "このモデルのCivitAIでのリモート例画像は利用できません"
|
||||
},
|
||||
"badges": {
|
||||
"update": "アップデート",
|
||||
@@ -160,7 +160,7 @@
|
||||
},
|
||||
"checkModelUpdates": {
|
||||
"label": "アップデートを確認",
|
||||
"loading": "{type} のアップデートを確認中…",
|
||||
"loading": "{type} のアップデートを確認中...",
|
||||
"success": "{type} のアップデートが {count} 件見つかりました",
|
||||
"none": "すべての {type} は最新です",
|
||||
"error": "{type} のアップデート確認に失敗しました: {message}"
|
||||
@@ -173,17 +173,17 @@
|
||||
"error": "例画像フォルダのクリーンアップに失敗しました:{message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
"label": "ライセンスメタデータを更新",
|
||||
"loading": "{typePlural}のライセンスメタデータを更新中...",
|
||||
"success": "{count} 件の{typePlural}のライセンスメタデータを更新しました",
|
||||
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
|
||||
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "レシピデータの修復",
|
||||
"loading": "レシピデータを修復中...",
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}件のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
@@ -222,6 +222,7 @@
|
||||
"modelname": "モデル名",
|
||||
"tags": "タグ",
|
||||
"creator": "作成者",
|
||||
"hash": "ハッシュ",
|
||||
"title": "レシピタイトル",
|
||||
"loraName": "LoRAファイル名",
|
||||
"loraModel": "LoRAモデル名",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "いずれか",
|
||||
"all": "すべて",
|
||||
"tagLogicAny": "いずれかのタグに一致 (OR)",
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)"
|
||||
"tagLogicAll": "すべてのタグに一致 (AND)",
|
||||
"loraAvailability": "LoRA の利用状況",
|
||||
"availabilityReady": "使用可能",
|
||||
"availabilityMissing": "不足 LoRA あり",
|
||||
"availabilityDeleted": "削除済み LoRA あり"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "テーマの切り替え",
|
||||
@@ -285,15 +290,15 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"civitaiApiKey": "Civitai APIキー",
|
||||
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKey": "CivitAI APIキー",
|
||||
"civitaiApiKeyPlaceholder": "CivitAI APIキーを入力してください",
|
||||
"civitaiApiKeyHelp": "CivitAIからモデルをダウンロードするときの認証に使用されます",
|
||||
"civitaiApiKeyConfigured": "設定済み",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai ホスト",
|
||||
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
|
||||
"label": "CivitAI ホスト",
|
||||
"help": "「View on CivitAI」リンクを使うときに開く CivitAI サイトを選択します。",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW のみ)",
|
||||
"red": "civitai.red(制限なし)"
|
||||
@@ -314,8 +319,8 @@
|
||||
},
|
||||
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai ホスト設定を利用できます",
|
||||
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
"title": "CivitAI ホスト設定を利用できます",
|
||||
"content": "CivitAI は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
|
||||
"openSettings": "設定を開く"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
@@ -423,7 +428,7 @@
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "ベースモデルのダウンロードをスキップ",
|
||||
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
|
||||
"help": "有効にすると、選択したベースモデルを使用するバージョンはスキップされます。",
|
||||
"searchPlaceholder": "ベースモデルを絞り込む...",
|
||||
"empty": "現在の検索に一致するベースモデルはありません。",
|
||||
"summary": {
|
||||
@@ -445,7 +450,7 @@
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "デフォルト",
|
||||
@@ -498,7 +503,7 @@
|
||||
"defaultLoraRoot": "LoRAルート",
|
||||
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
|
||||
"defaultCheckpointRoot": "Checkpointルート",
|
||||
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
|
||||
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトCheckpointルートディレクトリを設定",
|
||||
"defaultUnetRoot": "Diffusion Modelルート",
|
||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||
"defaultEmbeddingRoot": "Embeddingルート",
|
||||
@@ -512,7 +517,7 @@
|
||||
"extraFolderPaths": {
|
||||
"title": "追加フォルダーパス",
|
||||
"description": "LoRA Manager専用の追加モデルルートパス。ComfyUIの標準フォルダー外の場所からモデルを読み込みます。ComfyUIの動作を低下させる可能性のある大規模ライブラリに最適です。",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"restartRequired": "変更を有効にするには再起動が必要です",
|
||||
"modelTypes": {
|
||||
"lora": "LoRAパス",
|
||||
"checkpoint": "Checkpointパス",
|
||||
@@ -524,8 +529,8 @@
|
||||
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
|
||||
"validation": {
|
||||
"duplicatePath": "このパスはすでに設定されています",
|
||||
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||
"checkpointUnetOverlap": "Checkpoints と diffusion models に同じパスは使用できません:{paths}",
|
||||
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
|
||||
}
|
||||
},
|
||||
"priorityTags": {
|
||||
@@ -535,7 +540,7 @@
|
||||
"helpLinkLabel": "優先タグのヘルプを開く",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "チェックポイント",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "埋め込み"
|
||||
},
|
||||
"saveSuccess": "優先タグを更新しました。",
|
||||
@@ -550,7 +555,7 @@
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "ダウンロードパステンプレート",
|
||||
"help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
|
||||
"help": "CivitAIからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
|
||||
"availablePlaceholders": "利用可能なプレースホルダー:",
|
||||
"templateOptions": {
|
||||
"flatStructure": "フラット構造",
|
||||
@@ -587,7 +592,7 @@
|
||||
"exampleImages": {
|
||||
"downloadLocation": "ダウンロード場所",
|
||||
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
||||
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
|
||||
"downloadLocationHelp": "CivitAIからの例画像を保存するフォルダパスを入力してください",
|
||||
"autoDownload": "例画像の自動ダウンロード",
|
||||
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
|
||||
"openMode": "サンプル画像を開く動作",
|
||||
@@ -620,11 +625,11 @@
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "早期アクセス更新を非表示",
|
||||
"help": "早期アクセスのみの更新"
|
||||
"help": "有効にすると、早期アクセス更新のみのモデルには「更新あり」バッジが表示されません。"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "有料更新を非表示",
|
||||
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||
@@ -642,7 +647,7 @@
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
|
||||
"enableArchiveDbHelp": "Civitaiから削除されたモデルのメタデータにアクセスするためにローカルデータベースを使用します。",
|
||||
"enableArchiveDbHelp": "CivitAIから削除されたモデルのメタデータにアクセスするためにローカルデータベースを使用します。",
|
||||
"status": "ステータス",
|
||||
"statusAvailable": "利用可能",
|
||||
"statusUnavailable": "利用不可",
|
||||
@@ -703,7 +708,7 @@
|
||||
"custom": "カスタム(OpenAI 互換)"
|
||||
},
|
||||
"apiBase": "APIベースURL",
|
||||
"apiBaseHelp": "LLM APIのベースURL(例:https://api.openai.com/v1)。空の場合はプロバイダーのデフォルトが使用されます。",
|
||||
"apiBaseHelp": "LLM APIのベースURL。プリセットを選択するか、カスタムURLを入力してください。ドロップダウンには対応しているすべてのプロバイダーのプリセットが表示されます。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "APIキー",
|
||||
"apiKeyHelp": "LLMプロバイダーのAPIキー。ローカルに保存され、選択したLLMプロバイダー以外のサーバーに送信されることはありません。",
|
||||
@@ -745,7 +750,7 @@
|
||||
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitaiからメタデータを取得",
|
||||
"title": "CivitAIからメタデータを取得",
|
||||
"action": "取得"
|
||||
},
|
||||
"download": {
|
||||
@@ -820,10 +825,10 @@
|
||||
"enrichHfAgent": "HF メタデータをAIで補完"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitaiデータを更新",
|
||||
"refreshMetadata": "CivitAIデータを更新",
|
||||
"checkUpdates": "更新確認",
|
||||
"linkModel": "モデルをリンク",
|
||||
"linkCivitai": "Civitai にリンク",
|
||||
"linkCivitai": "CivitAI にリンク",
|
||||
"linkHuggingFace": "HuggingFace にリンク",
|
||||
"copySyntax": "LoRA構文をコピー",
|
||||
"copyFilename": "モデルファイル名をコピー",
|
||||
@@ -853,20 +858,130 @@
|
||||
"recipes": {
|
||||
"title": "LoRAレシピ",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUIへ送信"
|
||||
"sendCheckpoint": "ComfyUIへ送信",
|
||||
"sendRecipe": "ComfyUIへ送信",
|
||||
"copyRecipeSyntax": "レシピ構文をコピー",
|
||||
"deleteRecipeWithShortcut": "レシピを削除(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "レシピナビゲーション",
|
||||
"previousWithShortcut": "前のレシピ(←)",
|
||||
"nextWithShortcut": "次のレシピ(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"copyId": "レシピIDをコピー"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
"failed": "ファイルの場所を開くのに失敗しました",
|
||||
"copied": "パスをクリップボードにコピーしました: {{path}}",
|
||||
"clipboardFallback": "パス: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "ワークフローをComfyUIへ送信",
|
||||
"sent": "ワークフローをComfyUIへ送信しました",
|
||||
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
|
||||
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
},
|
||||
"status": {
|
||||
"ready": "使用可能",
|
||||
"missingCount": "{count} 件不足",
|
||||
"deletedCount": "{count} 件削除済み",
|
||||
"downloadMissing": "不足している {count} 件のLoRAをダウンロード",
|
||||
"downloadMissingTooltip": "クリックして不足しているLoRAをダウンロード"
|
||||
},
|
||||
"loraStatus": {
|
||||
"none": "このレシピにはLoRAがありません",
|
||||
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
|
||||
"missing": "{total} 件中 {missing} 件のLoRAが不足",
|
||||
"missingAndUnavailable": "{total} 件中 {missing} 件のLoRAが不足、{unavailable} 件は利用不可(ソースから削除済みかハッシュを解決できません)",
|
||||
"partial": "{total} 件中 {unavailable} 件のLoRAが利用不可(ソースから削除済みかハッシュを解決できません)- レシピ使用時はスキップされます",
|
||||
"noneUsable": "使用可能なLoRAがありません - {total} 件中 {unavailable} 件がソースから削除済みかハッシュを解決できません"
|
||||
},
|
||||
"resources": {
|
||||
"inLibrary": "ライブラリ内",
|
||||
"notInLibrary": "ライブラリ外",
|
||||
"deleted": "削除済み",
|
||||
"hashInvalid": "解決不能なハッシュ",
|
||||
"inLibraryTooltip": "このモデルはローカルライブラリに存在します",
|
||||
"notInLibraryTooltip": "このモデルはライブラリにありません",
|
||||
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
|
||||
"hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります",
|
||||
"noLorasAssociated": "このレシピに関連付けられた LoRA はありません",
|
||||
"noLorasWhyToggle": "LoRA がない理由",
|
||||
"noLorasImportMethod": "インポート方法",
|
||||
"noLorasInferredNote": "考えられる理由(推定)— このレシピはインポート診断が記録される前にインポートされました。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "一括インポート(画像 URL)",
|
||||
"batch_import_local": "一括インポート(ローカルファイル)",
|
||||
"url": "画像 URL からのインポート",
|
||||
"local": "ローカルファイルのインポート",
|
||||
"upload": "画像のアップロード",
|
||||
"widget": "ワークフローから保存",
|
||||
"reimport_url": "再インポート(画像 URL)",
|
||||
"reimport_local": "再インポート(ローカルファイル)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成メタデータは完全で、LoRA への参照は含まれていません。",
|
||||
"api_meta_no_lora_resources": "ソース API がこの画像の LoRA リソースデータを返しませんでした。CivitAI ページに表示される LoRA は、公開 API では公開されない内部データに由来する場合があります。",
|
||||
"api_meta_missing": "ソース API がこの画像の生成メタデータを返しませんでした。",
|
||||
"no_embedded_metadata": "画像に埋め込まれた生成メタデータがないため、LoRA 情報を復元できませんでした。",
|
||||
"workflow_metadata_limited": "画像に埋め込まれたメタデータは ComfyUI ワークフローです。ワークフローからの LoRA 情報の抽出には限界があります。",
|
||||
"video_no_metadata": "動画ファイルには埋め込み生成メタデータがありません。",
|
||||
"metadata_unsupported": "画像に解析できない形式のメタデータが含まれています。",
|
||||
"unknown": "保存されたレシピデータから理由を特定できませんでした。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API メタデータフィールド",
|
||||
"modelVersionIds": "報告されたモデルバージョン ID 数",
|
||||
"embeddedMetadata": "埋め込みメタデータ",
|
||||
"present": "あり",
|
||||
"absent": "なし"
|
||||
},
|
||||
"download": "ダウンロード",
|
||||
"downloadLoraTooltip": "この LoRA をダウンロード",
|
||||
"preparingDownload": "ダウンロードを準備中...",
|
||||
"reconnect": "再接続",
|
||||
"reconnectTooltip": "ローカルの LoRA と再接続",
|
||||
"reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:",
|
||||
"reconnectExample": "例:<lora:name:1> または名前のみ",
|
||||
"reconnectPlaceholder": "LoRA 名または構文を入力",
|
||||
"reconnectSuggestionsLoading": "ローカルライブラリを検索中...",
|
||||
"reconnectSuggestionsEmpty": "ローカルライブラリに一致するLoRAがありません",
|
||||
"reconnectMatchSameHash": "同じハッシュ",
|
||||
"reconnectMatchSameVersion": "同じモデルバージョン",
|
||||
"reconnectMatchSimilarFilename": "類似のファイル名",
|
||||
"reconnectMatchSimilarName": "類似の名前",
|
||||
"undoReconnect": "元に戻す",
|
||||
"undoReconnectTooltip": "このエントリーを再接続前の関連付けに戻します",
|
||||
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
|
||||
"viewOnCivitai": "CivitAI で表示",
|
||||
"openLoraDetails": "LoRA ライブラリで {name} を表示",
|
||||
"openCheckpointDetails": "モデルライブラリで {name} を表示",
|
||||
"checkpointDeletedTooltip": "この Checkpoint はソースから削除されたため、ダウンロードできません - ローカルモデルで再接続してください",
|
||||
"checkpointHashInvalidTooltip": "この Checkpoint のハッシュは CivitAI で解決できません - モデルが更新された可能性があります",
|
||||
"reconnectCheckpoint": "再接続",
|
||||
"reconnectCheckpointTooltip": "ローカルの Checkpoint と再接続",
|
||||
"checkpointReconnectInstructions": "再接続する Checkpoint の名前を入力してください:",
|
||||
"checkpointReconnectPlaceholder": "Checkpoint 名を入力",
|
||||
"checkpointReconnectSuggestionsEmpty": "ローカルライブラリに一致するCheckpointがありません"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "インポート",
|
||||
"title": "画像またはURLからレシピをインポート",
|
||||
"urlLocalPath": "URL / ローカルパス",
|
||||
"uploadImage": "画像をアップロード",
|
||||
"urlSectionDescription": "Civitai画像URLまたはローカルファイルパスを入力してレシピとしてインポートします。",
|
||||
"dropZoneLabel": "画像をアップロード",
|
||||
"dropZoneHint": "画像をここにドラッグ&ドロップ、クリップボードから貼り付け、またはクリックして参照",
|
||||
"orDivider": "または画像をドラッグ&ドロップ / 貼り付け",
|
||||
"imageUrlOrPath": "画像URLまたはファイルパス:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... または C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... または https://civitai.red/images/... または C:/path/to/image.png",
|
||||
"fetchImage": "画像を取得",
|
||||
"uploadSectionDescription": "LoRAメタデータを含む画像をアップロードしてレシピとしてインポートします。",
|
||||
"selectImage": "画像を選択",
|
||||
"recipeName": "レシピ名",
|
||||
"recipeNamePlaceholder": "レシピ名を入力",
|
||||
"tagsOptional": "タグ(任意)",
|
||||
@@ -894,7 +1009,7 @@
|
||||
"downloadingLoras": "LoRAをダウンロード中...",
|
||||
"savingRecipe": "レシピを保存中...",
|
||||
"startingDownload": "LoRA {current}/{total} のダウンロードを開始",
|
||||
"deletedFromCivitai": "Civitaiから削除済み",
|
||||
"deletedFromCivitai": "CivitAIから削除済み",
|
||||
"inLibrary": "ライブラリ内",
|
||||
"notInLibrary": "ライブラリ外",
|
||||
"earlyAccessRequired": "このLoRAはダウンロードにアーリーアクセス料金が必要です。",
|
||||
@@ -911,6 +1026,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "画像ファイルを選択してください",
|
||||
"enterUrlOrPath": "URLまたはファイルパスを入力してください",
|
||||
"invalidUrl": "有効なURLを入力してください",
|
||||
"invalidInputFormat": "画像のURLまたはローカルの画像ファイルパスを入力してください",
|
||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +1062,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "重複レシピをスキャンしています...",
|
||||
"found": "{count} 個の重複グループが見つかりました",
|
||||
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
|
||||
"keepLatest": "最新バージョンを保持",
|
||||
@@ -993,61 +1111,63 @@
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "Batch Import Recipes",
|
||||
"action": "Batch Import",
|
||||
"urlList": "URL List",
|
||||
"directory": "Directory",
|
||||
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "Image URLs or Local Paths",
|
||||
"title": "レシピを一括インポート",
|
||||
"action": "一括インポート",
|
||||
"urlList": "URLリスト",
|
||||
"directory": "フォルダ",
|
||||
"urlDescription": "画像URLまたはローカルファイルパスを入力してください(1行に1つ)。それぞれがレシピとしてインポートされます。",
|
||||
"directoryDescription": "フォルダパスを入力すると、そのフォルダ内のすべての画像がインポートされます。",
|
||||
"urlsLabel": "画像URLまたはローカルパス",
|
||||
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "Enter one URL or path per line",
|
||||
"directoryPath": "Directory Path",
|
||||
"urlsHint": "1行に1つのURLまたはパスを入力",
|
||||
"directoryPath": "フォルダパス",
|
||||
"directoryPlaceholder": "/path/to/images/folder",
|
||||
"browse": "Browse",
|
||||
"recursive": "Include subdirectories",
|
||||
"tagsOptional": "Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "Enter tags separated by commas",
|
||||
"tagsHint": "Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "Skip images without metadata",
|
||||
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "Start Import",
|
||||
"startImport": "Start Import",
|
||||
"importing": "Importing...",
|
||||
"progress": "Progress",
|
||||
"total": "Total",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped",
|
||||
"current": "Current",
|
||||
"currentItem": "Current",
|
||||
"preparing": "Preparing...",
|
||||
"cancel": "Cancel",
|
||||
"cancelImport": "Cancel",
|
||||
"cancelled": "Import cancelled",
|
||||
"completed": "Import completed",
|
||||
"completedWithErrors": "Completed with errors",
|
||||
"completedSuccess": "Successfully imported {count} recipe(s)",
|
||||
"successCount": "Successful",
|
||||
"failedCount": "Failed",
|
||||
"skippedCount": "Skipped",
|
||||
"totalProcessed": "Total processed",
|
||||
"viewDetails": "View Details",
|
||||
"newImport": "New Import",
|
||||
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "Back to parent directory",
|
||||
"folders": "Folders",
|
||||
"folderCount": "{count} folders",
|
||||
"imageFiles": "Image Files",
|
||||
"images": "images",
|
||||
"imageCount": "{count} images",
|
||||
"selectFolder": "Select This Folder",
|
||||
"browse": "参照",
|
||||
"recursive": "サブフォルダを含める",
|
||||
"tagsOptional": "タグ(任意、すべてのレシピに適用)",
|
||||
"tagsPlaceholder": "タグをカンマ区切りで入力",
|
||||
"tagsHint": "タグはインポートされたすべてのレシピに追加されます",
|
||||
"skipNoMetadata": "メタデータのない画像をスキップ",
|
||||
"skipNoMetadataHelp": "LoRAメタデータのない画像は自動的にスキップされます。",
|
||||
"start": "インポートを開始",
|
||||
"startImport": "インポートを開始",
|
||||
"importing": "インポート中...",
|
||||
"rateLimitedSlowdown": "レート制限中 — 速度を落としています...",
|
||||
"rateLimitedHint": "メタデータプロバイダーのレート制限により一部の項目がスキップされました。後でもう一度インポートを実行して再試行してください。",
|
||||
"progress": "進捗",
|
||||
"total": "合計",
|
||||
"success": "成功",
|
||||
"failed": "失敗",
|
||||
"skipped": "スキップ",
|
||||
"current": "現在",
|
||||
"currentItem": "現在",
|
||||
"preparing": "準備中...",
|
||||
"cancel": "キャンセル",
|
||||
"cancelImport": "キャンセル",
|
||||
"cancelled": "インポートがキャンセルされました",
|
||||
"completed": "インポートが完了しました",
|
||||
"completedWithErrors": "エラーありで完了",
|
||||
"completedSuccess": "{count} 件のレシピを正常にインポートしました",
|
||||
"successCount": "成功",
|
||||
"failedCount": "失敗",
|
||||
"skippedCount": "スキップ",
|
||||
"totalProcessed": "処理済みの合計",
|
||||
"viewDetails": "詳細を見る",
|
||||
"newImport": "新しいインポート",
|
||||
"manualPathEntry": "フォルダパスを手動で入力してください。このブラウザではファイルブラウザは利用できません。",
|
||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||
"batchImportManualEntryRequired": "ファイルブラウザが利用できません。フォルダパスを手動で入力してください。",
|
||||
"backToParent": "親フォルダに戻る",
|
||||
"folders": "フォルダ",
|
||||
"folderCount": "{count} 個のフォルダ",
|
||||
"imageFiles": "画像ファイル",
|
||||
"images": "画像",
|
||||
"imageCount": "{count} 枚の画像",
|
||||
"selectFolder": "このフォルダを選択",
|
||||
"errors": {
|
||||
"enterUrls": "Please enter at least one URL or path",
|
||||
"enterDirectory": "Please enter a directory path",
|
||||
"startFailed": "Failed to start import: {message}"
|
||||
"enterUrls": "URLまたはパスを少なくとも1つ入力してください",
|
||||
"enterDirectory": "フォルダパスを入力してください",
|
||||
"startFailed": "インポートを開始できませんでした: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1217,7 +1337,7 @@
|
||||
"download": {
|
||||
"title": "URLからモデルをダウンロード",
|
||||
"titleWithType": "URLから{type}をダウンロード",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
|
||||
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
|
||||
@@ -1243,14 +1363,16 @@
|
||||
"downloaded": "ダウンロード済み",
|
||||
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
|
||||
"alreadyInLibrary": "既にライブラリ内",
|
||||
"partiallyDownloaded": "一部ダウンロード済み",
|
||||
"autoOrganizedPath": "[パステンプレートによる自動整理]",
|
||||
"fileSelection": {
|
||||
"title": "ファイル形式を選択",
|
||||
"files": "ファイル",
|
||||
"select": "ファイルを選択"
|
||||
"select": "ファイルを選択",
|
||||
"inLibrary": "ライブラリ内"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "無効なCivitai URL形式",
|
||||
"invalidUrl": "無効なCivitAI URL形式",
|
||||
"noVersions": "このモデルの利用可能なバージョンがありません",
|
||||
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
|
||||
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
|
||||
@@ -1329,9 +1451,9 @@
|
||||
"action": "すべて削除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "すべての{type}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"title": "すべての{typePlural}の更新を確認しますか?",
|
||||
"message": "ライブラリ内のすべての{typePlural}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
|
||||
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
|
||||
"action": "すべて確認"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
@@ -1364,7 +1486,7 @@
|
||||
"title": "ローカル例画像",
|
||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||
"downloadOption": {
|
||||
"title": "Civitaiからダウンロード",
|
||||
"title": "CivitAIからダウンロード",
|
||||
"description": "リモート例画像をローカルに保存して、オフライン使用と高速読み込みを可能にします"
|
||||
},
|
||||
"importOption": {
|
||||
@@ -1391,7 +1513,7 @@
|
||||
"confirmAction": "保存&リンク"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitaiに再リンク",
|
||||
"title": "CivitAIに再リンク",
|
||||
"warning": "警告:",
|
||||
"warningText": "これは破壊的な操作になる可能性があります。再リンクは以下を行います:",
|
||||
"warningList": {
|
||||
@@ -1400,14 +1522,15 @@
|
||||
"unintendedConsequences": "その他の意図しない結果を引き起こす可能性"
|
||||
},
|
||||
"proceedText": "これが本当に必要な場合のみ続行してください。",
|
||||
"urlLabel": "CivitaiモデルURL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlLabel": "CivitAIモデルURL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 または https://civitai.red/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 (CivArchive)"
|
||||
},
|
||||
"confirmAction": "再リンクを確認"
|
||||
},
|
||||
@@ -1417,14 +1540,16 @@
|
||||
"editFileName": "ファイル名を編集",
|
||||
"editBaseModel": "ベースモデルを編集",
|
||||
"editVersionName": "バージョン名を編集",
|
||||
"viewOnCivitai": "Civitaiで表示",
|
||||
"viewOnCivitaiText": "Civitaiで表示",
|
||||
"viewOnCivitai": "CivitAIで表示",
|
||||
"viewOnCivitaiText": "CivitAIで表示",
|
||||
"viewOnHuggingFace": "Hugging Face で見る",
|
||||
"viewOnHuggingFaceText": "Hugging Face で見る",
|
||||
"viewCreatorProfile": "作成者プロフィールを表示",
|
||||
"openFileLocation": "ファイルの場所を開く",
|
||||
"sendToWorkflow": "ComfyUI に送信",
|
||||
"sendToWorkflowText": "ComfyUI に送信"
|
||||
"sendToWorkflowText": "ComfyUI に送信",
|
||||
"copyHash": "ハッシュをコピー",
|
||||
"deleteModelWithShortcut": "モデルを削除(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "ファイルの場所を正常に開きました",
|
||||
@@ -1441,13 +1566,14 @@
|
||||
"location": "場所",
|
||||
"baseModel": "ベースモデル",
|
||||
"size": "サイズ",
|
||||
"hashes": "ハッシュ",
|
||||
"unknown": "不明",
|
||||
"usageTips": "使用のヒント",
|
||||
"additionalNotes": "追加メモ",
|
||||
"notesHint": "Enterで保存、Shift+Enterで改行",
|
||||
"addNotesPlaceholder": "メモをここに追加...",
|
||||
"aboutThisVersion": "このバージョンについて",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索…",
|
||||
"baseModelSearchPlaceholder": "ベースモデルを検索...",
|
||||
"baseModelSuggested": "おすすめ",
|
||||
"baseModelNoMatch": "該当するベースモデルがありません"
|
||||
},
|
||||
@@ -1516,10 +1642,10 @@
|
||||
"noNext": "次のモデルがありません"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"noImageSell": "生成画像の販売禁止",
|
||||
"noRentCivit": "CivitAIでの生成不可",
|
||||
"noRent": "生成サービス不可",
|
||||
"noSell": "モデルの販売禁止",
|
||||
"creditRequired": "作成者のクレジットが必要",
|
||||
"noDerivatives": "共有マージ不可",
|
||||
"noReLicense": "同じ権限が必要",
|
||||
@@ -1532,6 +1658,30 @@
|
||||
"examples": "例を読み込み中...",
|
||||
"versions": "バージョンを読み込み中..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "SFWのみ設定により{count}件非表示",
|
||||
"showExamples": "例を表示",
|
||||
"showCount": "例を表示({count})",
|
||||
"hideExamples": "例を非表示",
|
||||
"addExamples": "例を追加",
|
||||
"previousExample": "前の例([)",
|
||||
"nextExample": "次の例(])",
|
||||
"noExamples": "利用可能な例画像がありません",
|
||||
"addMoreExamples": "さらに例を追加",
|
||||
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
|
||||
"or": "または",
|
||||
"selectFiles": "ファイルを選択",
|
||||
"supportedFormats": "対応形式:jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "ファイルをインポート中...",
|
||||
"noSupportedFiles": "対応ファイルが選択されていません。画像または動画ファイルを選択してください。",
|
||||
"allFiltered": "NSFWコンテンツ設定により、すべての例画像がフィルタリングされています",
|
||||
"sfwOnlyEnabled": "現在の設定ではSFWコンテンツのみが表示されます",
|
||||
"changeInSettings": "設定から変更できます",
|
||||
"nsfwMature": "成人向けコンテンツ",
|
||||
"nsfwR": "R指定コンテンツ",
|
||||
"nsfwX": "X指定コンテンツ",
|
||||
"nsfwXxx": "XXX指定コンテンツ"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "モデルバージョン",
|
||||
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
|
||||
@@ -1558,32 +1708,33 @@
|
||||
"newer": "新しいバージョン",
|
||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||
"earlyAccess": "早期アクセス",
|
||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"earlyAccessTooltip": "このバージョンは現在 CivitAI の早期アクセスが必要です",
|
||||
"paid": "有料",
|
||||
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
|
||||
"ignored": "無視中",
|
||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||
"onSiteOnly": "サイト内のみ",
|
||||
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
|
||||
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません"
|
||||
},
|
||||
"actions": {
|
||||
"download": "ダウンロード",
|
||||
"downloadTooltip": "このバージョンをダウンロード",
|
||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||
"downloadChooseFilesTooltip": "ダウンロードするファイルを選択",
|
||||
"downloadEarlyAccessTooltip": "CivitAI からこの早期アクセス版をダウンロード",
|
||||
"downloadPaidTooltip": "CivitAI からこの有料バージョンをダウンロード",
|
||||
"downloadNotAllowedTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません",
|
||||
"delete": "削除",
|
||||
"deleteTooltip": "このローカルバージョンを削除",
|
||||
"ignore": "無視",
|
||||
"unignore": "無視を解除",
|
||||
"ignoreTooltip": "このバージョンの更新通知を無視",
|
||||
"unignoreTooltip": "このバージョンの更新通知を再開",
|
||||
"viewVersionOnCivitai": "Civitai でバージョンを表示",
|
||||
"viewVersionOnCivitai": "CivitAI でバージョンを表示",
|
||||
"earlyAccessTooltip": "早期アクセス購入が必要",
|
||||
"resumeModelUpdates": "このモデルの更新を再開",
|
||||
"ignoreModelUpdates": "このモデルの更新を無視",
|
||||
"viewLocalVersions": "ローカルの全バージョンを表示",
|
||||
"viewLocalTooltip": "近日対応予定"
|
||||
"viewLocalTooltip": "このモデルのすべてのローカルバージョンをメインページで表示"
|
||||
},
|
||||
"filters": {
|
||||
"label": "ベースフィルター",
|
||||
@@ -1599,7 +1750,7 @@
|
||||
},
|
||||
"empty": "このモデルにはまだバージョン履歴がありません。",
|
||||
"error": "バージョンの読み込みに失敗しました。",
|
||||
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
|
||||
"missingModelId": "このモデルにはCivitAIのモデルIDがありません。",
|
||||
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
|
||||
"confirm": {
|
||||
"delete": "このバージョンをライブラリから削除しますか?"
|
||||
@@ -1666,14 +1817,14 @@
|
||||
},
|
||||
"checkpoints": {
|
||||
"title": "Checkpoint Managerを初期化中",
|
||||
"message": "checkpointキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||
"message": "Checkpointキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||
},
|
||||
"embeddings": {
|
||||
"title": "Embedding Managerを初期化中",
|
||||
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Recipe Managerを初期化中",
|
||||
"title": "レシピマネージャーを初期化中",
|
||||
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1683,14 +1834,14 @@
|
||||
"tips": {
|
||||
"title": "ヒント&コツ",
|
||||
"civitai": {
|
||||
"title": "Civitai統合",
|
||||
"description": "Civitaiアカウントを接続:プロフィールアバター → 設定 → APIキー → APIキーを追加し、LoRA Manager設定に貼り付けてください。",
|
||||
"alt": "Civitai API設定"
|
||||
"title": "CivitAI統合",
|
||||
"description": "CivitAIアカウントを接続:プロフィールアバター → 設定 → APIキー → APIキーを追加し、LoRA Manager設定に貼り付けてください。",
|
||||
"alt": "CivitAI API設定"
|
||||
},
|
||||
"download": {
|
||||
"title": "簡単ダウンロード",
|
||||
"description": "Civitai URLを使用して新しいモデルを素早くダウンロードしてインストールできます。",
|
||||
"alt": "Civitaiダウンロード"
|
||||
"description": "CivitAI URLを使用して新しいモデルを素早くダウンロードしてインストールできます。",
|
||||
"alt": "CivitAIダウンロード"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "レシピを保存",
|
||||
@@ -1740,7 +1891,7 @@
|
||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "ワークフロー内に互換性のあるプロンプトターゲットがありません。\nComfyUIでノードを右クリック → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||
"modelUpdated": "モデルがワークフローで更新されました",
|
||||
"modelFailed": "モデルノードの更新に失敗しました",
|
||||
@@ -1874,7 +2025,7 @@
|
||||
"submitGithubIssue": "GitHub Issueを提出",
|
||||
"joinDiscord": "Discordに参加",
|
||||
"youtubeChannel": "YouTubeチャンネル",
|
||||
"civitaiProfile": "Civitaiプロフィール",
|
||||
"civitaiProfile": "CivitAIプロフィール",
|
||||
"supportKofi": "Ko-fiでサポート",
|
||||
"supportPatreon": "Patreonでサポート"
|
||||
},
|
||||
@@ -1917,6 +2068,7 @@
|
||||
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
|
||||
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
|
||||
"pleaseSelectVersion": "バージョンを選択してください",
|
||||
"pleaseSelectFile": "ファイルを1つ以上選択してください",
|
||||
"versionExists": "このバージョンは既にライブラリに存在します",
|
||||
"downloadCompleted": "ダウンロードが正常に完了しました",
|
||||
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
|
||||
@@ -1950,18 +2102,33 @@
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"noPreviousRecipe": "前のレシピがありません",
|
||||
"noNextRecipe": "次のレシピがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
|
||||
"enterLoraName": "LoRA名または構文を入力してください",
|
||||
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
|
||||
"reconnectBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、LoRA:{lora})— アーキテクチャ互換です",
|
||||
"reconnectFailed": "LoRA再接続エラー:{message}",
|
||||
"loraRestored": "LoRAが以前の関連付けに復元されました",
|
||||
"loraRestoreFailed": "LoRA復元エラー:{message}",
|
||||
"noPromptToSend": "送信するプロンプトがありません",
|
||||
"cannotSend": "レシピを送信できません:レシピIDがありません",
|
||||
"sendFailed": "レシピのワークフローへの送信に失敗しました",
|
||||
"sendError": "レシピのワークフロー送信エラー",
|
||||
"missingCheckpointPath": "チェックポイントのパスがありません",
|
||||
"missingCheckpointInfo": "チェックポイント情報が不足しています",
|
||||
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
|
||||
"missingCheckpointPath": "Checkpointのパスがありません",
|
||||
"missingCheckpointInfo": "Checkpoint情報が不足しています",
|
||||
"downloadCheckpointFailed": "Checkpointのダウンロードに失敗しました: {message}",
|
||||
"enterCheckpointName": "Checkpoint 名を入力してください",
|
||||
"checkpointReconnectedSuccessfully": "Checkpointが正常に再接続されました",
|
||||
"reconnectCheckpointBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、Checkpoint:{checkpoint})— アーキテクチャ互換です",
|
||||
"checkpointReconnectFailed": "Checkpoint再接続エラー:{message}",
|
||||
"checkpointRestored": "Checkpoint が以前の関連付けに復元されました",
|
||||
"checkpointRestoreFailed": "Checkpoint復元エラー:{message}",
|
||||
"checkpointDownloadUnavailable": "CivitAI の識別子がないため、この Checkpoint をダウンロードできません - ローカルの Checkpoint と再接続してみてください",
|
||||
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
|
||||
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
|
||||
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
|
||||
"cannotDelete": "レシピを削除できません:レシピIDがありません",
|
||||
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
|
||||
"deletedSuccessfully": "レシピが正常に削除されました",
|
||||
@@ -1976,17 +2143,18 @@
|
||||
"processingError": "処理エラー:{message}",
|
||||
"folderBrowserError": "フォルダブラウザの読み込みエラー:{message}",
|
||||
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"recipeSaved": "レシピを保存しました",
|
||||
"importFailed": "インポートに失敗しました:{message}",
|
||||
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
|
||||
"folderTreeError": "フォルダツリー読み込みエラー",
|
||||
"batchImportFailed": "Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "Cancelling batch import...",
|
||||
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"batchImportFailed": "一括インポートを開始できませんでした: {message}",
|
||||
"batchImportCancelling": "一括インポートをキャンセルしています...",
|
||||
"batchImportCancelFailed": "一括インポートをキャンセルできませんでした: {message}",
|
||||
"batchImportNoUrls": "URLまたはファイルパスを少なくとも1つ入力してください",
|
||||
"batchImportNoDirectory": "フォルダパスを入力してください",
|
||||
"batchImportRateLimited": "メタデータプロバイダーのレート制限に達しました — リクエストが遅延され、一部の項目がスキップされる場合があります。後でインポートを再実行できます。",
|
||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||
@@ -2002,7 +2170,10 @@
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
|
||||
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。",
|
||||
"workflowSent": "ワークフローをComfyUIへ送信しました",
|
||||
"workflowSendFailed": "ワークフローをComfyUIへ送信できませんでした: {error}",
|
||||
"workflowNoWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "モデルが選択されていません",
|
||||
@@ -2039,8 +2210,8 @@
|
||||
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
|
||||
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
|
||||
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
|
||||
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
|
||||
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
|
||||
"bulkUpdatesMissing": "選択された{type}はCivitAIの更新にリンクされていません",
|
||||
"bulkUpdatesPartialMissing": "CivitAIリンクがない{missing} 件の{type}をスキップしました",
|
||||
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
|
||||
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
|
||||
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
|
||||
@@ -2066,10 +2237,10 @@
|
||||
},
|
||||
"settings": {
|
||||
"loraRootsFailed": "LoRAルートの読み込みに失敗しました:{message}",
|
||||
"checkpointRootsFailed": "checkpointルートの読み込みに失敗しました:{message}",
|
||||
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural})",
|
||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||
@@ -2080,8 +2251,8 @@
|
||||
"compactModeToggled": "コンパクトモード {state}",
|
||||
"settingSaveFailed": "設定の保存に失敗しました:{message}",
|
||||
"displayDensitySet": "表示密度が {density} に設定されました",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"libraryLoadFailed": "ライブラリを読み込めませんでした: {message}",
|
||||
"libraryActivateFailed": "ライブラリをアクティブ化できませんでした: {message}",
|
||||
"languageChangeFailed": "言語の変更に失敗しました:{message}",
|
||||
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
|
||||
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
|
||||
@@ -2166,10 +2337,11 @@
|
||||
"contextMenu": {
|
||||
"contentRatingSet": "コンテンツレーティングが {level} に設定されました",
|
||||
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
|
||||
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
|
||||
"relinkSuccess": "モデルがCivitAIに正常に再リンクされました",
|
||||
"relinkFailed": "エラー:{message}",
|
||||
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
|
||||
"linkHfFailed": "エラー:{message}",
|
||||
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
|
||||
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
|
||||
"noCivitaiInfo": "CivitAI情報が利用できません",
|
||||
"missingHash": "モデルハッシュが利用できません"
|
||||
@@ -2229,7 +2401,7 @@
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"moveFailed": "アイテムを移動できませんでした: {message}",
|
||||
"copiedToClipboard": "クリップボードにコピーしました",
|
||||
"downloadStarted": "ダウンロードを開始しました"
|
||||
},
|
||||
@@ -2259,7 +2431,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API キー"
|
||||
"title": "CivitAI API キー"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "モデルキャッシュの健全性"
|
||||
@@ -2312,10 +2484,10 @@
|
||||
"seconds": "秒"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
"title": "あなたのサポートで LoRA Manager は成長し続けます ❤️",
|
||||
"content": "LoRA Managerは一人の開発者がフルタイムで維持している情熱的なプロジェクトです。Ko-fiでのご支援は開発費用のカバーや新機能のリリースに役立ち、お礼としてLM CivitAI拡張機能のライセンスキーもご提供します。すべてのご寄付が大きな違いを生みます。",
|
||||
"supportCta": "Ko-fiでサポート",
|
||||
"learnMore": "LM CivitAI拡張機能チュートリアル"
|
||||
},
|
||||
"cacheHealth": {
|
||||
"corrupted": {
|
||||
|
||||
+342
-170
@@ -67,11 +67,11 @@
|
||||
"steps": {
|
||||
"fetch": {
|
||||
"title": "모델 메타데이터 가져오기",
|
||||
"content": "<strong>가져오기</strong> 버튼을 클릭하여 Civitai에서 모델 메타데이터와 미리보기 이미지를 다운로드하세요."
|
||||
"content": "<strong>가져오기</strong> 버튼을 클릭하여 CivitAI에서 모델 메타데이터와 미리보기 이미지를 다운로드하세요."
|
||||
},
|
||||
"download": {
|
||||
"title": "새 모델 다운로드",
|
||||
"content": "<strong>다운로드</strong> 버튼을 사용하여 Civitai URL에서 모델을 직접 다운로드하세요."
|
||||
"content": "<strong>다운로드</strong> 버튼을 사용하여 CivitAI URL에서 모델을 직접 다운로드하세요."
|
||||
},
|
||||
"bulk": {
|
||||
"title": "일괄 작업",
|
||||
@@ -103,8 +103,8 @@
|
||||
"actions": {
|
||||
"addToFavorites": "즐겨찾기에 추가",
|
||||
"removeFromFavorites": "즐겨찾기에서 제거",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"notAvailableFromCivitai": "Civitai에서 사용할 수 없음",
|
||||
"viewOnCivitai": "CivitAI에서 보기",
|
||||
"notAvailableFromCivitai": "CivitAI에서 사용할 수 없음",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"sendToWorkflow": "ComfyUI로 전송 (클릭: 추가, Shift+클릭: 교체)",
|
||||
"copyLoRASyntax": "LoRA 문법 복사",
|
||||
@@ -131,13 +131,13 @@
|
||||
"updateFailed": "즐겨찾기 상태 업데이트 실패"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "Checkpoint을 워크플로로 전송 - 구현 예정 기능",
|
||||
"checkpointNotImplemented": "Checkpoint를 워크플로로 전송 - 구현 예정 기능",
|
||||
"missingPath": "이 카드의 모델 경로를 확인할 수 없습니다"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "예시 이미지 확인 중 오류",
|
||||
"missingHash": "모델 해시 정보가 없습니다.",
|
||||
"noRemoteImagesAvailable": "Civitai에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
||||
"noRemoteImagesAvailable": "CivitAI에서 이 모델의 원격 예시 이미지를 사용할 수 없습니다"
|
||||
},
|
||||
"badges": {
|
||||
"update": "업데이트",
|
||||
@@ -173,11 +173,11 @@
|
||||
"error": "예시 이미지 폴더 정리에 실패했습니다: {message}"
|
||||
},
|
||||
"fetchMissingLicenses": {
|
||||
"label": "Refresh license metadata",
|
||||
"loading": "Refreshing license metadata for {typePlural}...",
|
||||
"success": "Updated license metadata for {count} {typePlural}",
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
"label": "라이선스 메타데이터 새로고침",
|
||||
"loading": "{typePlural}의 라이선스 메타데이터를 새로고침하는 중...",
|
||||
"success": "{count}개의 {typePlural} 라이선스 메타데이터를 업데이트했습니다",
|
||||
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
|
||||
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "레시피 데이터 복구",
|
||||
@@ -222,6 +222,7 @@
|
||||
"modelname": "모델명",
|
||||
"tags": "태그",
|
||||
"creator": "제작자",
|
||||
"hash": "해시",
|
||||
"title": "레시피 제목",
|
||||
"loraName": "LoRA 파일명",
|
||||
"loraModel": "LoRA 모델명",
|
||||
@@ -258,8 +259,12 @@
|
||||
"clearAll": "모든 필터 지우기",
|
||||
"any": "아무",
|
||||
"all": "모두",
|
||||
"tagLogicAny": "모든 태그 일치 (OR)",
|
||||
"tagLogicAll": "모든 태그 일치 (AND)"
|
||||
"tagLogicAny": "어느 하나의 태그와 일치 (OR)",
|
||||
"tagLogicAll": "모든 태그 일치 (AND)",
|
||||
"loraAvailability": "LoRA 가용성",
|
||||
"availabilityReady": "바로 사용 가능",
|
||||
"availabilityMissing": "누락된 LoRA 있음",
|
||||
"availabilityDeleted": "삭제된 LoRA 있음"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "테마 토글",
|
||||
@@ -285,15 +290,15 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"civitaiApiKey": "Civitai API 키",
|
||||
"civitaiApiKeyPlaceholder": "Civitai API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "Civitai에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKey": "CivitAI API 키",
|
||||
"civitaiApiKeyPlaceholder": "CivitAI API 키를 입력하세요",
|
||||
"civitaiApiKeyHelp": "CivitAI에서 모델을 다운로드할 때 인증에 사용됩니다",
|
||||
"civitaiApiKeyConfigured": "설정됨",
|
||||
"civitaiApiKeyNotConfigured": "설정되지 않음",
|
||||
"civitaiApiKeySet": "설정",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 호스트",
|
||||
"help": "\"View on Civitai\" 링크를 사용할 때 어떤 Civitai 사이트를 열지 선택합니다.",
|
||||
"label": "CivitAI 호스트",
|
||||
"help": "\"View on CivitAI\" 링크를 사용할 때 어떤 CivitAI 사이트를 열지 선택합니다.",
|
||||
"options": {
|
||||
"com": "civitai.com(SFW 전용)",
|
||||
"red": "civitai.red(무제한)"
|
||||
@@ -314,8 +319,8 @@
|
||||
},
|
||||
"aria2HelpLink": "aria2 다운로드 백엔드 설정 방법 알아보기",
|
||||
"civitaiHostBanner": {
|
||||
"title": "Civitai 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 Civitai는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
"title": "CivitAI 호스트 기본 설정 사용 가능",
|
||||
"content": "이제 CivitAI는 SFW 콘텐츠에 civitai.com을, 무제한 콘텐츠에 civitai.red를 사용합니다. 설정에서 기본으로 열 사이트를 변경할 수 있습니다.",
|
||||
"openSettings": "설정 열기"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
@@ -422,10 +427,10 @@
|
||||
"noneAvailable": "아직 스냅샷이 없습니다"
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "기본 모델 다운로드 건너뛰기",
|
||||
"help": "모든 다운로드 흐름에 적용됩니다. 여기서는 지원되는 기본 모델만 선택할 수 있습니다.",
|
||||
"searchPlaceholder": "기본 모델 필터링...",
|
||||
"empty": "현재 검색과 일치하는 기본 모델이 없습니다.",
|
||||
"label": "베이스 모델 다운로드 건너뛰기",
|
||||
"help": "활성화하면 선택한 베이스 모델을 사용하는 버전은 건너뜁니다.",
|
||||
"searchPlaceholder": "베이스 모델 필터링...",
|
||||
"empty": "현재 검색과 일치하는 베이스 모델이 없습니다.",
|
||||
"summary": {
|
||||
"none": "선택 없음",
|
||||
"count": "{count}개 선택됨"
|
||||
@@ -436,7 +441,7 @@
|
||||
"clear": "지우기"
|
||||
},
|
||||
"validation": {
|
||||
"saveFailed": "제외된 기본 모델을 저장할 수 없습니다: {message}"
|
||||
"saveFailed": "제외된 베이스 모델을 저장할 수 없습니다: {message}"
|
||||
}
|
||||
},
|
||||
"skipPreviouslyDownloadedModelVersions": {
|
||||
@@ -445,7 +450,7 @@
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
"default": "기본",
|
||||
@@ -512,7 +517,7 @@
|
||||
"extraFolderPaths": {
|
||||
"title": "추가 폴다 경로",
|
||||
"description": "LoRA Manager 전용 추가 모델 루트 경로입니다. ComfyUI의 표준 폴더 외부 위치에서 모델을 로드하여 대규모 라이브러리로 인한 성능 저하를 방지합니다.",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"restartRequired": "변경 사항을 적용하려면 재시작이 필요합니다",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 경로",
|
||||
"checkpoint": "Checkpoint 경로",
|
||||
@@ -535,8 +540,8 @@
|
||||
"helpLinkLabel": "우선순위 태그 도움말 열기",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "체크포인트",
|
||||
"embedding": "임베딩"
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"saveSuccess": "우선순위 태그가 업데이트되었습니다.",
|
||||
"saveError": "우선순위 태그를 업데이트하지 못했습니다.",
|
||||
@@ -550,7 +555,7 @@
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "다운로드 경로 템플릿",
|
||||
"help": "Civitai에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.",
|
||||
"help": "CivitAI에서 다운로드할 때 다양한 모델 유형의 폴더 구조를 구성합니다.",
|
||||
"availablePlaceholders": "사용 가능한 플레이스홀더:",
|
||||
"templateOptions": {
|
||||
"flatStructure": "플랫 구조",
|
||||
@@ -587,7 +592,7 @@
|
||||
"exampleImages": {
|
||||
"downloadLocation": "다운로드 위치",
|
||||
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
||||
"downloadLocationHelp": "Civitai의 예시 이미지가 저장될 폴더 경로를 입력하세요",
|
||||
"downloadLocationHelp": "CivitAI의 예시 이미지가 저장될 폴더 경로를 입력하세요",
|
||||
"autoDownload": "예시 이미지 자동 다운로드",
|
||||
"autoDownloadHelp": "예시 이미지가 없는 모델의 예시 이미지를 자동으로 다운로드합니다 (다운로드 위치 설정 필요)",
|
||||
"openMode": "예시 이미지 열기 동작",
|
||||
@@ -620,11 +625,11 @@
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "얼리 액세스 업데이트 숨기기",
|
||||
"help": "얼리 액세스 업데이트만"
|
||||
"help": "활성화하면 얼리 액세스 업데이트만 있는 모델에는 '업데이트 가능' 배지가 표시되지 않습니다."
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "유료 업데이트 숨기기",
|
||||
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||
@@ -642,7 +647,7 @@
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "메타데이터 아카이브 데이터베이스 활성화",
|
||||
"enableArchiveDbHelp": "Civitai에서 삭제된 모델의 메타데이터에 접근하기 위해 로컬 데이터베이스를 사용합니다.",
|
||||
"enableArchiveDbHelp": "CivitAI에서 삭제된 모델의 메타데이터에 접근하기 위해 로컬 데이터베이스를 사용합니다.",
|
||||
"status": "상태",
|
||||
"statusAvailable": "사용 가능",
|
||||
"statusUnavailable": "사용 불가",
|
||||
@@ -703,7 +708,7 @@
|
||||
"custom": "사용자 정의 (OpenAI 호환)"
|
||||
},
|
||||
"apiBase": "API 기본 URL",
|
||||
"apiBaseHelp": "LLM API의 기본 URL입니다 (예: https://api.openai.com/v1). 비워두면 제공자 기본값이 사용됩니다.",
|
||||
"apiBaseHelp": "LLM API의 기본 URL입니다. 프리셋을 선택하거나 사용자 정의 URL을 입력하세요. 드롭다운에 지원되는 모든 제공자의 프리셋이 표시됩니다.",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 키",
|
||||
"apiKeyHelp": "LLM 제공자의 API 키입니다. 로컬에 저장되며 선택한 LLM 제공자 외의 서버로 전송되지 않습니다.",
|
||||
@@ -745,7 +750,7 @@
|
||||
"fullTooltip": "메타데이터 파일에서 모든 모델 정보를 다시 불러옵니다. 라이브러리가 오래되어 보이거나 수동 수정 후에 사용하세요."
|
||||
},
|
||||
"fetch": {
|
||||
"title": "Civitai에서 메타데이터 가져오기",
|
||||
"title": "CivitAI에서 메타데이터 가져오기",
|
||||
"action": "가져오기"
|
||||
},
|
||||
"download": {
|
||||
@@ -800,9 +805,9 @@
|
||||
"clear": "선택 지우기",
|
||||
"skipMetadataRefreshCount": "건너뛰기({count}개 모델)",
|
||||
"resumeMetadataRefreshCount": "재개({count}개 모델)",
|
||||
"sendToWorkflow": "워크플로우로 보내기",
|
||||
"sendToWorkflow": "워크플로로 보내기",
|
||||
"sections": {
|
||||
"workflow": "워크플로우",
|
||||
"workflow": "워크플로",
|
||||
"metadata": "메타데이터",
|
||||
"attributes": "속성",
|
||||
"organize": "정리",
|
||||
@@ -820,10 +825,10 @@
|
||||
"enrichHfAgent": "HF AI로 메타데이터 보강"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "Civitai 데이터 새로고침",
|
||||
"refreshMetadata": "CivitAI 데이터 새로고침",
|
||||
"checkUpdates": "업데이트 확인",
|
||||
"linkModel": "모델 연결",
|
||||
"linkCivitai": "Civitai에 연결",
|
||||
"linkCivitai": "CivitAI에 연결",
|
||||
"linkHuggingFace": "HuggingFace에 연결",
|
||||
"copySyntax": "LoRA 문법 복사",
|
||||
"copyFilename": "모델 파일명 복사",
|
||||
@@ -853,20 +858,130 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 레시피",
|
||||
"actions": {
|
||||
"sendCheckpoint": "ComfyUI로 보내기"
|
||||
"sendCheckpoint": "ComfyUI로 보내기",
|
||||
"sendRecipe": "ComfyUI로 보내기",
|
||||
"copyRecipeSyntax": "레시피 문법 복사",
|
||||
"deleteRecipeWithShortcut": "레시피 삭제(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "레시피 탐색",
|
||||
"previousWithShortcut": "이전 레시피(←)",
|
||||
"nextWithShortcut": "다음 레시피(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"copyId": "레시피 ID 복사"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
"failed": "파일 위치 열기에 실패했습니다",
|
||||
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
|
||||
"clipboardFallback": "경로: {{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
|
||||
"sent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
|
||||
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
},
|
||||
"status": {
|
||||
"ready": "바로 사용 가능",
|
||||
"missingCount": "{count}개 누락",
|
||||
"deletedCount": "{count}개 삭제됨",
|
||||
"downloadMissing": "누락된 LoRA {count}개 다운로드",
|
||||
"downloadMissingTooltip": "클릭하여 누락된 LoRA 다운로드"
|
||||
},
|
||||
"loraStatus": {
|
||||
"none": "이 레시피에는 LoRA가 없습니다",
|
||||
"allAvailable": "모든 LoRA 사용 가능 - 바로 사용 가능",
|
||||
"missing": "총 {total}개 중 {missing}개 LoRA 누락",
|
||||
"missingAndUnavailable": "총 {total}개 중 {missing}개 LoRA 누락, {unavailable}개 사용 불가(소스에서 삭제되었거나 해시를 확인할 수 없음)",
|
||||
"partial": "총 {total}개 중 {unavailable}개 LoRA 사용 불가(소스에서 삭제되었거나 해시를 확인할 수 없음) - 레시피 사용 시 건너뜁니다",
|
||||
"noneUsable": "사용 가능한 LoRA가 없습니다 - 총 {total}개 중 {unavailable}개가 소스에서 삭제되었거나 해시를 확인할 수 없습니다"
|
||||
},
|
||||
"resources": {
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"notInLibrary": "라이브러리에 없음",
|
||||
"deleted": "삭제됨",
|
||||
"hashInvalid": "해석할 수 없는 해시",
|
||||
"inLibraryTooltip": "이 모델은 로컬 라이브러리에 있습니다",
|
||||
"notInLibraryTooltip": "이 모델은 라이브러리에 없습니다",
|
||||
"deletedTooltip": "이 LoRA는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다",
|
||||
"hashInvalidTooltip": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"noLorasAssociated": "이 레시피에 연결된 LoRA가 없습니다",
|
||||
"noLorasWhyToggle": "LoRA가 없는 이유",
|
||||
"noLorasImportMethod": "가져오기 방법",
|
||||
"noLorasInferredNote": "가능한 이유(추정) — 이 레시피는 가져오기 진단이 기록되기 전에 가져온 것입니다.",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "일괄 가져오기(이미지 URL)",
|
||||
"batch_import_local": "일괄 가져오기(로컬 파일)",
|
||||
"url": "이미지 URL 가져오기",
|
||||
"local": "로컬 파일 가져오기",
|
||||
"upload": "이미지 업로드",
|
||||
"widget": "워크플로에서 저장",
|
||||
"reimport_url": "다시 가져오기(이미지 URL)",
|
||||
"reimport_local": "다시 가져오기(로컬 파일)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "생성 메타데이터가 완전하며 LoRA를 참조하지 않습니다.",
|
||||
"api_meta_no_lora_resources": "소스 API가 이 이미지에 대한 LoRA 리소스 데이터를 반환하지 않았습니다. CivitAI 페이지에 표시되는 LoRA는 공개 API가 노출하지 않는 내부 데이터에서 비롯될 수 있습니다.",
|
||||
"api_meta_missing": "소스 API가 이 이미지에 대한 생성 메타데이터를 반환하지 않았습니다.",
|
||||
"no_embedded_metadata": "이미지에 내장된 생성 메타데이터가 없어 LoRA 정보를 복구할 수 없습니다.",
|
||||
"workflow_metadata_limited": "이미지에 내장된 메타데이터는 ComfyUI 워크플로입니다. 워크플로에서 LoRA 정보를 추출하는 것은 제한적입니다.",
|
||||
"video_no_metadata": "동영상 파일에는 내장 생성 메타데이터가 없습니다.",
|
||||
"metadata_unsupported": "이미지에 파싱할 수 없는 형식의 메타데이터가 포함되어 있습니다.",
|
||||
"unknown": "저장된 레시피 데이터에서 이유를 확인할 수 없습니다."
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 메타데이터 필드",
|
||||
"modelVersionIds": "보고된 모델 버전 ID 수",
|
||||
"embeddedMetadata": "내장 메타데이터",
|
||||
"present": "있음",
|
||||
"absent": "없음"
|
||||
},
|
||||
"download": "다운로드",
|
||||
"downloadLoraTooltip": "이 LoRA 다운로드",
|
||||
"preparingDownload": "다운로드 준비 중...",
|
||||
"reconnect": "다시 연결",
|
||||
"reconnectTooltip": "로컬 LoRA와 다시 연결",
|
||||
"reconnectInstructions": "다시 연결할 LoRA 구문 또는 이름을 입력하세요:",
|
||||
"reconnectExample": "예:<lora:name:1> 또는 이름만 입력",
|
||||
"reconnectPlaceholder": "LoRA 이름 또는 구문 입력",
|
||||
"reconnectSuggestionsLoading": "로컬 라이브러리 검색 중...",
|
||||
"reconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 LoRA가 없습니다",
|
||||
"reconnectMatchSameHash": "동일한 해시",
|
||||
"reconnectMatchSameVersion": "동일한 모델 버전",
|
||||
"reconnectMatchSimilarFilename": "유사한 파일 이름",
|
||||
"reconnectMatchSimilarName": "유사한 이름",
|
||||
"undoReconnect": "실행 취소",
|
||||
"undoReconnectTooltip": "이 항목을 다시 연결 전의 연결 상태로 복원",
|
||||
"undoReconnectTooltipNamed": "이전 연결 상태로 복원: {name}",
|
||||
"viewOnCivitai": "CivitAI에서 보기",
|
||||
"openLoraDetails": "LoRA 라이브러리에서 {name} 보기",
|
||||
"openCheckpointDetails": "모델 라이브러리에서 {name} 보기",
|
||||
"checkpointDeletedTooltip": "이 Checkpoint는 소스에서 삭제되어 더 이상 다운로드할 수 없습니다 - 로컬 모델로 다시 연결하세요",
|
||||
"checkpointHashInvalidTooltip": "이 Checkpoint의 해시를 CivitAI에서 확인할 수 없습니다 - 모델이 업데이트되었을 수 있습니다",
|
||||
"reconnectCheckpoint": "다시 연결",
|
||||
"reconnectCheckpointTooltip": "로컬 Checkpoint와 다시 연결",
|
||||
"checkpointReconnectInstructions": "다시 연결할 Checkpoint 이름을 입력하세요:",
|
||||
"checkpointReconnectPlaceholder": "Checkpoint 이름 입력",
|
||||
"checkpointReconnectSuggestionsEmpty": "로컬 라이브러리에 일치하는 Checkpoint가 없습니다"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "가져오기",
|
||||
"title": "이미지 또는 URL에서 레시피 가져오기",
|
||||
"urlLocalPath": "URL / 로컬 경로",
|
||||
"uploadImage": "이미지 업로드",
|
||||
"urlSectionDescription": "Civitai 이미지 URL 또는 로컬 파일 경로를 입력하여 레시피로 가져옵니다.",
|
||||
"dropZoneLabel": "이미지 업로드",
|
||||
"dropZoneHint": "이미지를 여기에 끌어다 놓거나, 클립보드에서 붙여넣거나, 클릭하여 찾아보세요",
|
||||
"orDivider": "또는 이미지를 끌어다 놓기 / 붙여넣기",
|
||||
"imageUrlOrPath": "이미지 URL 또는 파일 경로:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 또는 C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 또는 https://civitai.red/images/... 또는 C:/path/to/image.png",
|
||||
"fetchImage": "이미지 가져오기",
|
||||
"uploadSectionDescription": "LoRA 메타데이터가 포함된 이미지를 업로드하여 레시피로 가져옵니다.",
|
||||
"selectImage": "이미지 선택",
|
||||
"recipeName": "레시피 이름",
|
||||
"recipeNamePlaceholder": "레시피 이름을 입력하세요",
|
||||
"tagsOptional": "태그 (선택사항)",
|
||||
@@ -894,7 +1009,7 @@
|
||||
"downloadingLoras": "LoRA 다운로드 중...",
|
||||
"savingRecipe": "레시피 저장 중...",
|
||||
"startingDownload": "LoRA {current}/{total} 다운로드 시작",
|
||||
"deletedFromCivitai": "Civitai에서 삭제됨",
|
||||
"deletedFromCivitai": "CivitAI에서 삭제됨",
|
||||
"inLibrary": "라이브러리에 있음",
|
||||
"notInLibrary": "라이브러리에 없음",
|
||||
"earlyAccessRequired": "이 LoRA는 얼리 액세스 결제가 필요합니다.",
|
||||
@@ -911,6 +1026,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "이미지 파일을 선택해주세요",
|
||||
"enterUrlOrPath": "URL 또는 파일 경로를 입력해주세요",
|
||||
"invalidUrl": "유효한 URL을 입력하세요",
|
||||
"invalidInputFormat": "이미지 URL 또는 로컬 이미지 파일 경로를 입력하세요",
|
||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +1062,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "중복 레시피를 스캔하는 중...",
|
||||
"found": "{count}개의 중복 그룹 발견",
|
||||
"noGroups": "현재 일치 기준으로 중복 그룹을 찾을 수 없습니다",
|
||||
"keepLatest": "최신 버전 유지",
|
||||
@@ -993,61 +1111,63 @@
|
||||
}
|
||||
},
|
||||
"batchImport": {
|
||||
"title": "Batch Import Recipes",
|
||||
"action": "Batch Import",
|
||||
"urlList": "URL List",
|
||||
"directory": "Directory",
|
||||
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
|
||||
"directoryDescription": "Enter a directory path to import all images from that folder.",
|
||||
"urlsLabel": "Image URLs or Local Paths",
|
||||
"title": "레시피 일괄 가져오기",
|
||||
"action": "일괄 가져오기",
|
||||
"urlList": "URL 목록",
|
||||
"directory": "폴더",
|
||||
"urlDescription": "이미지 URL 또는 로컬 파일 경로를 입력하세요 (줄당 하나). 각 항목은 레시피로 가져옵니다.",
|
||||
"directoryDescription": "폴더 경로를 입력하면 해당 폴더의 모든 이미지를 가져옵니다.",
|
||||
"urlsLabel": "이미지 URL 또는 로컬 경로",
|
||||
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
|
||||
"urlsHint": "Enter one URL or path per line",
|
||||
"directoryPath": "Directory Path",
|
||||
"urlsHint": "줄당 URL 또는 경로 하나를 입력하세요",
|
||||
"directoryPath": "폴더 경로",
|
||||
"directoryPlaceholder": "/path/to/images/folder",
|
||||
"browse": "Browse",
|
||||
"recursive": "Include subdirectories",
|
||||
"tagsOptional": "Tags (optional, applied to all recipes)",
|
||||
"tagsPlaceholder": "Enter tags separated by commas",
|
||||
"tagsHint": "Tags will be added to all imported recipes",
|
||||
"skipNoMetadata": "Skip images without metadata",
|
||||
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
|
||||
"start": "Start Import",
|
||||
"startImport": "Start Import",
|
||||
"importing": "Importing...",
|
||||
"progress": "Progress",
|
||||
"total": "Total",
|
||||
"success": "Success",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped",
|
||||
"current": "Current",
|
||||
"currentItem": "Current",
|
||||
"preparing": "Preparing...",
|
||||
"cancel": "Cancel",
|
||||
"cancelImport": "Cancel",
|
||||
"cancelled": "Import cancelled",
|
||||
"completed": "Import completed",
|
||||
"completedWithErrors": "Completed with errors",
|
||||
"completedSuccess": "Successfully imported {count} recipe(s)",
|
||||
"successCount": "Successful",
|
||||
"failedCount": "Failed",
|
||||
"skippedCount": "Skipped",
|
||||
"totalProcessed": "Total processed",
|
||||
"viewDetails": "View Details",
|
||||
"newImport": "New Import",
|
||||
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
|
||||
"backToParent": "Back to parent directory",
|
||||
"folders": "Folders",
|
||||
"folderCount": "{count} folders",
|
||||
"imageFiles": "Image Files",
|
||||
"images": "images",
|
||||
"imageCount": "{count} images",
|
||||
"selectFolder": "Select This Folder",
|
||||
"browse": "찾아보기",
|
||||
"recursive": "하위 폴더 포함",
|
||||
"tagsOptional": "태그 (선택 사항, 모든 레시피에 적용)",
|
||||
"tagsPlaceholder": "쉼표로 구분된 태그 입력",
|
||||
"tagsHint": "태그가 가져온 모든 레시피에 추가됩니다",
|
||||
"skipNoMetadata": "메타데이터 없는 이미지 건너뛰기",
|
||||
"skipNoMetadataHelp": "LoRA 메타데이터가 없는 이미지는 자동으로 건너뜁니다.",
|
||||
"start": "가져오기 시작",
|
||||
"startImport": "가져오기 시작",
|
||||
"importing": "가져오는 중...",
|
||||
"rateLimitedSlowdown": "속도 제한 — 느려지고 있습니다...",
|
||||
"rateLimitedHint": "메타데이터 제공자의 속도 제한으로 일부 항목이 건너뛰어졌습니다. 나중에 가져오기를 다시 실행하여 재시도하세요.",
|
||||
"progress": "진행률",
|
||||
"total": "전체",
|
||||
"success": "성공",
|
||||
"failed": "실패",
|
||||
"skipped": "건너뜀",
|
||||
"current": "현재",
|
||||
"currentItem": "현재",
|
||||
"preparing": "준비 중...",
|
||||
"cancel": "취소",
|
||||
"cancelImport": "취소",
|
||||
"cancelled": "가져오기가 취소되었습니다",
|
||||
"completed": "가져오기가 완료되었습니다",
|
||||
"completedWithErrors": "오류와 함께 완료됨",
|
||||
"completedSuccess": "{count}개의 레시피를 성공적으로 가져왔습니다",
|
||||
"successCount": "성공",
|
||||
"failedCount": "실패",
|
||||
"skippedCount": "건너뜀",
|
||||
"totalProcessed": "처리된 전체",
|
||||
"viewDetails": "세부 정보 보기",
|
||||
"newImport": "새 가져오기",
|
||||
"manualPathEntry": "폴더 경로를 직접 입력하세요. 이 브라우저에서는 파일 브라우저를 사용할 수 없습니다.",
|
||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||
"batchImportManualEntryRequired": "파일 브라우저를 사용할 수 없습니다. 폴더 경로를 직접 입력하세요.",
|
||||
"backToParent": "상위 폴더로",
|
||||
"folders": "폴더",
|
||||
"folderCount": "폴더 {count}개",
|
||||
"imageFiles": "이미지 파일",
|
||||
"images": "이미지",
|
||||
"imageCount": "이미지 {count}개",
|
||||
"selectFolder": "이 폴더 선택",
|
||||
"errors": {
|
||||
"enterUrls": "Please enter at least one URL or path",
|
||||
"enterDirectory": "Please enter a directory path",
|
||||
"startFailed": "Failed to start import: {message}"
|
||||
"enterUrls": "URL 또는 경로를 하나 이상 입력하세요",
|
||||
"enterDirectory": "폴더 경로를 입력하세요",
|
||||
"startFailed": "가져오기를 시작하지 못했습니다: {message}"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1059,7 +1179,7 @@
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "{otherType} 폴더로 이동",
|
||||
"sendToWorkflow": "워크플로우로 전송"
|
||||
"sendToWorkflow": "워크플로로 전송"
|
||||
}
|
||||
},
|
||||
"embeddings": {
|
||||
@@ -1217,7 +1337,7 @@
|
||||
"download": {
|
||||
"title": "URL에서 모델 다운로드",
|
||||
"titleWithType": "URL에서 {type} 다운로드",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "한 줄에 하나의 CivitAI, CivArchive 또는 Hugging Face URL을 입력하세요. 여러 URL을 일괄 다운로드할 수 있습니다.",
|
||||
"selectHfFiles": "이 저장소에서 다운로드할 파일을 선택하세요:",
|
||||
@@ -1243,14 +1363,16 @@
|
||||
"downloaded": "다운로드됨",
|
||||
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
|
||||
"alreadyInLibrary": "이미 라이브러리에 있음",
|
||||
"partiallyDownloaded": "부분적으로 다운로드됨",
|
||||
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
|
||||
"fileSelection": {
|
||||
"title": "파일 형식 선택",
|
||||
"files": "개 파일",
|
||||
"select": "파일 선택"
|
||||
"select": "파일 선택",
|
||||
"inLibrary": "라이브러리에 있음"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "잘못된 Civitai URL 형식",
|
||||
"invalidUrl": "잘못된 CivitAI URL 형식",
|
||||
"noVersions": "이 모델에 사용 가능한 버전이 없습니다",
|
||||
"mixedSources": "동일한 배치에서 CivitAI와 Hugging Face URL을 혼합할 수 없습니다.",
|
||||
"noModelFiles": "이 저장소에서 모델 파일을 찾을 수 없습니다."
|
||||
@@ -1329,9 +1451,9 @@
|
||||
"action": "모두 삭제"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "{type} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {type}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
"tip": "나눠서 진행하고 싶다면 벌크 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"title": "{typePlural} 전체 업데이트를 확인할까요?",
|
||||
"message": "라이브러리에 있는 모든 {typePlural}의 업데이트를 확인합니다. 컬렉션이 클수록 시간이 조금 더 걸릴 수 있습니다.",
|
||||
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
|
||||
"action": "전체 확인"
|
||||
},
|
||||
"bulkAddTags": {
|
||||
@@ -1364,7 +1486,7 @@
|
||||
"title": "로컬 예시 이미지",
|
||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||
"downloadOption": {
|
||||
"title": "Civitai에서 다운로드",
|
||||
"title": "CivitAI에서 다운로드",
|
||||
"description": "오프라인 사용 및 빠른 로딩을 위해 원격 예시를 로컬에 저장"
|
||||
},
|
||||
"importOption": {
|
||||
@@ -1391,7 +1513,7 @@
|
||||
"confirmAction": "저장 및 연결"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "Civitai에 다시 연결",
|
||||
"title": "CivitAI에 다시 연결",
|
||||
"warning": "경고:",
|
||||
"warningText": "이것은 잠재적으로 파괴적인 작업입니다. 다시 연결하면:",
|
||||
"warningList": {
|
||||
@@ -1400,14 +1522,15 @@
|
||||
"unintendedConsequences": "기타 의도하지 않은 결과가 있을 수 있음"
|
||||
},
|
||||
"proceedText": "원하는 작업이 확실한 경우에만 진행하세요.",
|
||||
"urlLabel": "Civitai 모델 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlLabel": "CivitAI 모델 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 또는 https://civitai.red/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 (CivArchive)"
|
||||
},
|
||||
"confirmAction": "다시 연결 확인"
|
||||
},
|
||||
@@ -1417,14 +1540,16 @@
|
||||
"editFileName": "파일명 편집",
|
||||
"editBaseModel": "베이스 모델 편집",
|
||||
"editVersionName": "버전명 편집",
|
||||
"viewOnCivitai": "Civitai에서 보기",
|
||||
"viewOnCivitaiText": "Civitai에서 보기",
|
||||
"viewOnCivitai": "CivitAI에서 보기",
|
||||
"viewOnCivitaiText": "CivitAI에서 보기",
|
||||
"viewOnHuggingFace": "Hugging Face에서 보기",
|
||||
"viewOnHuggingFaceText": "Hugging Face에서 보기",
|
||||
"viewCreatorProfile": "제작자 프로필 보기",
|
||||
"openFileLocation": "파일 위치 열기",
|
||||
"sendToWorkflow": "ComfyUI로 보내기",
|
||||
"sendToWorkflowText": "ComfyUI로 보내기"
|
||||
"sendToWorkflowText": "ComfyUI로 보내기",
|
||||
"copyHash": "해시 복사",
|
||||
"deleteModelWithShortcut": "모델 삭제(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "파일 위치가 성공적으로 열렸습니다",
|
||||
@@ -1441,13 +1566,14 @@
|
||||
"location": "위치",
|
||||
"baseModel": "베이스 모델",
|
||||
"size": "크기",
|
||||
"hashes": "해시",
|
||||
"unknown": "알 수 없음",
|
||||
"usageTips": "사용 팁",
|
||||
"additionalNotes": "추가 메모",
|
||||
"notesHint": "Enter로 저장, Shift+Enter로 줄바꿈",
|
||||
"addNotesPlaceholder": "메모를 여기에 추가하세요...",
|
||||
"aboutThisVersion": "이 버전에 대해",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색…",
|
||||
"baseModelSearchPlaceholder": "베이스 모델 검색...",
|
||||
"baseModelSuggested": "추천",
|
||||
"baseModelNoMatch": "일치하는 베이스 모델 없음"
|
||||
},
|
||||
@@ -1516,10 +1642,10 @@
|
||||
"noNext": "다음 모델이 없습니다"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"noImageSell": "생성 콘텐츠 판매 금지",
|
||||
"noRentCivit": "CivitAI 생성 불가",
|
||||
"noRent": "생성 서비스 불가",
|
||||
"noSell": "모델 판매 금지",
|
||||
"creditRequired": "제작자 크레딧 필요",
|
||||
"noDerivatives": "공유 병합 불가",
|
||||
"noReLicense": "동일한 권한 필요",
|
||||
@@ -1532,6 +1658,30 @@
|
||||
"examples": "예시 로딩 중...",
|
||||
"versions": "버전 로딩 중..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "SFW 전용 설정으로 {count}개 숨겨짐",
|
||||
"showExamples": "예시 보기",
|
||||
"showCount": "예시 보기 ({count})",
|
||||
"hideExamples": "예시 숨기기",
|
||||
"addExamples": "예시 추가",
|
||||
"previousExample": "이전 예시([)",
|
||||
"nextExample": "다음 예시(])",
|
||||
"noExamples": "사용 가능한 예시 이미지가 없습니다",
|
||||
"addMoreExamples": "예시 더 추가",
|
||||
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
|
||||
"or": "또는",
|
||||
"selectFiles": "파일 선택",
|
||||
"supportedFormats": "지원되는 형식: jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "파일을 가져오는 중...",
|
||||
"noSupportedFiles": "지원되는 파일이 선택되지 않았습니다. 이미지 또는 비디오 파일을 선택하세요.",
|
||||
"allFiltered": "NSFW 콘텐츠 설정으로 인해 모든 예시 이미지가 필터링되었습니다",
|
||||
"sfwOnlyEnabled": "현재 설정이 안전한(SFW) 콘텐츠만 표시하도록 설정되어 있습니다",
|
||||
"changeInSettings": "설정에서 변경할 수 있습니다",
|
||||
"nsfwMature": "성인 콘텐츠",
|
||||
"nsfwR": "R등급 콘텐츠",
|
||||
"nsfwX": "X등급 콘텐츠",
|
||||
"nsfwXxx": "XXX등급 콘텐츠"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "모델 버전",
|
||||
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
|
||||
@@ -1558,32 +1708,33 @@
|
||||
"newer": "최신 버전",
|
||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||
"earlyAccess": "얼리 액세스",
|
||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"earlyAccessTooltip": "이 버전은 현재 CivitAI 얼리 액세스가 필요합니다",
|
||||
"paid": "유료",
|
||||
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
|
||||
"ignored": "무시됨",
|
||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||
"onSiteOnly": "사이트 내 전용",
|
||||
"onSiteOnlyTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||
"onSiteOnlyTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다"
|
||||
},
|
||||
"actions": {
|
||||
"download": "다운로드",
|
||||
"downloadTooltip": "이 버전 다운로드",
|
||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||
"downloadChooseFilesTooltip": "다운로드할 파일 선택",
|
||||
"downloadEarlyAccessTooltip": "CivitAI에서 이 얼리 액세스 버전 다운로드",
|
||||
"downloadPaidTooltip": "CivitAI에서 이 유료 버전 다운로드",
|
||||
"downloadNotAllowedTooltip": "이 버전은 CivitAI 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||
"delete": "삭제",
|
||||
"deleteTooltip": "이 로컬 버전 삭제",
|
||||
"ignore": "무시",
|
||||
"unignore": "무시 해제",
|
||||
"ignoreTooltip": "이 버전의 업데이트 알림 무시",
|
||||
"unignoreTooltip": "이 버전의 업데이트 알림 다시 받기",
|
||||
"viewVersionOnCivitai": "Civitai에서 버전 보기",
|
||||
"viewVersionOnCivitai": "CivitAI에서 버전 보기",
|
||||
"earlyAccessTooltip": "얼리 액세스 구매 필요",
|
||||
"resumeModelUpdates": "이 모델 업데이트 재개",
|
||||
"ignoreModelUpdates": "이 모델 업데이트 무시",
|
||||
"viewLocalVersions": "로컬 버전 모두 보기",
|
||||
"viewLocalTooltip": "곧 제공 예정"
|
||||
"viewLocalTooltip": "이 모델의 모든 로컬 버전을 메인 페이지에 표시"
|
||||
},
|
||||
"filters": {
|
||||
"label": "기본 필터",
|
||||
@@ -1599,7 +1750,7 @@
|
||||
},
|
||||
"empty": "이 모델에는 아직 버전 기록이 없습니다.",
|
||||
"error": "버전을 불러오지 못했습니다.",
|
||||
"missingModelId": "이 모델에는 Civitai 모델 ID가 없습니다.",
|
||||
"missingModelId": "이 모델에는 CivitAI 모델 ID가 없습니다.",
|
||||
"hfGroupInfo": "HuggingFace 모델 그룹입니다. 라이브러리를 열어 그리드에서 모든 버전을 확인하세요.",
|
||||
"confirm": {
|
||||
"delete": "이 버전을 라이브러리에서 삭제하시겠습니까?"
|
||||
@@ -1673,7 +1824,7 @@
|
||||
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Recipe Manager 초기화 중",
|
||||
"title": "레시피 매니저 초기화 중",
|
||||
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1683,14 +1834,14 @@
|
||||
"tips": {
|
||||
"title": "팁 & 요령",
|
||||
"civitai": {
|
||||
"title": "Civitai 통합",
|
||||
"description": "Civitai 계정 연결: 프로필 아바타 → 설정 → API 키 → API 키 추가를 방문한 후 LoRA Manager 설정에 붙여넣으세요.",
|
||||
"alt": "Civitai API 설정"
|
||||
"title": "CivitAI 통합",
|
||||
"description": "CivitAI 계정 연결: 프로필 아바타 → 설정 → API 키 → API 키 추가를 방문한 후 LoRA Manager 설정에 붙여넣으세요.",
|
||||
"alt": "CivitAI API 설정"
|
||||
},
|
||||
"download": {
|
||||
"title": "간편 다운로드",
|
||||
"description": "Civitai URL을 사용하여 새로운 모델을 빠르게 다운로드하고 설치하세요.",
|
||||
"alt": "Civitai 다운로드"
|
||||
"description": "CivitAI URL을 사용하여 새로운 모델을 빠르게 다운로드하고 설치하세요.",
|
||||
"alt": "CivitAI 다운로드"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "레시피 저장",
|
||||
@@ -1740,7 +1891,7 @@
|
||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||
"noPromptTargets": "워크플로에 호환되는 프롬프트 타겟이 없습니다.\nComfyUI에서 노드를 우클릭 → Mark as → Send Prompt Target",
|
||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||
"modelFailed": "모델 노드 업데이트 실패",
|
||||
@@ -1752,7 +1903,7 @@
|
||||
"nodeSelector": {
|
||||
"recipe": "레시피",
|
||||
"lora": "LoRA",
|
||||
"embedding": "임베딩",
|
||||
"embedding": "Embedding",
|
||||
"prompt": "프롬프트",
|
||||
"replace": "교체",
|
||||
"append": "추가",
|
||||
@@ -1874,7 +2025,7 @@
|
||||
"submitGithubIssue": "GitHub 이슈 제출",
|
||||
"joinDiscord": "Discord 참여",
|
||||
"youtubeChannel": "YouTube 채널",
|
||||
"civitaiProfile": "Civitai 프로필",
|
||||
"civitaiProfile": "CivitAI 프로필",
|
||||
"supportKofi": "Ko-fi에서 지원",
|
||||
"supportPatreon": "Patreon에서 지원"
|
||||
},
|
||||
@@ -1917,9 +2068,10 @@
|
||||
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
|
||||
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
|
||||
"pleaseSelectVersion": "버전을 선택해주세요",
|
||||
"pleaseSelectFile": "파일을 하나 이상 선택해주세요",
|
||||
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
|
||||
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
|
||||
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
|
||||
"downloadSkippedByBaseModel": "베이스 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
|
||||
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
||||
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
||||
"autoOrganizeFailed": "자동 정리 실패: {error}",
|
||||
@@ -1943,25 +2095,40 @@
|
||||
"negativePromptUpdated": "네거티브 프롬프트가 성공적으로 업데이트되었습니다",
|
||||
"promptEditorHint": "Enter 키를 눌러 저장, Shift+Enter로 새 줄",
|
||||
"noRecipeId": "사용 가능한 레시피 ID가 없습니다",
|
||||
"sendToWorkflowFailed": "워크플로우에 레시피 보내기 실패: {message}",
|
||||
"sendToWorkflowFailed": "워크플로에 레시피 보내기 실패: {message}",
|
||||
"copyFailed": "레시피 문법 복사 오류: {message}",
|
||||
"createError": "레시피 생성 중 오류 발생:{message}",
|
||||
"createFailed": "레시피 생성 실패:{error}",
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||
"noNextRecipe": "다음 레시피가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
|
||||
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
|
||||
"reconnectedSuccessfully": "LoRA가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, LoRA: {lora}) — 아키텍처 호환입니다",
|
||||
"reconnectFailed": "LoRA 다시 연결 오류: {message}",
|
||||
"loraRestored": "LoRA가 이전 연결 상태로 복원되었습니다",
|
||||
"loraRestoreFailed": "LoRA 복원 오류: {message}",
|
||||
"noPromptToSend": "보낼 프롬프트가 없습니다",
|
||||
"cannotSend": "레시피를 전송할 수 없습니다: 레시피 ID 누락",
|
||||
"sendFailed": "레시피를 워크플로로 전송하는데 실패했습니다",
|
||||
"sendError": "레시피를 워크플로로 전송하는 중 오류",
|
||||
"missingCheckpointPath": "체크포인트 경로를 사용할 수 없습니다",
|
||||
"missingCheckpointInfo": "체크포인트 정보가 부족합니다",
|
||||
"downloadCheckpointFailed": "체크포인트 다운로드 실패: {message}",
|
||||
"missingCheckpointPath": "Checkpoint 경로를 사용할 수 없습니다",
|
||||
"missingCheckpointInfo": "Checkpoint 정보가 부족합니다",
|
||||
"downloadCheckpointFailed": "Checkpoint 다운로드 실패: {message}",
|
||||
"enterCheckpointName": "Checkpoint 이름을 입력하세요",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint가 성공적으로 다시 연결되었습니다",
|
||||
"reconnectCheckpointBaseModelMismatch": "다시 연결했지만 베이스 모델이 다릅니다(레시피: {recipe}, Checkpoint: {checkpoint}) — 아키텍처 호환입니다",
|
||||
"checkpointReconnectFailed": "Checkpoint 다시 연결 오류: {message}",
|
||||
"checkpointRestored": "Checkpoint가 이전 연결 상태로 복원되었습니다",
|
||||
"checkpointRestoreFailed": "Checkpoint 복원 오류: {message}",
|
||||
"checkpointDownloadUnavailable": "CivitAI 식별자가 없어 이 Checkpoint를 다운로드할 수 없습니다 - 로컬 Checkpoint로 다시 연결해 보세요",
|
||||
"missingLoraDownloadInfo": "이 LoRA의 다운로드 정보가 없습니다",
|
||||
"hashNotFoundOnCivitai": "이 LoRA 해시는 CivitAI에서 해석할 수 없습니다 - 모델이 업데이트되었거나 해시가 유효하지 않을 수 있습니다",
|
||||
"downloadLoraFailed": "LoRA 다운로드 실패: {message}",
|
||||
"cannotDelete": "레시피를 삭제할 수 없습니다: 레시피 ID 누락",
|
||||
"deleteConfirmationError": "삭제 확인 표시 오류",
|
||||
"deletedSuccessfully": "레시피가 성공적으로 삭제되었습니다",
|
||||
@@ -1976,17 +2143,18 @@
|
||||
"processingError": "처리 오류: {message}",
|
||||
"folderBrowserError": "폴더 브라우저 로딩 오류: {message}",
|
||||
"recipeSaveFailed": "레시피 저장 실패: {error}",
|
||||
"recipeSaved": "Recipe saved successfully",
|
||||
"recipeSaved": "레시피가 저장되었습니다",
|
||||
"importFailed": "가져오기 실패: {message}",
|
||||
"folderTreeFailed": "폴더 트리 로딩 실패",
|
||||
"folderTreeError": "폴더 트리 로딩 오류",
|
||||
"batchImportFailed": "Failed to start batch import: {message}",
|
||||
"batchImportCancelling": "Cancelling batch import...",
|
||||
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
|
||||
"batchImportNoUrls": "Please enter at least one URL or file path",
|
||||
"batchImportNoDirectory": "Please enter a directory path",
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"batchImportFailed": "일괄 가져오기를 시작하지 못했습니다: {message}",
|
||||
"batchImportCancelling": "일괄 가져오기를 취소하는 중...",
|
||||
"batchImportCancelFailed": "일괄 가져오기를 취소하지 못했습니다: {message}",
|
||||
"batchImportNoUrls": "URL 또는 파일 경로를 하나 이상 입력하세요",
|
||||
"batchImportNoDirectory": "폴더 경로를 입력하세요",
|
||||
"batchImportRateLimited": "메타데이터 제공자 속도 제한 도달 — 요청이 느려지고 일부 항목이 건너뛰어질 수 있습니다. 나중에 가져오기를 다시 실행할 수 있습니다.",
|
||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||
@@ -2002,7 +2170,10 @@
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
|
||||
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요.",
|
||||
"workflowSent": "워크플로를 ComfyUI로 보냈습니다",
|
||||
"workflowSendFailed": "워크플로를 ComfyUI로 보내지 못했습니다: {error}",
|
||||
"workflowNoWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "선택된 모델이 없습니다",
|
||||
@@ -2039,8 +2210,8 @@
|
||||
"bulkUpdatesChecking": "선택한 {type}의 업데이트를 확인하는 중...",
|
||||
"bulkUpdatesSuccess": "선택한 {count}개의 {type}에 사용할 수 있는 업데이트가 있습니다",
|
||||
"bulkUpdatesNone": "선택한 {type}에 대한 업데이트가 없습니다",
|
||||
"bulkUpdatesMissing": "선택한 {type}이 Civitai 업데이트에 연결되어 있지 않습니다",
|
||||
"bulkUpdatesPartialMissing": "Civitai 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
|
||||
"bulkUpdatesMissing": "선택한 {type}이 CivitAI 업데이트에 연결되어 있지 않습니다",
|
||||
"bulkUpdatesPartialMissing": "CivitAI 링크가 없는 {missing}개의 {type}을 건너뛰었습니다",
|
||||
"bulkUpdatesFailed": "선택한 {type}의 업데이트 확인에 실패했습니다: {message}",
|
||||
"invalidCharactersRemoved": "파일명에서 잘못된 문자가 제거되었습니다",
|
||||
"filenameCannotBeEmpty": "파일 이름은 비어있을 수 없습니다",
|
||||
@@ -2080,8 +2251,8 @@
|
||||
"compactModeToggled": "컴팩트 모드 {state}",
|
||||
"settingSaveFailed": "설정 저장 실패: {message}",
|
||||
"displayDensitySet": "표시 밀도가 {density}로 설정되었습니다",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"libraryLoadFailed": "라이브러리를 불러오지 못했습니다: {message}",
|
||||
"libraryActivateFailed": "라이브러리를 활성화하지 못했습니다: {message}",
|
||||
"languageChangeFailed": "언어 변경 실패: {message}",
|
||||
"cacheCleared": "캐시 파일이 성공적으로 지워졌습니다. 다음 작업 시 캐시가 재구축됩니다.",
|
||||
"cacheClearFailed": "캐시 지우기 실패: {error}",
|
||||
@@ -2166,10 +2337,11 @@
|
||||
"contextMenu": {
|
||||
"contentRatingSet": "콘텐츠 등급이 {level}로 설정되었습니다",
|
||||
"contentRatingFailed": "콘텐츠 등급 설정 실패: {message}",
|
||||
"relinkSuccess": "모델이 Civitai에 성공적으로 다시 연결되었습니다",
|
||||
"relinkSuccess": "모델이 CivitAI에 성공적으로 다시 연결되었습니다",
|
||||
"relinkFailed": "오류: {message}",
|
||||
"linkHfSuccess": "모델이 HuggingFace에 연결되었습니다",
|
||||
"linkHfFailed": "오류: {message}",
|
||||
"linkCivArchSuccess": "모델이 CivitArchive을 통해 성공적으로 다시 연결되었습니다",
|
||||
"fetchMetadataFirst": "먼저 CivitAI에서 메타데이터를 가져와주세요",
|
||||
"noCivitaiInfo": "사용 가능한 CivitAI 정보가 없습니다",
|
||||
"missingHash": "모델 해시를 사용할 수 없습니다"
|
||||
@@ -2229,7 +2401,7 @@
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"moveFailed": "항목을 이동하지 못했습니다: {message}",
|
||||
"copiedToClipboard": "클립보드에 복사됨",
|
||||
"downloadStarted": "다운로드 시작됨"
|
||||
},
|
||||
@@ -2259,7 +2431,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 키"
|
||||
"title": "CivitAI API 키"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "모델 캐시 상태"
|
||||
@@ -2297,7 +2469,7 @@
|
||||
"conflictConfirm": {
|
||||
"title": "파일명 충돌 해결",
|
||||
"message": "중복 파일명에 4자리 해시를 추가하여 이름을 변경합니다.",
|
||||
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로우의 모델 참조를 업데이트해야 할 수 있습니다.",
|
||||
"note": "이 작업은 디스크에 있는 파일의 이름을 변경합니다. A1111 구문 형식을 사용하는 경우 기존 워크플로의 모델 참조를 업데이트해야 할 수 있습니다.",
|
||||
"detail": "예시: <code>filename_v1.2</code> → <code>filename_v1.2-ab3c</code>",
|
||||
"impact": "<strong>{groups}</strong>개 중복 그룹에서 <strong>{count}</strong>개 파일 이름을 변경합니다",
|
||||
"confirm": "파일 이름 변경",
|
||||
@@ -2312,10 +2484,10 @@
|
||||
"seconds": "초"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
"title": "여러분의 지원으로 LoRA Manager가 계속 성장합니다 ❤️",
|
||||
"content": "LoRA Manager는 한 명의 개발자가 전담으로 유지하는 열정적인 프로젝트입니다. Ko-fi에서의 지원은 개발 비용을 충당하고 새로운 업데이트를 제공하는 데 도움이 되며, 감사의 의미로 LM CivitAI 확장 기능의 라이선스 키를 드립니다. 모든 기여가 실질적인 차이를 만듭니다.",
|
||||
"supportCta": "Ko-fi에서 지원하기",
|
||||
"learnMore": "LM CivitAI 확장 기능 튜토리얼"
|
||||
},
|
||||
"cacheHealth": {
|
||||
"corrupted": {
|
||||
|
||||
+359
-187
File diff suppressed because it is too large
Load Diff
+281
-109
@@ -67,11 +67,11 @@
|
||||
"steps": {
|
||||
"fetch": {
|
||||
"title": "获取模型元数据",
|
||||
"content": "点击 <strong>获取</strong> 按钮,从 Civitai 下载模型元数据和预览图片。"
|
||||
"content": "点击 <strong>获取</strong> 按钮,从 CivitAI 下载模型元数据和预览图片。"
|
||||
},
|
||||
"download": {
|
||||
"title": "下载新模型",
|
||||
"content": "使用 <strong>下载</strong> 按钮,可直接通过 Civitai URL 下载模型。"
|
||||
"content": "使用 <strong>下载</strong> 按钮,可直接通过 CivitAI URL 下载模型。"
|
||||
},
|
||||
"bulk": {
|
||||
"title": "批量操作",
|
||||
@@ -103,12 +103,12 @@
|
||||
"actions": {
|
||||
"addToFavorites": "添加到收藏",
|
||||
"removeFromFavorites": "从收藏移除",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"notAvailableFromCivitai": "Civitai 上不可用",
|
||||
"viewOnCivitai": "在 CivitAI 查看",
|
||||
"notAvailableFromCivitai": "CivitAI 上不可用",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
|
||||
"copyLoRASyntax": "复制 LoRA 语法",
|
||||
"checkpointNameCopied": "检查点名称已复制",
|
||||
"checkpointNameCopied": "Checkpoint 名称已复制",
|
||||
"toggleBlur": "切换模糊",
|
||||
"show": "显示",
|
||||
"openExampleImages": "打开示例图片文件夹",
|
||||
@@ -131,13 +131,13 @@
|
||||
"updateFailed": "收藏状态更新失败"
|
||||
},
|
||||
"sendToWorkflow": {
|
||||
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
|
||||
"checkpointNotImplemented": "发送Checkpoint到工作流 - 功能待实现",
|
||||
"missingPath": "无法确定此卡片的模型路径"
|
||||
},
|
||||
"exampleImages": {
|
||||
"checkError": "检查示例图片时出错",
|
||||
"missingHash": "缺少模型哈希信息。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
|
||||
"noRemoteImagesAvailable": "此模型在 CivitAI 上没有远程示例图片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
@@ -187,14 +187,14 @@
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "将食谱重新匹配到本地模型",
|
||||
"loading": "正在将食谱重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
|
||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
|
||||
"allFailed": "{failures}/{total} 个食谱重新匹配失败",
|
||||
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
|
||||
"error": "食谱重新匹配失败:{message}"
|
||||
"label": "将配方重新匹配到本地模型",
|
||||
"loading": "正在将配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
||||
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||
"error": "配方重新匹配失败:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
@@ -222,6 +222,7 @@
|
||||
"modelname": "模型名称",
|
||||
"tags": "标签",
|
||||
"creator": "创作者",
|
||||
"hash": "哈希",
|
||||
"title": "配方标题",
|
||||
"loraName": "LoRA 文件名",
|
||||
"loraModel": "LoRA 模型名称",
|
||||
@@ -259,7 +260,11 @@
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "匹配任一标签 (或)",
|
||||
"tagLogicAll": "匹配所有标签 (与)"
|
||||
"tagLogicAll": "匹配所有标签 (与)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺失 LoRA",
|
||||
"availabilityDeleted": "包含已删除 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切换主题",
|
||||
@@ -285,15 +290,15 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"civitaiApiKey": "Civitai API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
|
||||
"civitaiApiKey": "CivitAI API 密钥",
|
||||
"civitaiApiKeyPlaceholder": "请输入你的 CivitAI API 密钥",
|
||||
"civitaiApiKeyHelp": "用于从 CivitAI 下载模型时的身份验证",
|
||||
"civitaiApiKeyConfigured": "已配置",
|
||||
"civitaiApiKeyNotConfigured": "未配置",
|
||||
"civitaiApiKeySet": "设置",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站点",
|
||||
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
|
||||
"label": "CivitAI 站点",
|
||||
"help": "选择使用“在 CivitAI 中查看”时默认打开的 CivitAI 站点。",
|
||||
"options": {
|
||||
"com": "civitai.com(仅 SFW)",
|
||||
"red": "civitai.red(无限制)"
|
||||
@@ -314,8 +319,8 @@
|
||||
},
|
||||
"aria2HelpLink": "了解如何配置 aria2 下载后端",
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站点偏好设置",
|
||||
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
"title": "已提供 CivitAI 站点偏好设置",
|
||||
"content": "CivitAI 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
|
||||
"openSettings": "打开设置"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
@@ -423,7 +428,7 @@
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "跳过这些基础模型的下载",
|
||||
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
|
||||
"help": "启用后,使用所选基础模型的版本将被跳过。",
|
||||
"searchPlaceholder": "筛选基础模型...",
|
||||
"empty": "没有与当前搜索匹配的基础模型。",
|
||||
"summary": {
|
||||
@@ -445,7 +450,7 @@
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "默认",
|
||||
@@ -550,7 +555,7 @@
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "下载路径模板",
|
||||
"help": "配置从 Civitai 下载不同模型类型的文件夹结构。",
|
||||
"help": "配置从 CivitAI 下载不同模型类型的文件夹结构。",
|
||||
"availablePlaceholders": "可用占位符:",
|
||||
"templateOptions": {
|
||||
"flatStructure": "扁平结构",
|
||||
@@ -587,7 +592,7 @@
|
||||
"exampleImages": {
|
||||
"downloadLocation": "下载位置",
|
||||
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
||||
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
|
||||
"downloadLocationHelp": "输入保存从 CivitAI 下载的示例图片的文件夹路径",
|
||||
"autoDownload": "自动下载示例图片",
|
||||
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
|
||||
"openMode": "打开示例图片操作",
|
||||
@@ -620,11 +625,11 @@
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "隐藏抢先体验更新",
|
||||
"help": "抢先体验更新"
|
||||
"help": "启用后,仅有抢先体验更新的模型将不显示“可更新”徽章。"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "隐藏付费更新",
|
||||
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版许可协议图标",
|
||||
@@ -642,7 +647,7 @@
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "启用元数据归档数据库",
|
||||
"enableArchiveDbHelp": "使用本地数据库访问已从 Civitai 删除的模型元数据。",
|
||||
"enableArchiveDbHelp": "使用本地数据库访问已从 CivitAI 删除的模型元数据。",
|
||||
"status": "状态",
|
||||
"statusAvailable": "可用",
|
||||
"statusUnavailable": "不可用",
|
||||
@@ -691,7 +696,7 @@
|
||||
"aiProvider": {
|
||||
"title": "AI 提供商",
|
||||
"provider": "提供商",
|
||||
"providerHelp": "选择您的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许您指定任何兼容 OpenAI 的端点。",
|
||||
"providerHelp": "选择你的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许你指定任何兼容 OpenAI 的端点。",
|
||||
"providerOptions": {
|
||||
"openai": "OpenAI",
|
||||
"ollama": "Ollama(本地)",
|
||||
@@ -706,7 +711,7 @@
|
||||
"apiBaseHelp": "LLM API 的基础地址。选择预设或输入自定义地址,下拉框显示所有支持的提供商预设。",
|
||||
"apiBasePlaceholder": "https://api.openai.com/v1",
|
||||
"apiKey": "API 密钥",
|
||||
"apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除您选择的 LLM 提供商外不会发送到任何服务器。",
|
||||
"apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除你选择的 LLM 提供商外不会发送到任何服务器。",
|
||||
"apiKeyPlaceholder": "sk-...",
|
||||
"apiKeyNotSet": "未设置",
|
||||
"apiKeyConfigured": "已配置",
|
||||
@@ -745,7 +750,7 @@
|
||||
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "从 Civitai 获取元数据",
|
||||
"title": "从 CivitAI 获取元数据",
|
||||
"action": "获取"
|
||||
},
|
||||
"download": {
|
||||
@@ -820,10 +825,10 @@
|
||||
"enrichHfAgent": "AI HF 元数据增强"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 数据",
|
||||
"refreshMetadata": "刷新 CivitAI 数据",
|
||||
"checkUpdates": "检查更新",
|
||||
"linkModel": "链接模型",
|
||||
"linkCivitai": "链接到 Civitai",
|
||||
"linkCivitai": "链接到 CivitAI",
|
||||
"linkHuggingFace": "链接到 HuggingFace",
|
||||
"copySyntax": "复制 LoRA 语法",
|
||||
"copyFilename": "复制模型文件名",
|
||||
@@ -853,20 +858,130 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "发送到 ComfyUI"
|
||||
"sendCheckpoint": "发送到 ComfyUI",
|
||||
"sendRecipe": "发送到 ComfyUI",
|
||||
"copyRecipeSyntax": "复制配方语法",
|
||||
"deleteRecipeWithShortcut": "删除配方(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "配方导航",
|
||||
"previousWithShortcut": "上一个配方(←)",
|
||||
"nextWithShortcut": "下一个配方(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "打开文件位置",
|
||||
"copyId": "复制配方 ID"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
"failed": "打开文件位置失败",
|
||||
"copied": "路径已复制到剪贴板:{{path}}",
|
||||
"clipboardFallback": "路径:{{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "发送工作流到 ComfyUI",
|
||||
"sent": "工作流已发送到 ComfyUI",
|
||||
"sendFailed": "发送工作流到 ComfyUI 失败",
|
||||
"noWorkflow": "此配方中未找到内嵌工作流"
|
||||
},
|
||||
"status": {
|
||||
"ready": "可直接使用",
|
||||
"missingCount": "缺失 {count} 个",
|
||||
"deletedCount": "已删除 {count} 个",
|
||||
"downloadMissing": "下载 {count} 个缺失的 LoRA",
|
||||
"downloadMissingTooltip": "点击下载缺失的 LoRA"
|
||||
},
|
||||
"loraStatus": {
|
||||
"none": "此配方不包含 LoRA",
|
||||
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
|
||||
"missing": "{total} 个 LoRA 中缺失 {missing} 个",
|
||||
"missingAndUnavailable": "{total} 个 LoRA 中缺失 {missing} 个,{unavailable} 个不可用(已从源站删除或哈希无法解析)",
|
||||
"partial": "{total} 个 LoRA 中 {unavailable} 个不可用(已从源站删除或哈希无法解析)- 使用配方时将被跳过",
|
||||
"noneUsable": "没有可用的 LoRA - {total} 个中 {unavailable} 个已从源站删除或哈希无法解析"
|
||||
},
|
||||
"resources": {
|
||||
"inLibrary": "在库中",
|
||||
"notInLibrary": "不在库中",
|
||||
"deleted": "已删除",
|
||||
"hashInvalid": "无法解析的哈希",
|
||||
"inLibraryTooltip": "该模型已存在于本地库中",
|
||||
"notInLibraryTooltip": "该模型不在你的本地库中",
|
||||
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
|
||||
"hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方没有关联任何 LoRA",
|
||||
"noLorasWhyToggle": "为什么没有 LoRA?",
|
||||
"noLorasImportMethod": "导入方式",
|
||||
"noLorasInferredNote": "可能的原因(推断)——该配方是在记录导入诊断信息之前导入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量导入(图片 URL)",
|
||||
"batch_import_local": "批量导入(本地文件)",
|
||||
"url": "图片 URL 导入",
|
||||
"local": "本地文件导入",
|
||||
"upload": "图片上传",
|
||||
"widget": "从工作流保存",
|
||||
"reimport_url": "重新导入(图片 URL)",
|
||||
"reimport_local": "重新导入(本地文件)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成元数据完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "来源 API 未返回此图片的 LoRA 资源数据。CivitAI 页面上显示的 LoRA 可能来自公开 API 未开放的内部数据。",
|
||||
"api_meta_missing": "来源 API 未返回此图片的生成元数据。",
|
||||
"no_embedded_metadata": "图片没有内嵌生成元数据,因此无法恢复 LoRA 信息。",
|
||||
"workflow_metadata_limited": "图片内嵌的元数据是 ComfyUI 工作流;从工作流中提取 LoRA 信息的能力有限。",
|
||||
"video_no_metadata": "视频文件不携带内嵌生成元数据。",
|
||||
"metadata_unsupported": "图片包含的元数据格式无法解析。",
|
||||
"unknown": "无法从存储的配方数据中确定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 元数据字段",
|
||||
"modelVersionIds": "报告的模型版本 ID 数",
|
||||
"embeddedMetadata": "内嵌元数据",
|
||||
"present": "已找到",
|
||||
"absent": "无"
|
||||
},
|
||||
"download": "下载",
|
||||
"downloadLoraTooltip": "下载此 LoRA",
|
||||
"preparingDownload": "正在准备下载...",
|
||||
"reconnect": "重新关联",
|
||||
"reconnectTooltip": "与本地 LoRA 重新关联",
|
||||
"reconnectInstructions": "输入 LoRA 语法或名称以重新关联:",
|
||||
"reconnectExample": "示例:<lora:name:1> 或只填名称",
|
||||
"reconnectPlaceholder": "输入 LoRA 名称或语法",
|
||||
"reconnectSuggestionsLoading": "正在搜索本地库...",
|
||||
"reconnectSuggestionsEmpty": "本地库中没有匹配的 LoRA",
|
||||
"reconnectMatchSameHash": "相同哈希",
|
||||
"reconnectMatchSameVersion": "相同模型版本",
|
||||
"reconnectMatchSimilarFilename": "相似文件名",
|
||||
"reconnectMatchSimilarName": "相似名称",
|
||||
"undoReconnect": "撤销",
|
||||
"undoReconnectTooltip": "恢复此条目在重新关联前的关联",
|
||||
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
|
||||
"viewOnCivitai": "在 CivitAI 上查看",
|
||||
"openLoraDetails": "在 LoRA 库中查看 {name}",
|
||||
"openCheckpointDetails": "在模型库中查看 {name}",
|
||||
"checkpointDeletedTooltip": "此 Checkpoint 已从来源删除,无法再下载 - 请使用本地模型重新关联",
|
||||
"checkpointHashInvalidTooltip": "此 Checkpoint 的哈希无法在 CivitAI 上解析 - 模型可能已更新",
|
||||
"reconnectCheckpoint": "重新关联",
|
||||
"reconnectCheckpointTooltip": "与本地 Checkpoint 重新关联",
|
||||
"checkpointReconnectInstructions": "输入 Checkpoint 名称以重新关联:",
|
||||
"checkpointReconnectPlaceholder": "输入 Checkpoint 名称",
|
||||
"checkpointReconnectSuggestionsEmpty": "本地库中没有匹配的 Checkpoint"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "导入",
|
||||
"title": "从图片或 URL 导入配方",
|
||||
"urlLocalPath": "URL / 本地路径",
|
||||
"uploadImage": "上传图片",
|
||||
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
|
||||
"dropZoneLabel": "上传图片",
|
||||
"dropZoneHint": "将图片拖拽到此处、从剪贴板粘贴,或点击浏览",
|
||||
"orDivider": "或拖拽 / 粘贴图片",
|
||||
"imageUrlOrPath": "图片 URL 或文件路径:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "获取图片",
|
||||
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
|
||||
"selectImage": "选择图片",
|
||||
"recipeName": "配方名称",
|
||||
"recipeNamePlaceholder": "输入配方名称",
|
||||
"tagsOptional": "标签(可选)",
|
||||
@@ -874,7 +989,7 @@
|
||||
"addTag": "添加",
|
||||
"noTagsAdded": "未添加标签",
|
||||
"lorasInRecipe": "此配方中的 LoRA",
|
||||
"downloadLocationPreview": "下载位置预览:{path}",
|
||||
"downloadLocationPreview": "下载位置预览:",
|
||||
"useDefaultPath": "使用默认路径",
|
||||
"useDefaultPathTooltip": "启用后,文件将自动使用配置的路径模板进行组织",
|
||||
"selectLoraRoot": "选择 LoRA 根目录",
|
||||
@@ -888,20 +1003,20 @@
|
||||
"importAndDownload": "导入并下载",
|
||||
"downloadMissingLoras": "下载缺失的 LoRA",
|
||||
"saveRecipe": "保存配方",
|
||||
"loraCountInfo": "({existing}/{total} in library)",
|
||||
"loraCountInfo": "(库中 {existing}/{total})",
|
||||
"processingInput": "处理输入...",
|
||||
"analyzingMetadata": "分析图像元数据...",
|
||||
"downloadingLoras": "下载 LoRA...",
|
||||
"savingRecipe": "保存配方...",
|
||||
"startingDownload": "开始下载 LoRA {current}/{total}",
|
||||
"deletedFromCivitai": "从 Civitai 中删除",
|
||||
"deletedFromCivitai": "从 CivitAI 中删除",
|
||||
"inLibrary": "在库中",
|
||||
"notInLibrary": "不在库中",
|
||||
"earlyAccessRequired": "此 LoRA 需要提前访问权限才能下载。",
|
||||
"earlyAccessEnds": "提前访问权限将于 {date} 结束。",
|
||||
"earlyAccess": "提前访问",
|
||||
"verifyEarlyAccess": "在下载之前,请验证您是否已购买提前访问权限。",
|
||||
"duplicateRecipesFound": "在您的库中找到 {count} 个相同的配方。",
|
||||
"verifyEarlyAccess": "在下载之前,请确认你已购买提前访问权限。",
|
||||
"duplicateRecipesFound": "在你的库中找到 {count} 个相同的配方。",
|
||||
"duplicateRecipesDescription": "这些配方包含相同的 LoRA,权重完全相同。",
|
||||
"showDuplicates": "显示重复项",
|
||||
"hideDuplicates": "隐藏重复项",
|
||||
@@ -911,6 +1026,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "请选择一个图像文件",
|
||||
"enterUrlOrPath": "请输入 URL 或文件路径",
|
||||
"invalidUrl": "请输入有效的 URL",
|
||||
"invalidInputFormat": "请输入图片 URL 或本地图片文件路径",
|
||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +1062,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "正在扫描重复配方...",
|
||||
"found": "发现 {count} 个重复组",
|
||||
"noGroups": "按当前判重依据未找到重复组",
|
||||
"keepLatest": "保留最新版本",
|
||||
@@ -1014,6 +1132,8 @@
|
||||
"start": "开始导入",
|
||||
"startImport": "开始导入",
|
||||
"importing": "正在导入配方...",
|
||||
"rateLimitedSlowdown": "触发速率限制 — 正在减速...",
|
||||
"rateLimitedHint": "部分条目因元数据提供方的速率限制而被跳过。稍后重新运行导入即可重试这些条目。",
|
||||
"progress": "进度",
|
||||
"total": "总计",
|
||||
"success": "成功",
|
||||
@@ -1055,7 +1175,7 @@
|
||||
"title": "Checkpoint 模型",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
"diffusion_model": "扩散模型"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
|
||||
@@ -1079,7 +1199,7 @@
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "Move is not supported for this item.",
|
||||
"moveUnsupported": "此条目不支持移动。",
|
||||
"createFolderHint": "释放以创建新文件夹",
|
||||
"newFolderName": "新文件夹名称",
|
||||
"folderNameHint": "按 Enter 确认,Escape 取消",
|
||||
@@ -1217,7 +1337,7 @@
|
||||
"download": {
|
||||
"title": "从 URL 下载模型",
|
||||
"titleWithType": "从 URL 下载 {type}",
|
||||
"civitaiUrl": "Civitai URL:",
|
||||
"civitaiUrl": "CivitAI URL:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
|
||||
"selectHfFiles": "选择从此仓库下载的文件:",
|
||||
@@ -1243,14 +1363,16 @@
|
||||
"downloaded": "已下载",
|
||||
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
|
||||
"alreadyInLibrary": "已存在于库中",
|
||||
"partiallyDownloaded": "部分已下载",
|
||||
"autoOrganizedPath": "【已按路径模板自动整理】",
|
||||
"fileSelection": {
|
||||
"title": "选择文件格式",
|
||||
"files": "个文件",
|
||||
"select": "选择文件"
|
||||
"select": "选择文件",
|
||||
"inLibrary": "已在库中"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "无效的 Civitai URL 格式",
|
||||
"invalidUrl": "无效的 CivitAI URL 格式",
|
||||
"noVersions": "此模型没有可用版本",
|
||||
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"noModelFiles": "在此仓库中未找到模型文件。"
|
||||
@@ -1329,8 +1451,8 @@
|
||||
"action": "全部删除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "检查所有 {type} 的更新?",
|
||||
"message": "这会为库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
|
||||
"title": "检查所有 {typePlural} 的更新?",
|
||||
"message": "这会检查库中的每个 {typePlural} 的更新,大型集合可能需要一些时间。",
|
||||
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
|
||||
"action": "检查全部"
|
||||
},
|
||||
@@ -1364,7 +1486,7 @@
|
||||
"title": "本地示例图片",
|
||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||
"downloadOption": {
|
||||
"title": "从 Civitai 下载",
|
||||
"title": "从 CivitAI 下载",
|
||||
"description": "将远程示例保存到本地,便于离线使用和更快加载"
|
||||
},
|
||||
"importOption": {
|
||||
@@ -1391,7 +1513,7 @@
|
||||
"confirmAction": "保存并链接"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新关联到 Civitai",
|
||||
"title": "重新关联到 CivitAI",
|
||||
"warning": "警告:",
|
||||
"warningText": "这是一个有潜在风险的操作。重新关联将:",
|
||||
"warningList": {
|
||||
@@ -1400,14 +1522,15 @@
|
||||
"unintendedConsequences": "可能有其他不可预期的后果"
|
||||
},
|
||||
"proceedText": "仅在你确定需要此操作时继续。",
|
||||
"urlLabel": "Civitai 模型 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
|
||||
"urlLabel": "CivitAI 模型 URL:",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/models/12345/model-name?modelVersionId=67890",
|
||||
"helpText": {
|
||||
"title": "粘贴任意来自 civitai.com 或 civitai.red 的 Civitai 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/649516",
|
||||
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
|
||||
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"note": "注意:如果未提供 modelVersionId,将使用最新版本。"
|
||||
"title": "粘贴任意 CivitAI 或 CivitArchive 模型 URL。支持格式:",
|
||||
"format1": "https://civitai.com/models/12345",
|
||||
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
|
||||
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
|
||||
"note": "注意:如果未提供 modelVersionId,将使用最新版本。",
|
||||
"format4": "https://civarchive.com/models/12345 (CivArchive)"
|
||||
},
|
||||
"confirmAction": "确认重新关联"
|
||||
},
|
||||
@@ -1417,14 +1540,16 @@
|
||||
"editFileName": "编辑文件名",
|
||||
"editBaseModel": "编辑基础模型",
|
||||
"editVersionName": "编辑版本名称",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewOnCivitai": "在 CivitAI 查看",
|
||||
"viewOnCivitaiText": "在 CivitAI 查看",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看创作者主页",
|
||||
"openFileLocation": "打开文件位置",
|
||||
"sendToWorkflow": "发送到 ComfyUI",
|
||||
"sendToWorkflowText": "发送到 ComfyUI"
|
||||
"sendToWorkflowText": "发送到 ComfyUI",
|
||||
"copyHash": "复制哈希值",
|
||||
"deleteModelWithShortcut": "删除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "文件位置已成功打开",
|
||||
@@ -1441,13 +1566,14 @@
|
||||
"location": "位置",
|
||||
"baseModel": "基础模型",
|
||||
"size": "大小",
|
||||
"hashes": "哈希值",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加备注",
|
||||
"notesHint": "回车保存,Shift+回车换行",
|
||||
"addNotesPlaceholder": "在此添加你的备注...",
|
||||
"aboutThisVersion": "关于此版本",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型…",
|
||||
"baseModelSearchPlaceholder": "搜索基础模型...",
|
||||
"baseModelSuggested": "推荐",
|
||||
"baseModelNoMatch": "没有匹配的基础模型"
|
||||
},
|
||||
@@ -1516,10 +1642,10 @@
|
||||
"noNext": "没有下一个模型"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"noImageSell": "禁止出售生成的图片",
|
||||
"noRentCivit": "禁止在 CivitAI 上生成",
|
||||
"noRent": "禁止生成服务",
|
||||
"noSell": "禁止出售模型",
|
||||
"creditRequired": "需要创作者署名",
|
||||
"noDerivatives": "禁止分享合并作品",
|
||||
"noReLicense": "需要相同权限",
|
||||
@@ -1532,6 +1658,30 @@
|
||||
"examples": "正在加载示例...",
|
||||
"versions": "正在加载版本..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "{count} 张因仅显示 SFW 设置而被隐藏",
|
||||
"showExamples": "显示示例",
|
||||
"showCount": "显示示例({count})",
|
||||
"hideExamples": "隐藏示例",
|
||||
"addExamples": "添加示例",
|
||||
"previousExample": "上一个示例([)",
|
||||
"nextExample": "下一个示例(])",
|
||||
"noExamples": "暂无示例图片",
|
||||
"addMoreExamples": "添加更多示例",
|
||||
"dragDrop": "将图片或视频拖放到此处",
|
||||
"or": "或",
|
||||
"selectFiles": "选择文件",
|
||||
"supportedFormats": "支持的格式:jpg, png, gif, webp, avif, jxl, mp4, webm",
|
||||
"importing": "正在导入文件...",
|
||||
"noSupportedFiles": "未选择受支持的文件。请选择图片或视频文件。",
|
||||
"allFiltered": "所有示例图片均因 NSFW 内容设置而被过滤",
|
||||
"sfwOnlyEnabled": "你当前的设置为仅显示 SFW 内容",
|
||||
"changeInSettings": "你可以在设置中更改此选项",
|
||||
"nsfwMature": "成熟内容",
|
||||
"nsfwR": "R 级内容",
|
||||
"nsfwX": "X 级内容",
|
||||
"nsfwXxx": "XXX 级内容"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在一个位置管理该模型的所有版本。",
|
||||
@@ -1558,48 +1708,49 @@
|
||||
"newer": "较新的版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"earlyAccess": "抢先体验",
|
||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"earlyAccessTooltip": "此版本当前需要 CivitAI 抢先体验权限",
|
||||
"paid": "付费",
|
||||
"paidTooltip": "此版本需要付费后才能下载",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已关闭更新通知",
|
||||
"onSiteOnly": "仅站内生成",
|
||||
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
|
||||
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下载",
|
||||
"downloadTooltip": "下载此版本",
|
||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||
"downloadChooseFilesTooltip": "选择要下载的文件",
|
||||
"downloadEarlyAccessTooltip": "从 CivitAI 下载此抢先体验版本",
|
||||
"downloadPaidTooltip": "从 CivitAI 下载此付费版本",
|
||||
"downloadNotAllowedTooltip": "此版本仅在 CivitAI 站内可用,无法下载",
|
||||
"delete": "删除",
|
||||
"deleteTooltip": "删除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢复此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"viewVersionOnCivitai": "在 CivitAI 上查看版本",
|
||||
"earlyAccessTooltip": "需要购买抢先体验",
|
||||
"resumeModelUpdates": "继续跟踪该模型的更新",
|
||||
"ignoreModelUpdates": "忽略该模型的更新",
|
||||
"viewLocalVersions": "查看所有本地版本",
|
||||
"viewLocalTooltip": "敬请期待"
|
||||
"viewLocalTooltip": "在主页面上显示该模型的所有本地版本"
|
||||
},
|
||||
"filters": {
|
||||
"label": "基础筛选",
|
||||
"state": {
|
||||
"showAll": "全部版本",
|
||||
"showSameBase": "相同基模型"
|
||||
"showSameBase": "相同基础模型"
|
||||
},
|
||||
"tooltip": {
|
||||
"showAllVersions": "切换为显示所有版本",
|
||||
"showSameBaseVersions": "仅显示与当前基模型匹配的版本"
|
||||
"showSameBaseVersions": "仅显示与当前基础模型匹配的版本"
|
||||
},
|
||||
"empty": "没有与当前基模型筛选匹配的版本。"
|
||||
"empty": "没有与当前基础模型筛选匹配的版本。"
|
||||
},
|
||||
"empty": "该模型还没有版本历史。",
|
||||
"error": "加载版本失败。",
|
||||
"missingModelId": "该模型缺少 Civitai 模型 ID。",
|
||||
"missingModelId": "该模型缺少 CivitAI 模型 ID。",
|
||||
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
|
||||
"confirm": {
|
||||
"delete": "从库中删除此版本?"
|
||||
@@ -1683,14 +1834,14 @@
|
||||
"tips": {
|
||||
"title": "技巧与提示",
|
||||
"civitai": {
|
||||
"title": "Civitai 集成",
|
||||
"description": "连接你的 Civitai 账号:访问头像 → 设置 → API 密钥 → 添加密钥,然后粘贴到 LoRA 管理器设置中。",
|
||||
"alt": "Civitai API 设置"
|
||||
"title": "CivitAI 集成",
|
||||
"description": "连接你的 CivitAI 账号:访问头像 → 设置 → API 密钥 → 添加密钥,然后粘贴到 LoRA 管理器设置中。",
|
||||
"alt": "CivitAI API 设置"
|
||||
},
|
||||
"download": {
|
||||
"title": "便捷下载",
|
||||
"description": "使用 Civitai URL 快速下载和安装新模型。",
|
||||
"alt": "Civitai 下载"
|
||||
"description": "使用 CivitAI URL 快速下载和安装新模型。",
|
||||
"alt": "CivitAI 下载"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "保存配方",
|
||||
@@ -1768,7 +1919,7 @@
|
||||
"copiedUri": "链接已复制到剪贴板:{{uri}}",
|
||||
"uriClipboardFallback": "链接:{{uri}}",
|
||||
"setupRequired": "示例图片存储",
|
||||
"setupDescription": "要添加自定义示例图片,您需要先设置下载位置。",
|
||||
"setupDescription": "要添加自定义示例图片,你需要先设置下载位置。",
|
||||
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
|
||||
"openSettings": "打开设置"
|
||||
}
|
||||
@@ -1874,7 +2025,7 @@
|
||||
"submitGithubIssue": "提交 GitHub 问题",
|
||||
"joinDiscord": "加入 Discord",
|
||||
"youtubeChannel": "YouTube 频道",
|
||||
"civitaiProfile": "Civitai 个人资料",
|
||||
"civitaiProfile": "CivitAI 个人资料",
|
||||
"supportKofi": "支持 Ko-fi",
|
||||
"supportPatreon": "支持 Patreon"
|
||||
},
|
||||
@@ -1917,6 +2068,7 @@
|
||||
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
|
||||
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
|
||||
"pleaseSelectVersion": "请选择版本",
|
||||
"pleaseSelectFile": "请至少选择一个文件",
|
||||
"versionExists": "该版本已存在于你的库中",
|
||||
"downloadCompleted": "下载成功完成",
|
||||
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
|
||||
@@ -1950,18 +2102,33 @@
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"noPreviousRecipe": "没有上一个配方",
|
||||
"noNextRecipe": "没有下一个配方",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
|
||||
"enterLoraName": "请输入 LoRA 名称或语法",
|
||||
"reconnectedSuccessfully": "LoRA 重新连接成功",
|
||||
"reconnectBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},LoRA:{lora})——两者架构兼容",
|
||||
"reconnectFailed": "LoRA 重新连接出错:{message}",
|
||||
"loraRestored": "LoRA 已恢复为重新关联前的关联",
|
||||
"loraRestoreFailed": "LoRA 恢复出错:{message}",
|
||||
"noPromptToSend": "没有可发送的提示词",
|
||||
"cannotSend": "无法发送配方:缺少配方 ID",
|
||||
"sendFailed": "发送配方到工作流失败",
|
||||
"sendError": "发送配方到工作流出错",
|
||||
"missingCheckpointPath": "缺少检查点路径",
|
||||
"missingCheckpointInfo": "缺少检查点信息",
|
||||
"downloadCheckpointFailed": "下载检查点失败:{message}",
|
||||
"missingCheckpointPath": "缺少Checkpoint路径",
|
||||
"missingCheckpointInfo": "缺少Checkpoint信息",
|
||||
"downloadCheckpointFailed": "下载Checkpoint失败:{message}",
|
||||
"enterCheckpointName": "请输入 Checkpoint 名称",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint 重新连接成功",
|
||||
"reconnectCheckpointBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},Checkpoint:{checkpoint})——两者架构兼容",
|
||||
"checkpointReconnectFailed": "Checkpoint 重新连接出错:{message}",
|
||||
"checkpointRestored": "Checkpoint 已恢复为重新关联前的关联",
|
||||
"checkpointRestoreFailed": "Checkpoint 恢复出错:{message}",
|
||||
"checkpointDownloadUnavailable": "缺少 CivitAI 标识,无法下载此 Checkpoint - 请尝试使用本地 Checkpoint 重新关联",
|
||||
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
|
||||
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
|
||||
"downloadLoraFailed": "下载 LoRA 失败:{message}",
|
||||
"cannotDelete": "无法删除配方:缺少配方 ID",
|
||||
"deleteConfirmationError": "显示删除确认出错",
|
||||
"deletedSuccessfully": "配方删除成功",
|
||||
@@ -1985,24 +2152,28 @@
|
||||
"batchImportCancelFailed": "取消批量导入失败:{message}",
|
||||
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
|
||||
"batchImportNoDirectory": "请输入目录路径",
|
||||
"batchImportRateLimited": "已达到元数据提供方的速率限制 — 请求正在放缓,部分条目可能被跳过。你可以稍后重新运行导入。",
|
||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||
"noRecipesSelected": "未选择任何配方",
|
||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
|
||||
"rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
|
||||
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
|
||||
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
|
||||
"rematchFailed": "重新匹配所选食谱失败:{message}",
|
||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
||||
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
"reimportSuccess": "配方已从源重新导入成功",
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。",
|
||||
"workflowSent": "工作流已发送到 ComfyUI",
|
||||
"workflowSendFailed": "发送工作流到 ComfyUI 失败: {error}",
|
||||
"workflowNoWorkflow": "此配方中未找到内嵌工作流"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未选中模型",
|
||||
@@ -2039,8 +2210,8 @@
|
||||
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所选 {type} 未发现更新",
|
||||
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
|
||||
"bulkUpdatesMissing": "所选 {type} 未关联 CivitAI 更新",
|
||||
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 CivitAI 的所选 {type}",
|
||||
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
|
||||
"invalidCharactersRemoved": "文件名中的无效字符已移除",
|
||||
"filenameCannotBeEmpty": "文件名不能为空",
|
||||
@@ -2069,7 +2240,7 @@
|
||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural})",
|
||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
||||
"mappingsCleared": "基础模型路径映射已清除",
|
||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||
@@ -2080,8 +2251,8 @@
|
||||
"compactModeToggled": "紧凑模式 {state}",
|
||||
"settingSaveFailed": "保存设置失败:{message}",
|
||||
"displayDensitySet": "显示密度已设置为 {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"libraryLoadFailed": "加载模型库失败:{message}",
|
||||
"libraryActivateFailed": "激活模型库失败:{message}",
|
||||
"languageChangeFailed": "切换语言失败:{message}",
|
||||
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
|
||||
"cacheClearFailed": "清除缓存失败:{error}",
|
||||
@@ -2166,10 +2337,11 @@
|
||||
"contextMenu": {
|
||||
"contentRatingSet": "内容评级已设置为 {level}",
|
||||
"contentRatingFailed": "设置内容评级失败:{message}",
|
||||
"relinkSuccess": "模型已成功重新关联到 Civitai",
|
||||
"relinkSuccess": "模型已成功重新关联到 CivitAI",
|
||||
"relinkFailed": "错误:{message}",
|
||||
"linkHfSuccess": "模型已成功链接到 HuggingFace",
|
||||
"linkHfFailed": "错误:{message}",
|
||||
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
|
||||
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
|
||||
"noCivitaiInfo": "无 CivitAI 信息",
|
||||
"missingHash": "模型哈希不可用"
|
||||
@@ -2229,7 +2401,7 @@
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"moveFailed": "移动条目失败:{message}",
|
||||
"copiedToClipboard": "已复制到剪贴板",
|
||||
"downloadStarted": "下载已开始"
|
||||
},
|
||||
@@ -2259,7 +2431,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 密钥"
|
||||
"title": "CivitAI API 密钥"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型缓存健康状态"
|
||||
|
||||
+293
-121
@@ -67,11 +67,11 @@
|
||||
"steps": {
|
||||
"fetch": {
|
||||
"title": "取得模型 metadata",
|
||||
"content": "點擊 <strong>取得</strong> 按鈕,從 Civitai 下載模型 metadata 與預覽圖片。"
|
||||
"content": "點擊 <strong>取得</strong> 按鈕,從 CivitAI 下載模型 metadata 與預覽圖片。"
|
||||
},
|
||||
"download": {
|
||||
"title": "下載新模型",
|
||||
"content": "使用 <strong>下載</strong> 按鈕,直接從 Civitai 網址下載模型。"
|
||||
"content": "使用 <strong>下載</strong> 按鈕,直接從 CivitAI 網址下載模型。"
|
||||
},
|
||||
"bulk": {
|
||||
"title": "批次操作",
|
||||
@@ -103,8 +103,8 @@
|
||||
"actions": {
|
||||
"addToFavorites": "加入收藏",
|
||||
"removeFromFavorites": "移除收藏",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"notAvailableFromCivitai": "Civitai 不提供",
|
||||
"viewOnCivitai": "在 CivitAI 查看",
|
||||
"notAvailableFromCivitai": "CivitAI 不提供",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
|
||||
"copyLoRASyntax": "複製 LoRA 語法",
|
||||
@@ -113,7 +113,7 @@
|
||||
"show": "顯示",
|
||||
"openExampleImages": "開啟範例圖片資料夾",
|
||||
"replacePreview": "更換預覽圖",
|
||||
"copyCheckpointName": "複製檢查點名稱",
|
||||
"copyCheckpointName": "複製 Checkpoint 名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
@@ -137,7 +137,7 @@
|
||||
"exampleImages": {
|
||||
"checkError": "檢查範例圖片時發生錯誤",
|
||||
"missingHash": "缺少模型雜湊資訊。",
|
||||
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
|
||||
"noRemoteImagesAvailable": "此模型在 CivitAI 上無遠端範例圖片"
|
||||
},
|
||||
"badges": {
|
||||
"update": "更新",
|
||||
@@ -187,14 +187,14 @@
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "將食譜重新匹配到本地模型",
|
||||
"loading": "正在將食譜重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
|
||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
|
||||
"allFailed": "{failures}/{total} 個食譜重新匹配失敗",
|
||||
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
|
||||
"error": "食譜重新匹配失敗:{message}"
|
||||
"label": "將配方重新匹配到本地模型",
|
||||
"loading": "正在將配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
||||
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||
"error": "配方重新匹配失敗:{message}"
|
||||
},
|
||||
"manageExcludedModels": {
|
||||
"label": "管理已排除的模型"
|
||||
@@ -222,6 +222,7 @@
|
||||
"modelname": "模型名稱",
|
||||
"tags": "標籤",
|
||||
"creator": "創作者",
|
||||
"hash": "雜湊",
|
||||
"title": "配方標題",
|
||||
"loraName": "LoRA 檔案名稱",
|
||||
"loraModel": "LoRA 模型名稱",
|
||||
@@ -258,8 +259,12 @@
|
||||
"clearAll": "清除所有篩選",
|
||||
"any": "任一",
|
||||
"all": "全部",
|
||||
"tagLogicAny": "符合任一票籤 (或)",
|
||||
"tagLogicAll": "符合所有標籤 (與)"
|
||||
"tagLogicAny": "符合任一標籤 (或)",
|
||||
"tagLogicAll": "符合所有標籤 (與)",
|
||||
"loraAvailability": "LoRA 可用性",
|
||||
"availabilityReady": "可直接使用",
|
||||
"availabilityMissing": "包含缺少的 LoRA",
|
||||
"availabilityDeleted": "包含已刪除的 LoRA"
|
||||
},
|
||||
"theme": {
|
||||
"toggle": "切換主題",
|
||||
@@ -285,15 +290,15 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"civitaiApiKey": "Civitai API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
|
||||
"civitaiApiKey": "CivitAI API 金鑰",
|
||||
"civitaiApiKeyPlaceholder": "請輸入您的 CivitAI API 金鑰",
|
||||
"civitaiApiKeyHelp": "用於從 CivitAI 下載模型時的身份驗證",
|
||||
"civitaiApiKeyConfigured": "已設定",
|
||||
"civitaiApiKeyNotConfigured": "未設定",
|
||||
"civitaiApiKeySet": "設定",
|
||||
"civitaiHost": {
|
||||
"label": "Civitai 站點",
|
||||
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
|
||||
"label": "CivitAI 站點",
|
||||
"help": "選擇使用「在 CivitAI 中查看」時預設開啟的 CivitAI 站點。",
|
||||
"options": {
|
||||
"com": "civitai.com(僅 SFW)",
|
||||
"red": "civitai.red(無限制)"
|
||||
@@ -314,8 +319,8 @@
|
||||
},
|
||||
"aria2HelpLink": "了解如何設定 aria2 下載後端",
|
||||
"civitaiHostBanner": {
|
||||
"title": "已提供 Civitai 站點偏好設定",
|
||||
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。你可以在設定中變更預設開啟的站點。",
|
||||
"title": "已提供 CivitAI 站點偏好設定",
|
||||
"content": "CivitAI 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。您可以在設定中變更預設開啟的站點。",
|
||||
"openSettings": "開啟設定"
|
||||
},
|
||||
"openSettingsFileLocation": {
|
||||
@@ -402,7 +407,7 @@
|
||||
"retentionHelp": "在刪除舊快照之前,要保留多少自動快照。",
|
||||
"management": "備份管理",
|
||||
"managementHelp": "匯出目前的使用者狀態,或從備份封存中還原。",
|
||||
"scopeHelp": "備份你的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
|
||||
"scopeHelp": "備份您的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
|
||||
"locationSummary": "目前備份位置",
|
||||
"openFolderButton": "開啟備份資料夾",
|
||||
"openFolderSuccess": "已開啟備份資料夾",
|
||||
@@ -423,7 +428,7 @@
|
||||
},
|
||||
"downloadSkipBaseModels": {
|
||||
"label": "跳過這些基礎模型的下載",
|
||||
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
|
||||
"help": "啟用後,使用所選基礎模型的版本將被略過。",
|
||||
"searchPlaceholder": "篩選基礎模型...",
|
||||
"empty": "沒有符合目前搜尋條件的基礎模型。",
|
||||
"summary": {
|
||||
@@ -445,7 +450,7 @@
|
||||
},
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "預設",
|
||||
@@ -512,7 +517,7 @@
|
||||
"extraFolderPaths": {
|
||||
"title": "額外資料夾路徑",
|
||||
"description": "LoRA Manager 專屬的額外模型根目錄。從 ComfyUI 標準資料夾之外的位置載入模型,特別適合管理大型模型庫,避免影響 ComfyUI 效能。",
|
||||
"restartRequired": "Requires restart to take effect",
|
||||
"restartRequired": "需要重新啟動才能生效",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA 路徑",
|
||||
"checkpoint": "Checkpoint 路徑",
|
||||
@@ -550,7 +555,7 @@
|
||||
},
|
||||
"downloadPathTemplates": {
|
||||
"title": "下載路徑範本",
|
||||
"help": "設定從 Civitai 下載時不同模型類型的資料夾結構。",
|
||||
"help": "設定從 CivitAI 下載時不同模型類型的資料夾結構。",
|
||||
"availablePlaceholders": "可用佔位符:",
|
||||
"templateOptions": {
|
||||
"flatStructure": "扁平結構",
|
||||
@@ -587,7 +592,7 @@
|
||||
"exampleImages": {
|
||||
"downloadLocation": "下載位置",
|
||||
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
||||
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
|
||||
"downloadLocationHelp": "輸入從 CivitAI 下載範例圖片要儲存的資料夾路徑",
|
||||
"autoDownload": "自動下載範例圖片",
|
||||
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
|
||||
"openMode": "開啟範例圖片動作",
|
||||
@@ -620,11 +625,11 @@
|
||||
},
|
||||
"hideEarlyAccessUpdates": {
|
||||
"label": "隱藏搶先體驗更新",
|
||||
"help": "搶先體驗更新"
|
||||
"help": "啟用後,只有搶先體驗更新的模型將不顯示「可更新」徽章。"
|
||||
},
|
||||
"hidePaidUpdates": {
|
||||
"label": "[TODO: Translate] Hide Paid Updates",
|
||||
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
|
||||
"label": "隱藏付費更新",
|
||||
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
|
||||
},
|
||||
"licenseIcons": {
|
||||
"useNewStyle": "使用新版許可協議圖標",
|
||||
@@ -642,7 +647,7 @@
|
||||
},
|
||||
"metadataArchive": {
|
||||
"enableArchiveDb": "啟用中繼資料封存資料庫",
|
||||
"enableArchiveDbHelp": "使用本機資料庫以存取已從 Civitai 刪除模型的中繼資料。",
|
||||
"enableArchiveDbHelp": "使用本機資料庫以存取已從 CivitAI 刪除模型的中繼資料。",
|
||||
"status": "狀態",
|
||||
"statusAvailable": "可用",
|
||||
"statusUnavailable": "不可用",
|
||||
@@ -745,7 +750,7 @@
|
||||
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
|
||||
},
|
||||
"fetch": {
|
||||
"title": "從 Civitai 取得 metadata",
|
||||
"title": "從 CivitAI 取得 metadata",
|
||||
"action": "取得"
|
||||
},
|
||||
"download": {
|
||||
@@ -820,10 +825,10 @@
|
||||
"enrichHfAgent": "AI HF 中繼資料增強"
|
||||
},
|
||||
"contextMenu": {
|
||||
"refreshMetadata": "刷新 Civitai 資料",
|
||||
"refreshMetadata": "刷新 CivitAI 資料",
|
||||
"checkUpdates": "檢查更新",
|
||||
"linkModel": "連結模型",
|
||||
"linkCivitai": "連結到 Civitai",
|
||||
"linkCivitai": "連結到 CivitAI",
|
||||
"linkHuggingFace": "連結到 HuggingFace",
|
||||
"copySyntax": "複製 LoRA 語法",
|
||||
"copyFilename": "複製模型檔名",
|
||||
@@ -853,20 +858,130 @@
|
||||
"recipes": {
|
||||
"title": "LoRA 配方",
|
||||
"actions": {
|
||||
"sendCheckpoint": "傳送到 ComfyUI"
|
||||
"sendCheckpoint": "傳送到 ComfyUI",
|
||||
"sendRecipe": "傳送到 ComfyUI",
|
||||
"copyRecipeSyntax": "複製配方語法",
|
||||
"deleteRecipeWithShortcut": "刪除配方(Del)"
|
||||
},
|
||||
"navigation": {
|
||||
"label": "配方導覽",
|
||||
"previousWithShortcut": "上一個配方(←)",
|
||||
"nextWithShortcut": "下一個配方(→)"
|
||||
},
|
||||
"modal": {
|
||||
"metadata": {
|
||||
"id": "ID"
|
||||
},
|
||||
"actions": {
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"copyId": "複製配方 ID"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
"failed": "開啟檔案位置失敗",
|
||||
"copied": "路徑已複製到剪貼簿:{{path}}",
|
||||
"clipboardFallback": "路徑:{{path}}"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
"sendWorkflow": "傳送工作流到 ComfyUI",
|
||||
"sent": "工作流已傳送到 ComfyUI",
|
||||
"sendFailed": "傳送工作流到 ComfyUI 失敗",
|
||||
"noWorkflow": "此配方中未找到內嵌工作流"
|
||||
},
|
||||
"status": {
|
||||
"ready": "可直接使用",
|
||||
"missingCount": "缺少 {count} 個",
|
||||
"deletedCount": "已刪除 {count} 個",
|
||||
"downloadMissing": "下載 {count} 個缺少的 LoRA",
|
||||
"downloadMissingTooltip": "點擊下載缺少的 LoRA"
|
||||
},
|
||||
"loraStatus": {
|
||||
"none": "此配方不含 LoRA",
|
||||
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
|
||||
"missing": "{total} 個 LoRA 中缺少 {missing} 個",
|
||||
"missingAndUnavailable": "{total} 個 LoRA 中缺少 {missing} 個,{unavailable} 個不可用(已從來源刪除或雜湊無法解析)",
|
||||
"partial": "{total} 個 LoRA 中 {unavailable} 個不可用(已從來源刪除或雜湊無法解析)- 使用配方時將被略過",
|
||||
"noneUsable": "沒有可用的 LoRA - {total} 個中 {unavailable} 個已從來源刪除或雜湊無法解析"
|
||||
},
|
||||
"resources": {
|
||||
"inLibrary": "已在庫存",
|
||||
"notInLibrary": "不在庫存",
|
||||
"deleted": "已刪除",
|
||||
"hashInvalid": "無法解析的雜湊",
|
||||
"inLibraryTooltip": "此模型已存在於本地庫",
|
||||
"notInLibraryTooltip": "此模型不在您的本地庫中",
|
||||
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
|
||||
"hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新",
|
||||
"noLorasAssociated": "此配方未關聯任何 LoRA",
|
||||
"noLorasWhyToggle": "為什麼沒有 LoRA?",
|
||||
"noLorasImportMethod": "匯入方式",
|
||||
"noLorasInferredNote": "可能的原因(推斷)——此配方是在記錄匯入診斷資訊之前匯入的。",
|
||||
"noLorasChannels": {
|
||||
"batch_import_url": "批量匯入(圖片 URL)",
|
||||
"batch_import_local": "批量匯入(本機檔案)",
|
||||
"url": "圖片 URL 匯入",
|
||||
"local": "本機檔案匯入",
|
||||
"upload": "圖片上傳",
|
||||
"widget": "從工作流儲存",
|
||||
"reimport_url": "重新匯入(圖片 URL)",
|
||||
"reimport_local": "重新匯入(本機檔案)"
|
||||
},
|
||||
"noLorasReasons": {
|
||||
"no_loras_used": "生成中繼資料完整,且未引用任何 LoRA。",
|
||||
"api_meta_no_lora_resources": "來源 API 未回傳此圖片的 LoRA 資源資料。CivitAI 頁面上顯示的 LoRA 可能來自公開 API 未開放的內部資料。",
|
||||
"api_meta_missing": "來源 API 未回傳此圖片的生成中繼資料。",
|
||||
"no_embedded_metadata": "圖片沒有內嵌生成中繼資料,因此無法復原 LoRA 資訊。",
|
||||
"workflow_metadata_limited": "圖片內嵌的中繼資料是 ComfyUI 工作流;從工作流中提取 LoRA 資訊的能力有限。",
|
||||
"video_no_metadata": "影片檔案不攜帶內嵌生成中繼資料。",
|
||||
"metadata_unsupported": "圖片包含的中繼資料格式無法解析。",
|
||||
"unknown": "無法從儲存的配方資料中確定原因。"
|
||||
},
|
||||
"noLorasDetails": {
|
||||
"apiMetaFields": "API 中繼資料欄位",
|
||||
"modelVersionIds": "回報的模型版本 ID 數",
|
||||
"embeddedMetadata": "內嵌中繼資料",
|
||||
"present": "已找到",
|
||||
"absent": "無"
|
||||
},
|
||||
"download": "下載",
|
||||
"downloadLoraTooltip": "下載此 LoRA",
|
||||
"preparingDownload": "正在準備下載...",
|
||||
"reconnect": "重新關聯",
|
||||
"reconnectTooltip": "與本地 LoRA 重新關聯",
|
||||
"reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:",
|
||||
"reconnectExample": "範例:<lora:name:1> 或只填名稱",
|
||||
"reconnectPlaceholder": "輸入 LoRA 名稱或語法",
|
||||
"reconnectSuggestionsLoading": "正在搜尋本地庫...",
|
||||
"reconnectSuggestionsEmpty": "本地庫中沒有符合的 LoRA",
|
||||
"reconnectMatchSameHash": "相同雜湊",
|
||||
"reconnectMatchSameVersion": "相同模型版本",
|
||||
"reconnectMatchSimilarFilename": "相似檔案名稱",
|
||||
"reconnectMatchSimilarName": "相似名稱",
|
||||
"undoReconnect": "撤銷",
|
||||
"undoReconnectTooltip": "恢復此條目在重新關聯前的關聯",
|
||||
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
|
||||
"viewOnCivitai": "在 CivitAI 上檢視",
|
||||
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
|
||||
"openCheckpointDetails": "在模型庫中檢視 {name}",
|
||||
"checkpointDeletedTooltip": "此 Checkpoint 已從來源刪除,無法再下載 - 請使用本地模型重新關聯",
|
||||
"checkpointHashInvalidTooltip": "此 Checkpoint 的雜湊無法在 CivitAI 上解析 - 模型可能已更新",
|
||||
"reconnectCheckpoint": "重新關聯",
|
||||
"reconnectCheckpointTooltip": "與本地 Checkpoint 重新關聯",
|
||||
"checkpointReconnectInstructions": "輸入 Checkpoint 名稱以重新關聯:",
|
||||
"checkpointReconnectPlaceholder": "輸入 Checkpoint 名稱",
|
||||
"checkpointReconnectSuggestionsEmpty": "本地庫中沒有符合的 Checkpoint"
|
||||
},
|
||||
"controls": {
|
||||
"import": {
|
||||
"action": "匯入",
|
||||
"title": "從圖片或網址匯入配方",
|
||||
"urlLocalPath": "網址 / 本機路徑",
|
||||
"uploadImage": "上傳圖片",
|
||||
"urlSectionDescription": "輸入 Civitai 圖片網址或本機檔案路徑以匯入配方。",
|
||||
"dropZoneLabel": "上傳圖片",
|
||||
"dropZoneHint": "將圖片拖曳至此處、從剪貼簿貼上,或點擊瀏覽",
|
||||
"orDivider": "或拖曳 / 貼上圖片",
|
||||
"imageUrlOrPath": "圖片網址或檔案路徑:",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
|
||||
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
|
||||
"fetchImage": "取得圖片",
|
||||
"uploadSectionDescription": "上傳含 LoRA metadata 的圖片以匯入配方。",
|
||||
"selectImage": "選擇圖片",
|
||||
"recipeName": "配方名稱",
|
||||
"recipeNamePlaceholder": "輸入配方名稱",
|
||||
"tagsOptional": "標籤(選填)",
|
||||
@@ -894,7 +1009,7 @@
|
||||
"downloadingLoras": "下載 LoRA 中...",
|
||||
"savingRecipe": "儲存配方中...",
|
||||
"startingDownload": "開始下載 LoRA {current}/{total}",
|
||||
"deletedFromCivitai": "已從 Civitai 刪除",
|
||||
"deletedFromCivitai": "已從 CivitAI 刪除",
|
||||
"inLibrary": "已在庫存",
|
||||
"notInLibrary": "不在庫存",
|
||||
"earlyAccessRequired": "此 LoRA 需購買早期存取才能下載。",
|
||||
@@ -911,6 +1026,8 @@
|
||||
"errors": {
|
||||
"selectImageFile": "請選擇圖片檔案",
|
||||
"enterUrlOrPath": "請輸入網址或檔案路徑",
|
||||
"invalidUrl": "請輸入有效的 URL",
|
||||
"invalidInputFormat": "請輸入圖片 URL 或本機圖片檔案路徑",
|
||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||
}
|
||||
},
|
||||
@@ -945,6 +1062,7 @@
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"finding": "正在掃描重複配方...",
|
||||
"found": "發現 {count} 組重複項",
|
||||
"noGroups": "按目前判重依據未找到重複組",
|
||||
"keepLatest": "保留最新版本",
|
||||
@@ -1014,6 +1132,8 @@
|
||||
"start": "開始匯入",
|
||||
"startImport": "開始匯入",
|
||||
"importing": "匯入中...",
|
||||
"rateLimitedSlowdown": "觸發速率限制 — 正在減速...",
|
||||
"rateLimitedHint": "部分項目因元數據提供方的速率限制而被略過。稍後重新執行匯入即可重試這些項目。",
|
||||
"progress": "進度",
|
||||
"total": "總計",
|
||||
"success": "成功",
|
||||
@@ -1055,7 +1175,7 @@
|
||||
"title": "Checkpoint 模型",
|
||||
"modelTypes": {
|
||||
"checkpoint": "Checkpoint",
|
||||
"diffusion_model": "Diffusion Model"
|
||||
"diffusion_model": "擴散模型"
|
||||
},
|
||||
"contextMenu": {
|
||||
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
|
||||
@@ -1079,7 +1199,7 @@
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "Move is not supported for this item.",
|
||||
"moveUnsupported": "此項目不支援移動。",
|
||||
"createFolderHint": "放開以建立新資料夾",
|
||||
"newFolderName": "新資料夾名稱",
|
||||
"folderNameHint": "按 Enter 確認,Escape 取消",
|
||||
@@ -1142,36 +1262,36 @@
|
||||
"unusedLoras": {
|
||||
"high": {
|
||||
"title": "大量未使用的 LoRA",
|
||||
"description": "你的 LoRA 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"description": "您的 LoRA 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
|
||||
}
|
||||
},
|
||||
"unusedCheckpoints": {
|
||||
"detected": {
|
||||
"title": "檢測到未使用的 Checkpoint",
|
||||
"description": "你的 Checkpoint 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"description": "您的 Checkpoint 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
|
||||
}
|
||||
},
|
||||
"unusedEmbeddings": {
|
||||
"high": {
|
||||
"title": "大量未使用的 Embedding",
|
||||
"description": "你的 Embedding 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的 Embedding 以優化你的收藏。"
|
||||
"description": "您的 Embedding 中有 {percent}%({count}/{total})從未被使用過。",
|
||||
"suggestion": "考慮整理或封存未使用的 Embedding 以優化您的收藏。"
|
||||
}
|
||||
},
|
||||
"collection": {
|
||||
"large": {
|
||||
"title": "檢測到大型收藏",
|
||||
"description": "你的模型收藏正在使用 {size} 的儲存空間。",
|
||||
"description": "您的模型收藏正在使用 {size} 的儲存空間。",
|
||||
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
|
||||
}
|
||||
},
|
||||
"activity": {
|
||||
"active": {
|
||||
"title": "活躍用戶",
|
||||
"description": "你已經完成了 {count} 次生成!",
|
||||
"suggestion": "繼續探索並用你的模型創作精彩內容。"
|
||||
"description": "您已經完成了 {count} 次生成!",
|
||||
"suggestion": "繼續探索並用您的模型創作精彩內容。"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1217,7 +1337,7 @@
|
||||
"download": {
|
||||
"title": "從網址下載模型",
|
||||
"titleWithType": "從網址下載 {type}",
|
||||
"civitaiUrl": "Civitai 網址:",
|
||||
"civitaiUrl": "CivitAI 網址:",
|
||||
"placeholder": "https://civitai.com/models/...",
|
||||
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
|
||||
"selectHfFiles": "選擇從此倉庫下載的檔案:",
|
||||
@@ -1241,16 +1361,18 @@
|
||||
"earlyAccessTooltip": "需要早期存取",
|
||||
"inLibrary": "已在庫存",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
|
||||
"downloadedTooltip": "先前已下載,但目前不在您的庫中。",
|
||||
"alreadyInLibrary": "已在庫存",
|
||||
"partiallyDownloaded": "部分已下載",
|
||||
"autoOrganizedPath": "[依路徑範本自動整理]",
|
||||
"fileSelection": {
|
||||
"title": "選擇檔案格式",
|
||||
"files": "個檔案",
|
||||
"select": "選擇檔案"
|
||||
"select": "選擇檔案",
|
||||
"inLibrary": "已在庫中"
|
||||
},
|
||||
"errors": {
|
||||
"invalidUrl": "Civitai 網址格式無效",
|
||||
"invalidUrl": "CivitAI 網址格式無效",
|
||||
"noVersions": "此模型無可用版本",
|
||||
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
|
||||
"noModelFiles": "在此倉庫中未找到模型檔案。"
|
||||
@@ -1329,8 +1451,8 @@
|
||||
"action": "全部刪除"
|
||||
},
|
||||
"checkUpdates": {
|
||||
"title": "要檢查所有 {type} 的更新嗎?",
|
||||
"message": "這會為資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
|
||||
"title": "要檢查所有 {typePlural} 的更新嗎?",
|
||||
"message": "這會檢查資料庫中的每個 {typePlural} 的更新,大型收藏可能會花上一些時間。",
|
||||
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
|
||||
"action": "全部檢查"
|
||||
},
|
||||
@@ -1354,7 +1476,7 @@
|
||||
},
|
||||
"bulkDownloadMissingLoras": {
|
||||
"title": "下載缺失的 LoRAs",
|
||||
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取食譜中的 {totalCount} 個總數)。",
|
||||
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取配方中的 {totalCount} 個總數)。",
|
||||
"previewTitle": "要下載的 LoRAs:",
|
||||
"moreItems": "...還有 {count} 個",
|
||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||
@@ -1364,7 +1486,7 @@
|
||||
"title": "本機範例圖片",
|
||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||
"downloadOption": {
|
||||
"title": "從 Civitai 下載",
|
||||
"title": "從 CivitAI 下載",
|
||||
"description": "將遠端範例儲存到本機以便離線使用及加快載入"
|
||||
},
|
||||
"importOption": {
|
||||
@@ -1391,7 +1513,7 @@
|
||||
"confirmAction": "儲存並連結"
|
||||
},
|
||||
"relinkCivitai": {
|
||||
"title": "重新連結至 Civitai",
|
||||
"title": "重新連結至 CivitAI",
|
||||
"warning": "警告:",
|
||||
"warningText": "這是可能造成破壞性的操作。重新連結將會:",
|
||||
"warningList": {
|
||||
@@ -1400,14 +1522,15 @@
|
||||
"unintendedConsequences": "可能產生其他非預期後果"
|
||||
},
|
||||
"proceedText": "僅在確定需要執行時才繼續。",
|
||||
"urlLabel": "Civitai 模型網址:",
|
||||
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
|
||||
"urlLabel": "CivitAI 模型網址:",
|
||||
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/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 (CivArchive)"
|
||||
},
|
||||
"confirmAction": "確認重新連結"
|
||||
},
|
||||
@@ -1417,14 +1540,16 @@
|
||||
"editFileName": "編輯檔案名稱",
|
||||
"editBaseModel": "編輯基礎模型",
|
||||
"editVersionName": "編輯版本名稱",
|
||||
"viewOnCivitai": "在 Civitai 查看",
|
||||
"viewOnCivitaiText": "在 Civitai 查看",
|
||||
"viewOnCivitai": "在 CivitAI 查看",
|
||||
"viewOnCivitaiText": "在 CivitAI 查看",
|
||||
"viewOnHuggingFace": "在 Hugging Face 查看",
|
||||
"viewOnHuggingFaceText": "在 Hugging Face 查看",
|
||||
"viewCreatorProfile": "查看創作者個人檔案",
|
||||
"openFileLocation": "開啟檔案位置",
|
||||
"sendToWorkflow": "傳送到 ComfyUI",
|
||||
"sendToWorkflowText": "傳送到 ComfyUI"
|
||||
"sendToWorkflowText": "傳送到 ComfyUI",
|
||||
"copyHash": "複製雜湊值",
|
||||
"deleteModelWithShortcut": "刪除模型(Del)"
|
||||
},
|
||||
"openFileLocation": {
|
||||
"success": "檔案位置已成功開啟",
|
||||
@@ -1441,13 +1566,14 @@
|
||||
"location": "位置",
|
||||
"baseModel": "基礎模型",
|
||||
"size": "大小",
|
||||
"hashes": "雜湊值",
|
||||
"unknown": "未知",
|
||||
"usageTips": "使用提示",
|
||||
"additionalNotes": "附加備註",
|
||||
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
|
||||
"addNotesPlaceholder": "在此新增備註...",
|
||||
"aboutThisVersion": "關於此版本",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型…",
|
||||
"baseModelSearchPlaceholder": "搜尋基礎模型...",
|
||||
"baseModelSuggested": "推薦",
|
||||
"baseModelNoMatch": "沒有符合的基礎模型"
|
||||
},
|
||||
@@ -1516,10 +1642,10 @@
|
||||
"noNext": "沒有下一個模型"
|
||||
},
|
||||
"license": {
|
||||
"noImageSell": "No selling generated content",
|
||||
"noRentCivit": "No Civitai generation",
|
||||
"noRent": "No generation services",
|
||||
"noSell": "No selling models",
|
||||
"noImageSell": "禁止出售生成的圖片",
|
||||
"noRentCivit": "禁止在 CivitAI 上生成",
|
||||
"noRent": "禁止生成服務",
|
||||
"noSell": "禁止出售模型",
|
||||
"creditRequired": "需要創作者標示",
|
||||
"noDerivatives": "禁止分享合併作品",
|
||||
"noReLicense": "需要相同授權",
|
||||
@@ -1532,6 +1658,30 @@
|
||||
"examples": "載入範例中...",
|
||||
"versions": "載入版本中..."
|
||||
},
|
||||
"showcase": {
|
||||
"hiddenBySfw": "因僅顯示 SFW 設定而隱藏 {count} 張",
|
||||
"showExamples": "顯示範例",
|
||||
"showCount": "顯示範例({count})",
|
||||
"hideExamples": "隱藏範例",
|
||||
"addExamples": "新增範例",
|
||||
"previousExample": "上一個範例([)",
|
||||
"nextExample": "下一個範例(])",
|
||||
"noExamples": "沒有可用的範例圖片",
|
||||
"addMoreExamples": "新增更多範例",
|
||||
"dragDrop": "拖放圖片或影片到此處",
|
||||
"or": "或",
|
||||
"selectFiles": "選擇檔案",
|
||||
"supportedFormats": "支援的格式:jpg、png、gif、webp、avif、jxl、mp4、webm",
|
||||
"importing": "正在匯入檔案...",
|
||||
"noSupportedFiles": "未選擇支援的檔案。請選擇圖片或影片檔案。",
|
||||
"allFiltered": "所有範例圖片都因 NSFW 內容設定而被過濾",
|
||||
"sfwOnlyEnabled": "您目前的設定為僅顯示安全(SFW)內容",
|
||||
"changeInSettings": "您可以在設定中變更此選項",
|
||||
"nsfwMature": "成熟內容",
|
||||
"nsfwR": "R 級內容",
|
||||
"nsfwX": "X 級內容",
|
||||
"nsfwXxx": "XXX 級內容"
|
||||
},
|
||||
"versions": {
|
||||
"heading": "模型版本",
|
||||
"copy": "在同一位置追蹤並管理此模型的所有版本。",
|
||||
@@ -1550,40 +1700,41 @@
|
||||
},
|
||||
"badges": {
|
||||
"current": "已開啟版本",
|
||||
"currentTooltip": "這是你用來開啟此彈窗的版本",
|
||||
"currentTooltip": "這是您用來開啟此彈窗的版本",
|
||||
"inLibrary": "已在庫中",
|
||||
"inLibraryTooltip": "此版本已存在於你的本地庫中",
|
||||
"inLibraryTooltip": "此版本已存在於您的本地庫中",
|
||||
"downloaded": "已下載",
|
||||
"downloadedTooltip": "此版本之前下載過,但目前不在你的本地庫中",
|
||||
"downloadedTooltip": "此版本之前下載過,但目前不在您的本地庫中",
|
||||
"newer": "較新版本",
|
||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||
"newerTooltip": "此版本比您本地的最新版本更新",
|
||||
"earlyAccess": "搶先體驗",
|
||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||
"paid": "[TODO: Translate] Paid",
|
||||
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||
"earlyAccessTooltip": "此版本目前需要 CivitAI 搶先體驗權限",
|
||||
"paid": "付費",
|
||||
"paidTooltip": "此版本需要付費才能下載",
|
||||
"ignored": "已忽略",
|
||||
"ignoredTooltip": "此版本已關閉更新通知",
|
||||
"onSiteOnly": "僅站內生成",
|
||||
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
|
||||
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載"
|
||||
},
|
||||
"actions": {
|
||||
"download": "下載",
|
||||
"downloadTooltip": "下載此版本",
|
||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||
"downloadChooseFilesTooltip": "選擇要下載的檔案",
|
||||
"downloadEarlyAccessTooltip": "從 CivitAI 下載此搶先體驗版本",
|
||||
"downloadPaidTooltip": "從 CivitAI 下載此付費版本",
|
||||
"downloadNotAllowedTooltip": "此版本僅在 CivitAI 站內可用,無法下載",
|
||||
"delete": "刪除",
|
||||
"deleteTooltip": "刪除此本地版本",
|
||||
"ignore": "忽略",
|
||||
"unignore": "取消忽略",
|
||||
"ignoreTooltip": "忽略此版本的更新通知",
|
||||
"unignoreTooltip": "恢復此版本的更新通知",
|
||||
"viewVersionOnCivitai": "在 Civitai 上查看版本",
|
||||
"viewVersionOnCivitai": "在 CivitAI 上查看版本",
|
||||
"earlyAccessTooltip": "需要購買搶先體驗",
|
||||
"resumeModelUpdates": "恢復追蹤此模型的更新",
|
||||
"ignoreModelUpdates": "忽略此模型的更新",
|
||||
"viewLocalVersions": "檢視所有本地版本",
|
||||
"viewLocalTooltip": "敬請期待"
|
||||
"viewLocalTooltip": "在主頁面上顯示此模型的所有本機版本"
|
||||
},
|
||||
"filters": {
|
||||
"label": "基礎篩選",
|
||||
@@ -1599,7 +1750,7 @@
|
||||
},
|
||||
"empty": "此模型尚無版本歷史。",
|
||||
"error": "載入版本失敗。",
|
||||
"missingModelId": "此模型缺少 Civitai 模型 ID。",
|
||||
"missingModelId": "此模型缺少 CivitAI 模型 ID。",
|
||||
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
|
||||
"confirm": {
|
||||
"delete": "要從庫中刪除此版本嗎?"
|
||||
@@ -1683,14 +1834,14 @@
|
||||
"tips": {
|
||||
"title": "小技巧",
|
||||
"civitai": {
|
||||
"title": "Civitai 整合",
|
||||
"description": "連結您的 Civitai 帳號:前往個人頭像 → 設定 → API 金鑰 → 新增 API 金鑰,然後貼到 LoRA 管理器設定中。",
|
||||
"alt": "Civitai API 設定"
|
||||
"title": "CivitAI 整合",
|
||||
"description": "連結您的 CivitAI 帳號:前往個人頭像 → 設定 → API 金鑰 → 新增 API 金鑰,然後貼到 LoRA 管理器設定中。",
|
||||
"alt": "CivitAI API 設定"
|
||||
},
|
||||
"download": {
|
||||
"title": "快速下載",
|
||||
"description": "使用 Civitai 網址即可快速下載並安裝新模型。",
|
||||
"alt": "Civitai 下載"
|
||||
"description": "使用 CivitAI 網址即可快速下載並安裝新模型。",
|
||||
"alt": "CivitAI 下載"
|
||||
},
|
||||
"recipes": {
|
||||
"title": "儲存配方",
|
||||
@@ -1874,7 +2025,7 @@
|
||||
"submitGithubIssue": "提交 GitHub 問題",
|
||||
"joinDiscord": "加入 Discord",
|
||||
"youtubeChannel": "YouTube 頻道",
|
||||
"civitaiProfile": "Civitai 個人檔案",
|
||||
"civitaiProfile": "CivitAI 個人檔案",
|
||||
"supportKofi": "在 Ko-fi 支持",
|
||||
"supportPatreon": "在 Patreon 支持"
|
||||
},
|
||||
@@ -1917,6 +2068,7 @@
|
||||
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
|
||||
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
|
||||
"pleaseSelectVersion": "請選擇一個版本",
|
||||
"pleaseSelectFile": "請至少選擇一個檔案",
|
||||
"versionExists": "此版本已存在於您的庫中",
|
||||
"downloadCompleted": "下載成功完成",
|
||||
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
|
||||
@@ -1950,18 +2102,33 @@
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"noPreviousRecipe": "沒有上一個配方",
|
||||
"noNextRecipe": "沒有下一個配方",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
|
||||
"enterLoraName": "請輸入 LoRA 名稱或語法",
|
||||
"reconnectedSuccessfully": "LoRA 重新連結成功",
|
||||
"reconnectBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},LoRA:{lora})——兩者架構相容",
|
||||
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
|
||||
"loraRestored": "LoRA 已恢復為重新關聯前的關聯",
|
||||
"loraRestoreFailed": "LoRA 恢復錯誤:{message}",
|
||||
"noPromptToSend": "沒有可發送的提示詞",
|
||||
"cannotSend": "無法傳送配方:缺少配方 ID",
|
||||
"sendFailed": "傳送配方到工作流失敗",
|
||||
"sendError": "傳送配方到工作流錯誤",
|
||||
"missingCheckpointPath": "缺少檢查點路徑",
|
||||
"missingCheckpointInfo": "缺少檢查點資訊",
|
||||
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
|
||||
"missingCheckpointPath": "缺少Checkpoint路徑",
|
||||
"missingCheckpointInfo": "缺少Checkpoint資訊",
|
||||
"downloadCheckpointFailed": "下載Checkpoint失敗:{message}",
|
||||
"enterCheckpointName": "請輸入 Checkpoint 名稱",
|
||||
"checkpointReconnectedSuccessfully": "Checkpoint 重新連結成功",
|
||||
"reconnectCheckpointBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},Checkpoint:{checkpoint})——兩者架構相容",
|
||||
"checkpointReconnectFailed": "Checkpoint 重新連結錯誤:{message}",
|
||||
"checkpointRestored": "Checkpoint 已恢復為重新關聯前的關聯",
|
||||
"checkpointRestoreFailed": "Checkpoint 恢復錯誤:{message}",
|
||||
"checkpointDownloadUnavailable": "缺少 CivitAI 標識,無法下載此 Checkpoint - 請嘗試使用本地 Checkpoint 重新關聯",
|
||||
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
|
||||
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
|
||||
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
|
||||
"cannotDelete": "無法刪除配方:缺少配方 ID",
|
||||
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
|
||||
"deletedSuccessfully": "配方已成功刪除",
|
||||
@@ -1985,24 +2152,28 @@
|
||||
"batchImportCancelFailed": "取消批量匯入失敗:{message}",
|
||||
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
|
||||
"batchImportNoDirectory": "請輸入目錄路徑",
|
||||
"batchImportRateLimited": "已達到元數據提供方的速率限制 — 請求正在放緩,部分項目可能被略過。您可以稍後重新執行匯入。",
|
||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||
"noRecipesSelected": "未選取任何食譜",
|
||||
"noRecipesSelected": "未選取任何配方",
|
||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
|
||||
"rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
|
||||
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
|
||||
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
|
||||
"rematchFailed": "重新匹配所選食譜失敗:{message}",
|
||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
||||
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
|
||||
"noMissingLorasInSelection": "在選取的配方中未找到缺失的 LoRAs",
|
||||
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。",
|
||||
"workflowSent": "工作流已傳送到 ComfyUI",
|
||||
"workflowSendFailed": "傳送工作流到 ComfyUI 失敗: {error}",
|
||||
"workflowNoWorkflow": "此配方中未找到內嵌工作流"
|
||||
},
|
||||
"models": {
|
||||
"noModelsSelected": "未選擇模型",
|
||||
@@ -2039,8 +2210,8 @@
|
||||
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
|
||||
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
|
||||
"bulkUpdatesNone": "所選 {type} 未找到更新",
|
||||
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
|
||||
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
|
||||
"bulkUpdatesMissing": "所選 {type} 未連結 CivitAI 更新",
|
||||
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 CivitAI 的所選 {type}",
|
||||
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
|
||||
"invalidCharactersRemoved": "已移除檔名中的無效字元",
|
||||
"filenameCannotBeEmpty": "檔案名稱不可為空",
|
||||
@@ -2080,8 +2251,8 @@
|
||||
"compactModeToggled": "緊湊模式已{state}",
|
||||
"settingSaveFailed": "儲存設定失敗:{message}",
|
||||
"displayDensitySet": "顯示密度已設為 {density}",
|
||||
"libraryLoadFailed": "Failed to load libraries: {message}",
|
||||
"libraryActivateFailed": "Failed to activate library: {message}",
|
||||
"libraryLoadFailed": "載入模型庫失敗:{message}",
|
||||
"libraryActivateFailed": "啟動模型庫失敗:{message}",
|
||||
"languageChangeFailed": "切換語言失敗:{message}",
|
||||
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
|
||||
"cacheClearFailed": "清除快取失敗:{error}",
|
||||
@@ -2159,17 +2330,18 @@
|
||||
},
|
||||
"controls": {
|
||||
"reloadFailed": "重新載入 {pageType} 失敗:{message}",
|
||||
"refreshFailed": "刷新 {pageType} 失敗:{message}",
|
||||
"refreshFailed": "{action} {pageType} 失敗:{message}",
|
||||
"fetchMetadataFailed": "取得 metadata 失敗:{message}",
|
||||
"clearFilterFailed": "清除自訂篩選失敗:{message}"
|
||||
},
|
||||
"contextMenu": {
|
||||
"contentRatingSet": "內容分級已設為 {level}",
|
||||
"contentRatingFailed": "設定內容分級失敗:{message}",
|
||||
"relinkSuccess": "模型已成功重新連結至 Civitai",
|
||||
"relinkSuccess": "模型已成功重新連結至 CivitAI",
|
||||
"relinkFailed": "錯誤:{message}",
|
||||
"linkHfSuccess": "模型已成功連結到 HuggingFace",
|
||||
"linkHfFailed": "錯誤:{message}",
|
||||
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
|
||||
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
|
||||
"noCivitaiInfo": "無 CivitAI 資訊",
|
||||
"missingHash": "模型雜湊不可用"
|
||||
@@ -2229,7 +2401,7 @@
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}",
|
||||
"moveFailed": "移動項目失敗:{message}",
|
||||
"copiedToClipboard": "已複製到剪貼簿",
|
||||
"downloadStarted": "下載已開始"
|
||||
},
|
||||
@@ -2259,7 +2431,7 @@
|
||||
},
|
||||
"issues": {
|
||||
"civitai_api_key": {
|
||||
"title": "Civitai API 金鑰"
|
||||
"title": "CivitAI API 金鑰"
|
||||
},
|
||||
"cache_health": {
|
||||
"title": "模型快取健康狀態"
|
||||
@@ -2312,10 +2484,10 @@
|
||||
"seconds": "秒後重新整理"
|
||||
},
|
||||
"communitySupport": {
|
||||
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
|
||||
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
|
||||
"supportCta": "Support on Ko-fi",
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
"title": "用您的支持讓 LoRA Manager 持續茁壯 ❤️",
|
||||
"content": "LoRA Manager 是由一位獨立開發者全職維護的熱情項目。您在 Ko-fi 上的支持有助於支付開發成本、持續推出新更新,並將贈送 LM CivitAI 擴充功能的授權金鑰作為感謝之禮。每一份貢獻都意義重大。",
|
||||
"supportCta": "在 Ko-fi 上支持",
|
||||
"learnMore": "LM CivitAI 擴充功能教學"
|
||||
},
|
||||
"cacheHealth": {
|
||||
"corrupted": {
|
||||
|
||||
@@ -46,6 +46,16 @@ async def api_json_error(
|
||||
if request.path.startswith("/api/lm/previews") and exc.status == 404:
|
||||
logger_method = logger.debug
|
||||
|
||||
# Download-progress 404 is routine too: in-memory tracking is removed
|
||||
# once a download finishes/fails, so the extension's final polls 404.
|
||||
# The extension relies on the 404 status itself (failure detection),
|
||||
# so only the log level is lowered.
|
||||
if (
|
||||
request.path.startswith("/api/lm/download-progress/")
|
||||
and exc.status == 404
|
||||
):
|
||||
logger_method = logger.debug
|
||||
|
||||
logger_method(
|
||||
"API %s %s returned HTTP %d: %s",
|
||||
request.method,
|
||||
|
||||
@@ -13,6 +13,10 @@ class CheckpointLoaderLM:
|
||||
|
||||
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
||||
extra folder paths, providing a unified interface for checkpoint loading.
|
||||
The ckpt_name combo supports ComfyUI's control_after_generate, letting
|
||||
users pick a random checkpoint on every run; the base_model input narrows
|
||||
the random pool through a front-end extension that filters the combo
|
||||
options.
|
||||
"""
|
||||
|
||||
NAME = "Checkpoint Loader (LoraManager)"
|
||||
@@ -22,11 +26,29 @@ class CheckpointLoaderLM:
|
||||
def INPUT_TYPES(cls):
|
||||
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||
checkpoint_names = cls._get_checkpoint_names()
|
||||
base_models = cls._get_available_base_models()
|
||||
return {
|
||||
"required": {
|
||||
"ckpt_name": (
|
||||
checkpoint_names,
|
||||
{"tooltip": "The name of the checkpoint (model) to load."},
|
||||
{
|
||||
"tooltip": (
|
||||
"The name of the checkpoint (model) to load. Use "
|
||||
"control_after_generate to pick a random model on "
|
||||
"every run."
|
||||
),
|
||||
"control_after_generate": "fixed",
|
||||
},
|
||||
),
|
||||
"base_model": (
|
||||
base_models,
|
||||
{
|
||||
"default": "Any",
|
||||
"tooltip": (
|
||||
"Restrict the random selection pool to this base "
|
||||
"model. 'Any' uses the full pool."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -93,15 +115,68 @@ class CheckpointLoaderLM:
|
||||
logger.error(f"Error getting checkpoint names: {e}")
|
||||
return []
|
||||
|
||||
def load_checkpoint(self, ckpt_name: str) -> Tuple[Any, Any, Any]:
|
||||
@classmethod
|
||||
def _get_available_base_models(cls) -> List[str]:
|
||||
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_base_models():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "checkpoint":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
file_path = item.get("file_path", "")
|
||||
if base_model and file_path and os.path.exists(file_path):
|
||||
base_models.add(base_model)
|
||||
|
||||
return sorted(base_models)
|
||||
|
||||
return ["Any"] + cls._run_async(_get_base_models)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available base models: {e}")
|
||||
return ["Any"]
|
||||
|
||||
@staticmethod
|
||||
def _run_async(coro_fn):
|
||||
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro_fn())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_fn())
|
||||
|
||||
def load_checkpoint(
|
||||
self, ckpt_name: str, base_model: str = "Any"
|
||||
) -> Tuple[Any, Any, Any]:
|
||||
"""Load a checkpoint by name, supporting extra folder paths
|
||||
|
||||
Args:
|
||||
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
||||
base_model: Only used by the front-end to filter the random pool
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL, CLIP, VAE)
|
||||
"""
|
||||
del base_model
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ class CreateHookLoraLM:
|
||||
),
|
||||
},
|
||||
),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -52,7 +53,7 @@ class CreateHookLoraLM:
|
||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||
FUNCTION = "create_hook"
|
||||
|
||||
def create_hook(self, text: str, **kwargs):
|
||||
def create_hook(self, text: str, loras, **kwargs):
|
||||
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
|
||||
|
||||
Each active LoRA from the widget is loaded and wrapped in a WeightHook
|
||||
@@ -73,7 +74,7 @@ class CreateHookLoraLM:
|
||||
all_trigger_words: list[str] = []
|
||||
active_loras: list[tuple[str, float, float]] = []
|
||||
|
||||
for lora in get_loras_list(kwargs):
|
||||
for lora in get_loras_list({"loras": loras}):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
|
||||
|
||||
@@ -49,9 +49,9 @@ def _collect_stack_entries(lora_stack):
|
||||
return entries
|
||||
|
||||
|
||||
def _collect_widget_entries(kwargs):
|
||||
def _collect_widget_entries(loras):
|
||||
entries = []
|
||||
for lora in get_loras_list(kwargs):
|
||||
for lora in get_loras_list({"loras": loras}):
|
||||
if not lora.get("active", False):
|
||||
continue
|
||||
lora_name = apply_lora_syntax_format(lora["name"])
|
||||
@@ -139,6 +139,7 @@ class LoraLoaderLM:
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -152,12 +153,12 @@ class LoraLoaderLM:
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||
FUNCTION = "load_loras"
|
||||
|
||||
def load_loras(self, model, text, **kwargs):
|
||||
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
|
||||
def load_loras(self, model, text, loras, **kwargs):
|
||||
"""Loads multiple LoRAs based on the widget input and lora_stack."""
|
||||
del text
|
||||
clip = kwargs.get("clip", None)
|
||||
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
|
||||
lora_entries.extend(_collect_widget_entries(kwargs))
|
||||
lora_entries.extend(_collect_widget_entries(loras))
|
||||
|
||||
nunchaku_model_kind = detect_nunchaku_model_kind(model)
|
||||
if nunchaku_model_kind == "flux":
|
||||
|
||||
@@ -18,6 +18,7 @@ class LoraStackerLM:
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -31,8 +32,8 @@ class LoraStackerLM:
|
||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||
FUNCTION = "stack_loras"
|
||||
|
||||
def stack_loras(self, text, **kwargs):
|
||||
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
|
||||
def stack_loras(self, text, loras, **kwargs):
|
||||
"""Stacks multiple LoRAs based on the widget input without loading them."""
|
||||
stack = []
|
||||
active_loras = []
|
||||
all_trigger_words = []
|
||||
@@ -47,8 +48,8 @@ class LoraStackerLM:
|
||||
_, trigger_words = get_lora_info(lora_name)
|
||||
all_trigger_words.extend(trigger_words)
|
||||
|
||||
# Process loras from kwargs with support for both old and new formats
|
||||
loras_list = get_loras_list(kwargs)
|
||||
# Process loras from the widget with support for both old and new formats
|
||||
loras_list = get_loras_list({"loras": loras})
|
||||
for lora in loras_list:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from typing import Any, List, Optional, Tuple
|
||||
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||
import folder_paths # pyright: ignore[reportMissingImports]
|
||||
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RandomCheckpointLoaderLM:
|
||||
"""Checkpoint Loader that can randomly pick a checkpoint from the pool
|
||||
|
||||
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
|
||||
extra folder paths. When select_at_random is enabled, ignores ckpt_name
|
||||
and picks a random checkpoint (optionally filtered by base_model) on
|
||||
every run.
|
||||
"""
|
||||
|
||||
NAME = "Random Checkpoint Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Get list of checkpoint names from scanner (includes extra folder paths)
|
||||
checkpoint_names = cls._get_checkpoint_names()
|
||||
base_models = cls._get_available_base_models()
|
||||
return {
|
||||
"required": {
|
||||
"ckpt_name": (
|
||||
checkpoint_names,
|
||||
{"tooltip": "The name of the checkpoint (model) to load."},
|
||||
),
|
||||
"select_at_random": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Ignore ckpt_name and pick a random checkpoint from the "
|
||||
"pool (optionally filtered by base_model) on every run."
|
||||
),
|
||||
},
|
||||
),
|
||||
"base_model": (
|
||||
base_models,
|
||||
{
|
||||
"default": "Any",
|
||||
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "CLIP", "VAE", "model_name")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The model used for denoising latents.",
|
||||
"The CLIP model used for encoding text prompts.",
|
||||
"The VAE model used for encoding and decoding images to and from latent space.",
|
||||
"The name of the checkpoint that was loaded (useful when select_at_random is enabled).",
|
||||
)
|
||||
FUNCTION = "load_checkpoint"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, ckpt_name, select_at_random=False, base_model="Any"):
|
||||
# Force re-execution on every run while randomizing, since the widget
|
||||
# values themselves don't change between queue runs.
|
||||
if select_at_random:
|
||||
return float("nan")
|
||||
return ckpt_name
|
||||
|
||||
@staticmethod
|
||||
def _run_async(coro_fn):
|
||||
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro_fn())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_fn())
|
||||
|
||||
@classmethod
|
||||
def _get_checkpoint_names(cls, base_model: Optional[str] = None) -> List[str]:
|
||||
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)
|
||||
|
||||
Args:
|
||||
base_model: If given (and not "Any"), only include checkpoints matching this base model.
|
||||
"""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_names():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
# Get all model roots for calculating relative paths
|
||||
model_roots = scanner.get_model_roots()
|
||||
|
||||
# Filter only checkpoint type (not diffusion_model) and format names
|
||||
names = []
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "checkpoint":
|
||||
continue
|
||||
if (
|
||||
base_model
|
||||
and base_model != "Any"
|
||||
and item.get("base_model") != base_model
|
||||
):
|
||||
continue
|
||||
file_path = item.get("file_path", "")
|
||||
# Only offer models that still exist on disk so ComfyUI
|
||||
# flags missing checkpoints at queue time via
|
||||
# "value not in list" (the scanner cache can be stale).
|
||||
if file_path and os.path.exists(file_path):
|
||||
# Format using relative path with OS-native separator
|
||||
formatted_name = _format_model_name_for_comfyui(
|
||||
file_path, model_roots
|
||||
)
|
||||
if formatted_name:
|
||||
names.append(formatted_name)
|
||||
|
||||
return sorted(names)
|
||||
|
||||
return cls._run_async(_get_names)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting checkpoint names: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _get_available_base_models(cls) -> List[str]:
|
||||
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_base_models():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "checkpoint":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
file_path = item.get("file_path", "")
|
||||
if base_model and file_path and os.path.exists(file_path):
|
||||
base_models.add(base_model)
|
||||
|
||||
return sorted(base_models)
|
||||
|
||||
return ["Any"] + cls._run_async(_get_base_models)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available base models: {e}")
|
||||
return ["Any"]
|
||||
|
||||
def load_checkpoint(
|
||||
self,
|
||||
ckpt_name: str,
|
||||
select_at_random: bool = False,
|
||||
base_model: str = "Any",
|
||||
) -> Tuple[Any, Any, Any, str]:
|
||||
"""Load a checkpoint by name, supporting extra folder paths
|
||||
|
||||
Args:
|
||||
ckpt_name: The name of the checkpoint to load (relative path with extension)
|
||||
select_at_random: If True, ignore ckpt_name and pick randomly from the pool
|
||||
base_model: Restricts random selection to this base model ("Any" = no filter)
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL, CLIP, VAE, model_name)
|
||||
"""
|
||||
if select_at_random:
|
||||
pool = self._get_checkpoint_names(base_model)
|
||||
if not pool:
|
||||
raise FileNotFoundError(
|
||||
f"No checkpoints found for base model '{base_model}'. "
|
||||
"Pick a different base model or disable 'select_at_random'."
|
||||
)
|
||||
ckpt_name = random.choice(pool)
|
||||
logger.info(
|
||||
f"[RandomCheckpointLoaderLM] Randomly selected checkpoint: {ckpt_name}"
|
||||
)
|
||||
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
|
||||
|
||||
if metadata is None:
|
||||
raise FileNotFoundError(
|
||||
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
|
||||
"Make sure the checkpoint is indexed and try again."
|
||||
)
|
||||
|
||||
# Load regular checkpoint using ComfyUI's API
|
||||
logger.info(f"Loading checkpoint from: {ckpt_path}")
|
||||
out = comfy.sd.load_checkpoint_guess_config(
|
||||
ckpt_path,
|
||||
output_vae=True,
|
||||
output_clip=True,
|
||||
embedding_directory=folder_paths.get_folder_paths("embeddings"),
|
||||
)
|
||||
return out[:3] + (ckpt_name,)
|
||||
@@ -1,326 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
from typing import Any, List, Optional, Tuple
|
||||
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reload_gguf_unet(
|
||||
unet_path: str, weight_dtype: str, disable_dynamic: bool = False
|
||||
) -> object:
|
||||
"""Reload a GGUF diffusion model from disk (cached_patcher_init factory).
|
||||
|
||||
Mirrors the GGUF branch of RandomUNETLoaderLM.load_unet so ModelPatcher
|
||||
deepclone/dynamic machinery can rebuild GGUF models with the correct
|
||||
GGMLOps. ``disable_dynamic`` is accepted for signature compatibility
|
||||
with core ComfyUI loaders.
|
||||
"""
|
||||
loader = RandomUNETLoaderLM()
|
||||
model, _unet_name = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
|
||||
return model
|
||||
|
||||
|
||||
class RandomUNETLoaderLM:
|
||||
"""UNET Loader that can randomly pick a diffusion model from the pool
|
||||
|
||||
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA
|
||||
Manager's extra folder paths. Supports both regular diffusion models and
|
||||
GGUF format models. When select_at_random is enabled, ignores unet_name
|
||||
and picks a random diffusion model (optionally filtered by base_model)
|
||||
on every run.
|
||||
"""
|
||||
|
||||
NAME = "Random Unet Loader (LoraManager)"
|
||||
CATEGORY = "Lora Manager/loaders"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Get list of unet names from scanner (includes extra folder paths)
|
||||
unet_names = cls._get_unet_names()
|
||||
base_models = cls._get_available_base_models()
|
||||
return {
|
||||
"required": {
|
||||
"unet_name": (
|
||||
unet_names,
|
||||
{"tooltip": "The name of the diffusion model to load."},
|
||||
),
|
||||
"weight_dtype": (
|
||||
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
||||
{"tooltip": "The dtype to use for the model weights."},
|
||||
),
|
||||
"select_at_random": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Ignore unet_name and pick a random diffusion model from "
|
||||
"the pool (optionally filtered by base_model) on every run."
|
||||
),
|
||||
},
|
||||
),
|
||||
"base_model": (
|
||||
base_models,
|
||||
{
|
||||
"default": "Any",
|
||||
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "STRING")
|
||||
RETURN_NAMES = ("MODEL", "model_name")
|
||||
OUTPUT_TOOLTIPS = (
|
||||
"The model used for denoising latents.",
|
||||
"The name of the diffusion model that was loaded (useful when select_at_random is enabled).",
|
||||
)
|
||||
FUNCTION = "load_unet"
|
||||
|
||||
@classmethod
|
||||
def IS_CHANGED(
|
||||
cls, unet_name, weight_dtype, select_at_random=False, base_model="Any"
|
||||
):
|
||||
# Force re-execution on every run while randomizing, since the widget
|
||||
# values themselves don't change between queue runs.
|
||||
if select_at_random:
|
||||
return float("nan")
|
||||
return unet_name
|
||||
|
||||
@staticmethod
|
||||
def _run_async(coro_fn):
|
||||
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro_fn())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_fn())
|
||||
|
||||
@classmethod
|
||||
def _get_unet_names(cls, base_model: Optional[str] = None) -> List[str]:
|
||||
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)
|
||||
|
||||
Args:
|
||||
base_model: If given (and not "Any"), only include models matching this base model.
|
||||
"""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_names():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
# Get all model roots for calculating relative paths
|
||||
model_roots = scanner.get_model_roots()
|
||||
|
||||
# Filter only diffusion_model type and format names
|
||||
names = []
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "diffusion_model":
|
||||
continue
|
||||
if (
|
||||
base_model
|
||||
and base_model != "Any"
|
||||
and item.get("base_model") != base_model
|
||||
):
|
||||
continue
|
||||
file_path = item.get("file_path", "")
|
||||
# Only offer models that still exist on disk so ComfyUI
|
||||
# flags missing diffusion models at queue time via
|
||||
# "value not in list" (the scanner cache can be stale).
|
||||
if file_path and os.path.exists(file_path):
|
||||
# Format using relative path with OS-native separator
|
||||
formatted_name = _format_model_name_for_comfyui(
|
||||
file_path, model_roots
|
||||
)
|
||||
if formatted_name:
|
||||
names.append(formatted_name)
|
||||
|
||||
return sorted(names)
|
||||
|
||||
return cls._run_async(_get_names)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting unet names: {e}")
|
||||
return []
|
||||
|
||||
@classmethod
|
||||
def _get_available_base_models(cls) -> List[str]:
|
||||
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_base_models():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "diffusion_model":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
file_path = item.get("file_path", "")
|
||||
if base_model and file_path and os.path.exists(file_path):
|
||||
base_models.add(base_model)
|
||||
|
||||
return sorted(base_models)
|
||||
|
||||
return ["Any"] + cls._run_async(_get_base_models)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available base models: {e}")
|
||||
return ["Any"]
|
||||
|
||||
def load_unet(
|
||||
self,
|
||||
unet_name: str,
|
||||
weight_dtype: str,
|
||||
select_at_random: bool = False,
|
||||
base_model: str = "Any",
|
||||
) -> Tuple[Any, ...]:
|
||||
"""Load a diffusion model by name, supporting extra folder paths
|
||||
|
||||
Args:
|
||||
unet_name: The name of the diffusion model to load (relative path with extension)
|
||||
weight_dtype: The dtype to use for model weights
|
||||
select_at_random: If True, ignore unet_name and pick randomly from the pool
|
||||
base_model: Restricts random selection to this base model ("Any" = no filter)
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL, model_name)
|
||||
"""
|
||||
import torch
|
||||
|
||||
if select_at_random:
|
||||
pool = self._get_unet_names(base_model)
|
||||
if not pool:
|
||||
raise FileNotFoundError(
|
||||
f"No diffusion models found for base model '{base_model}'. "
|
||||
"Pick a different base model or disable 'select_at_random'."
|
||||
)
|
||||
unet_name = random.choice(pool)
|
||||
logger.info(
|
||||
f"[RandomUNETLoaderLM] Randomly selected diffusion model: {unet_name}"
|
||||
)
|
||||
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
|
||||
|
||||
if metadata is None:
|
||||
raise FileNotFoundError(
|
||||
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
|
||||
"Make sure the model is indexed and try again."
|
||||
)
|
||||
|
||||
# Check if it's a GGUF model
|
||||
if unet_path.endswith(".gguf"):
|
||||
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
|
||||
|
||||
# Load regular diffusion model using ComfyUI's API
|
||||
logger.info(f"Loading diffusion model from: {unet_path}")
|
||||
|
||||
# Build model options based on weight_dtype
|
||||
model_options = {}
|
||||
if weight_dtype == "fp8_e4m3fn":
|
||||
model_options["dtype"] = torch.float8_e4m3fn
|
||||
elif weight_dtype == "fp8_e4m3fn_fast":
|
||||
model_options["dtype"] = torch.float8_e4m3fn
|
||||
model_options["fp8_optimizations"] = True
|
||||
elif weight_dtype == "fp8_e5m2":
|
||||
model_options["dtype"] = torch.float8_e5m2
|
||||
|
||||
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
|
||||
return (model, unet_name)
|
||||
|
||||
def _load_gguf_unet(
|
||||
self, unet_path: str, unet_name: str, weight_dtype: str
|
||||
) -> Tuple[Any, ...]:
|
||||
"""Load a GGUF format diffusion model
|
||||
|
||||
Args:
|
||||
unet_path: Absolute path to the GGUF file
|
||||
unet_name: Name of the model for error messages
|
||||
weight_dtype: The dtype to use for model weights
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL, model_name)
|
||||
"""
|
||||
import torch
|
||||
from .gguf_import_helper import get_gguf_modules
|
||||
|
||||
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
|
||||
try:
|
||||
loader_module, ops_module, nodes_module = get_gguf_modules()
|
||||
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
|
||||
GGMLOps = getattr(ops_module, "GGMLOps")
|
||||
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
|
||||
except RuntimeError as e:
|
||||
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
|
||||
|
||||
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
|
||||
|
||||
try:
|
||||
# Load GGUF state dict
|
||||
sd, extra = gguf_sd_loader(unet_path)
|
||||
|
||||
# Prepare kwargs for metadata if supported
|
||||
kwargs = {}
|
||||
import inspect
|
||||
|
||||
valid_params = inspect.signature(
|
||||
comfy.sd.load_diffusion_model_state_dict
|
||||
).parameters
|
||||
if "metadata" in valid_params:
|
||||
kwargs["metadata"] = extra.get("metadata", {})
|
||||
|
||||
# Setup custom operations with GGUF support
|
||||
ops = GGMLOps()
|
||||
|
||||
# Handle weight_dtype for GGUF models
|
||||
if weight_dtype in ("default", None):
|
||||
ops.Linear.dequant_dtype = None
|
||||
elif weight_dtype in ["target"]:
|
||||
ops.Linear.dequant_dtype = weight_dtype
|
||||
else:
|
||||
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
|
||||
|
||||
# Load the model
|
||||
model = comfy.sd.load_diffusion_model_state_dict(
|
||||
sd, model_options={"custom_operations": ops}, **kwargs
|
||||
)
|
||||
|
||||
if model is None:
|
||||
raise RuntimeError(
|
||||
f"Could not detect model type for GGUF diffusion model: {unet_path}"
|
||||
)
|
||||
|
||||
# Wrap with GGUFModelPatcher
|
||||
model = GGUFModelPatcher.clone(model)
|
||||
|
||||
# Register a reload factory so the MODEL carries its source path
|
||||
# (cached_patcher_init) like core ComfyUI loaders do — required
|
||||
# for model-name extraction downstream and for ModelPatcher
|
||||
# deepclone/dynamic machinery.
|
||||
model.cached_patcher_init = (_reload_gguf_unet, (unet_path, weight_dtype))
|
||||
|
||||
return (model, unet_name)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
|
||||
raise RuntimeError(
|
||||
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
|
||||
)
|
||||
@@ -778,6 +778,14 @@ class SaveImageLM:
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
|
||||
# The recipe image is the WebP produced above from the output file;
|
||||
# reuse the same metadata extraction to record workflow presence.
|
||||
try:
|
||||
metadata = ExifUtils._load_structured_metadata(image_path)
|
||||
recipe_data["has_workflow"] = bool(metadata.get("workflow"))
|
||||
except Exception:
|
||||
recipe_data["has_workflow"] = False
|
||||
|
||||
json_path = os.path.normpath(
|
||||
os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
)
|
||||
|
||||
+77
-2
@@ -28,6 +28,10 @@ class UNETLoaderLM:
|
||||
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
|
||||
extra folder paths, providing a unified interface for UNET loading.
|
||||
Supports both regular diffusion models and GGUF format models.
|
||||
The unet_name combo supports ComfyUI's control_after_generate, letting
|
||||
users pick a random diffusion model on every run; the base_model input
|
||||
narrows the random pool through a front-end extension that filters the
|
||||
combo options.
|
||||
"""
|
||||
|
||||
NAME = "Unet Loader (LoraManager)"
|
||||
@@ -37,16 +41,34 @@ class UNETLoaderLM:
|
||||
def INPUT_TYPES(cls):
|
||||
# Get list of unet names from scanner (includes extra folder paths)
|
||||
unet_names = cls._get_unet_names()
|
||||
base_models = cls._get_available_base_models()
|
||||
return {
|
||||
"required": {
|
||||
"unet_name": (
|
||||
unet_names,
|
||||
{"tooltip": "The name of the diffusion model to load."},
|
||||
{
|
||||
"tooltip": (
|
||||
"The name of the diffusion model to load. Use "
|
||||
"control_after_generate to pick a random model on "
|
||||
"every run."
|
||||
),
|
||||
"control_after_generate": "fixed",
|
||||
},
|
||||
),
|
||||
"weight_dtype": (
|
||||
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
|
||||
{"tooltip": "The dtype to use for the model weights."},
|
||||
),
|
||||
"base_model": (
|
||||
base_models,
|
||||
{
|
||||
"default": "Any",
|
||||
"tooltip": (
|
||||
"Restrict the random selection pool to this base "
|
||||
"model. 'Any' uses the full pool."
|
||||
),
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,16 +130,69 @@ class UNETLoaderLM:
|
||||
logger.error(f"Error getting unet names: {e}")
|
||||
return []
|
||||
|
||||
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple[Any, ...]:
|
||||
@classmethod
|
||||
def _get_available_base_models(cls) -> List[str]:
|
||||
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
|
||||
try:
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
|
||||
async def _get_base_models():
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
|
||||
base_models = set()
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != "diffusion_model":
|
||||
continue
|
||||
base_model = item.get("base_model")
|
||||
file_path = item.get("file_path", "")
|
||||
if base_model and file_path and os.path.exists(file_path):
|
||||
base_models.add(base_model)
|
||||
|
||||
return sorted(base_models)
|
||||
|
||||
return ["Any"] + cls._run_async(_get_base_models)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting available base models: {e}")
|
||||
return ["Any"]
|
||||
|
||||
@staticmethod
|
||||
def _run_async(coro_fn):
|
||||
"""Run an async fetcher, handling the case where an event loop is already running."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
import concurrent.futures
|
||||
|
||||
def run_in_thread():
|
||||
new_loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(new_loop)
|
||||
try:
|
||||
return new_loop.run_until_complete(coro_fn())
|
||||
finally:
|
||||
new_loop.close()
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(run_in_thread)
|
||||
return future.result()
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro_fn())
|
||||
|
||||
def load_unet(
|
||||
self, unet_name: str, weight_dtype: str, base_model: str = "Any"
|
||||
) -> Tuple[Any, ...]:
|
||||
"""Load a diffusion model by name, supporting extra folder paths
|
||||
|
||||
Args:
|
||||
unet_name: The name of the diffusion model to load (relative path with extension)
|
||||
weight_dtype: The dtype to use for model weights
|
||||
base_model: Only used by the front-end to filter the random pool
|
||||
|
||||
Returns:
|
||||
Tuple of (MODEL,)
|
||||
"""
|
||||
del base_model
|
||||
import torch
|
||||
|
||||
# Get absolute path from cache using ComfyUI-style name
|
||||
|
||||
@@ -31,6 +31,7 @@ class WanVideoLoraSelectLM:
|
||||
"placeholder": "Search LoRAs to add...",
|
||||
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
|
||||
}),
|
||||
"loras": ("LORAS", {}),
|
||||
},
|
||||
"optional": FlexibleOptionalInputType(any_type),
|
||||
}
|
||||
@@ -44,7 +45,7 @@ class WanVideoLoraSelectLM:
|
||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||
FUNCTION = "process_loras"
|
||||
|
||||
def process_loras(self, text, low_mem_load=False, merge_loras=True, **kwargs):
|
||||
def process_loras(self, text, loras, low_mem_load=False, merge_loras=True, **kwargs):
|
||||
loras_list = []
|
||||
all_trigger_words = []
|
||||
active_loras = []
|
||||
@@ -62,8 +63,8 @@ class WanVideoLoraSelectLM:
|
||||
selected_blocks = blocks.get("selected_blocks", {})
|
||||
layer_filter = blocks.get("layer_filter", "")
|
||||
|
||||
# Process loras from kwargs with support for both old and new formats
|
||||
loras_from_widget = get_loras_list(kwargs)
|
||||
# Process loras from the widget with support for both old and new formats
|
||||
loras_from_widget = get_loras_list({"loras": loras})
|
||||
for lora in loras_from_widget:
|
||||
if not lora.get('active', False):
|
||||
continue
|
||||
|
||||
@@ -41,6 +41,40 @@ class RecipeMetadataParser(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def populate_lora_from_local(lora_entry: Dict[str, Any], local_lora: Dict[str, Any], base_model_counts=None) -> Dict[str, Any]:
|
||||
"""Populate a recipe LoRA entry from the local scanner cache."""
|
||||
local_path = local_lora.get('file_path') or ''
|
||||
file_name = local_lora.get('file_name') or os.path.splitext(os.path.basename(local_path))[0]
|
||||
base_model = local_lora.get('base_model') or ''
|
||||
|
||||
lora_entry['name'] = local_lora.get('model_name') or file_name or lora_entry.get('name', '')
|
||||
lora_entry['file_name'] = file_name
|
||||
lora_entry['hash'] = (local_lora.get('sha256') or lora_entry.get('hash') or '').lower()
|
||||
lora_entry['localPath'] = local_path or None
|
||||
lora_entry['size'] = local_lora.get('size', 0) or 0
|
||||
lora_entry['baseModel'] = base_model
|
||||
lora_entry['existsLocally'] = True
|
||||
lora_entry['isDeleted'] = False
|
||||
|
||||
preview_url = local_lora.get('preview_url')
|
||||
if preview_url:
|
||||
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
|
||||
|
||||
civitai_info = local_lora.get('civitai') or {}
|
||||
if isinstance(civitai_info, dict):
|
||||
if civitai_info.get('id') is not None:
|
||||
lora_entry['id'] = civitai_info['id']
|
||||
if civitai_info.get('modelId') is not None:
|
||||
lora_entry['modelId'] = civitai_info['modelId']
|
||||
if civitai_info.get('name'):
|
||||
lora_entry['version'] = civitai_info['name']
|
||||
|
||||
if base_model_counts is not None and base_model:
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
|
||||
return lora_entry
|
||||
|
||||
@staticmethod
|
||||
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
|
||||
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+227
-68
@@ -8,6 +8,7 @@ from typing import Dict, Any
|
||||
from ..base import RecipeMetadataParser
|
||||
from ..constants import GEN_PARAM_KEYS
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...utils.constants import is_empty_placeholder_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -146,15 +147,13 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
# Initialize hashes dict if it doesn't exist
|
||||
if "hashes" not in metadata:
|
||||
metadata["hashes"] = {}
|
||||
# Add as lora type in the same format as
|
||||
# regular hashes. Only override an
|
||||
# existing entry if its value is empty
|
||||
# (Lora hashes is the more reliable
|
||||
# source when Hashes JSON has blanks).
|
||||
# Lora hashes carries the 12-char AutoV3
|
||||
# hash (resolvable on CivitAI and the local
|
||||
# autov3 index); the Hashes JSON value is
|
||||
# only the 10-char AutoV2 prefix, so on
|
||||
# conflict the Lora hashes value wins.
|
||||
key = f"lora:{lora_name}"
|
||||
existing = metadata["hashes"].get(key, "")
|
||||
if not existing:
|
||||
metadata["hashes"][key] = lora_hash
|
||||
metadata["hashes"][key] = lora_hash
|
||||
|
||||
# Remove lora hashes from params section
|
||||
params_section = params_section.replace(lora_hashes_match.group(0), '')
|
||||
@@ -362,68 +361,228 @@ class AutomaticMetadataParser(RecipeMetadataParser):
|
||||
|
||||
checkpoint = checkpoint_entry
|
||||
|
||||
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
|
||||
if not loras or len(loras) == 0:
|
||||
# Extract lora weights from extranet tags in prompt (for later use)
|
||||
lora_weights = {}
|
||||
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt)
|
||||
for lora_type, lora_name, lora_weight in lora_matches:
|
||||
key = f"{lora_type}:{lora_name}"
|
||||
lora_weights[key] = round(float(lora_weight), 2)
|
||||
|
||||
# Use hashes from metadata as the primary source
|
||||
if metadata.get("hashes"):
|
||||
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
||||
# Only process lora or hypernet types
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
def normalize_lora_name(name, basename=False):
|
||||
normalized = str(name or '').replace('\\', '/')
|
||||
if normalized.casefold().endswith('.safetensors'):
|
||||
normalized = normalized[:-12]
|
||||
if basename:
|
||||
normalized = normalized.rsplit('/', 1)[-1]
|
||||
return normalized.casefold()
|
||||
|
||||
def get_version_id(lora):
|
||||
version_id = lora.get('id')
|
||||
if version_id in (None, '', 0, '0'):
|
||||
version_id = lora.get('modelVersionId')
|
||||
if version_id in (None, '', 0, '0'):
|
||||
return None
|
||||
return str(version_id)
|
||||
|
||||
prompt_loras = {}
|
||||
for match in re.findall(self.EXTRANETS_REGEX, prompt):
|
||||
lora_type, lora_name, _ = match
|
||||
prompt_loras[(lora_type, normalize_lora_name(lora_name))] = match
|
||||
|
||||
prompt_by_basename = {}
|
||||
for lora_type, lora_name, lora_weight in prompt_loras.values():
|
||||
key = (lora_type, normalize_lora_name(lora_name, True))
|
||||
prompt_by_basename.setdefault(key, []).append((lora_name, round(float(lora_weight), 2)))
|
||||
|
||||
hash_basenames = {
|
||||
(hash_key.split(':', 1)[0], normalize_lora_name(hash_key.split(':', 1)[1], True))
|
||||
for hash_key, hash_value in metadata.get("hashes", {}).items()
|
||||
if hash_value and hash_key.startswith(("lora:", "hypernet:"))
|
||||
}
|
||||
recipe_base_model = checkpoint.get("baseModel") if checkpoint else None
|
||||
if not recipe_base_model and len(base_model_counts) == 1:
|
||||
recipe_base_model = next(iter(base_model_counts))
|
||||
|
||||
resource_lora_count = len(loras)
|
||||
|
||||
def make_lora_entry(lora_type, lora_name, weight, lora_hash=''):
|
||||
return {
|
||||
'name': lora_name,
|
||||
'type': lora_type,
|
||||
'weight': weight,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
def merge_or_append_civitai(civitai_entry, preserve_existing_weight=False):
|
||||
civitai_id = get_version_id(civitai_entry)
|
||||
civitai_hash = (civitai_entry.get('hash') or '').lower()
|
||||
for index, existing in enumerate(loras):
|
||||
existing_id = get_version_id(existing)
|
||||
existing_hash = (existing.get('hash') or '').lower()
|
||||
if not (
|
||||
(civitai_id and existing_id == civitai_id)
|
||||
or (civitai_hash and existing_hash == civitai_hash)
|
||||
):
|
||||
continue
|
||||
|
||||
if preserve_existing_weight:
|
||||
civitai_entry['weight'] = existing.get('weight', civitai_entry['weight'])
|
||||
existing_base = existing.get('baseModel')
|
||||
if not civitai_entry.get('baseModel'):
|
||||
civitai_entry['baseModel'] = existing_base or ''
|
||||
elif existing_base:
|
||||
remaining = base_model_counts.get(existing_base, 0) - 1
|
||||
if remaining > 0:
|
||||
base_model_counts[existing_base] = remaining
|
||||
else:
|
||||
base_model_counts.pop(existing_base, None)
|
||||
loras[index] = civitai_entry
|
||||
return
|
||||
loras.append(civitai_entry)
|
||||
|
||||
def merge_or_append_local(local_entry):
|
||||
local_id = get_version_id(local_entry)
|
||||
local_hash = (local_entry.get('hash') or '').lower()
|
||||
for existing in loras:
|
||||
existing_id = get_version_id(existing)
|
||||
existing_hash = (existing.get('hash') or '').lower()
|
||||
if not (
|
||||
(local_id and existing_id == local_id)
|
||||
or (local_hash and existing_hash == local_hash)
|
||||
):
|
||||
continue
|
||||
|
||||
existing['weight'] = local_entry['weight']
|
||||
existing['hash'] = local_entry['hash']
|
||||
existing['file_name'] = local_entry['file_name']
|
||||
existing['existsLocally'] = True
|
||||
existing['localPath'] = local_entry['localPath']
|
||||
existing['size'] = local_entry['size']
|
||||
existing['isDeleted'] = False
|
||||
if not existing.get('modelId') and local_entry.get('modelId'):
|
||||
existing['modelId'] = local_entry['modelId']
|
||||
if not existing.get('baseModel') and local_entry.get('baseModel'):
|
||||
existing['baseModel'] = local_entry['baseModel']
|
||||
base_model_counts[local_entry['baseModel']] = base_model_counts.get(local_entry['baseModel'], 0) + 1
|
||||
thumbnail_url = local_entry.get('thumbnailUrl')
|
||||
if thumbnail_url and not thumbnail_url.endswith('/images/no-preview.png'):
|
||||
existing['thumbnailUrl'] = thumbnail_url
|
||||
return
|
||||
|
||||
if local_entry.get('baseModel'):
|
||||
base_model = local_entry['baseModel']
|
||||
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
|
||||
loras.append(local_entry)
|
||||
|
||||
resolved_prompt_basenames = set()
|
||||
queried_local_basenames = set()
|
||||
for lora_type, lora_name, lora_weight in prompt_loras.values():
|
||||
weight = round(float(lora_weight), 2)
|
||||
basename_key = (lora_type, normalize_lora_name(lora_name, True))
|
||||
matching_resources = [
|
||||
lora
|
||||
for lora in loras[:resource_lora_count]
|
||||
if lora.get('file_name')
|
||||
and normalize_lora_name(lora['file_name'], True) == basename_key[1]
|
||||
and (
|
||||
(lora_type == 'hypernet' and str(lora.get('type', '')).casefold() in ('hypernet', 'hypernetwork'))
|
||||
or (lora_type == 'lora' and str(lora.get('type', '')).casefold() not in ('hypernet', 'hypernetwork'))
|
||||
)
|
||||
]
|
||||
if len(prompt_by_basename[basename_key]) == 1 and len(matching_resources) == 1:
|
||||
matching_resources[0]['weight'] = weight
|
||||
if basename_key not in hash_basenames:
|
||||
resolved_prompt_basenames.add(basename_key)
|
||||
continue
|
||||
|
||||
if basename_key in hash_basenames:
|
||||
continue
|
||||
|
||||
if not recipe_scanner or lora_type != 'lora':
|
||||
continue
|
||||
queried_local_basenames.add(basename_key)
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if not local_lora:
|
||||
continue
|
||||
|
||||
local_entry = self.populate_lora_from_local(
|
||||
make_lora_entry(lora_type, lora_name, weight),
|
||||
local_lora,
|
||||
)
|
||||
merge_or_append_local(local_entry)
|
||||
resolved_prompt_basenames.add(basename_key)
|
||||
|
||||
for hash_key, lora_hash in metadata.get("hashes", {}).items():
|
||||
if not hash_key.startswith(("lora:", "hypernet:")):
|
||||
continue
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
basename_key = (lora_type, normalize_lora_name(lora_name, True))
|
||||
if basename_key in resolved_prompt_basenames:
|
||||
continue
|
||||
|
||||
prompt_entries = prompt_by_basename.get(basename_key, [])
|
||||
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
|
||||
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
|
||||
|
||||
if is_empty_placeholder_hash(lora_hash):
|
||||
# The empty-hash placeholder (SHA256 of an empty byte
|
||||
# string) is not a real hash: never look it up in the
|
||||
# local hash index or on CivitAI. Match by filename;
|
||||
# otherwise keep the item as unresolved (no hash, flagged
|
||||
# hashInvalid so the UI shows the unresolvable-hash state
|
||||
# and offers reconnect instead of download) rather than
|
||||
# dropping it.
|
||||
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if local_lora:
|
||||
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
merge_or_append_local(local_entry)
|
||||
continue
|
||||
|
||||
# Skip entries without a hash value — they can't be
|
||||
# resolved via CivitAI and would only produce a
|
||||
# useless "Deleted" entry in the recipe.
|
||||
if not lora_hash:
|
||||
continue
|
||||
|
||||
lora_type, lora_name = hash_key.split(':', 1)
|
||||
|
||||
# Get weight from extranet tags if available, else default to 1.0
|
||||
weight = lora_weights.get(hash_key, 1.0)
|
||||
|
||||
# Initialize lora entry
|
||||
lora_entry = {
|
||||
'name': lora_name,
|
||||
'type': lora_type, # 'lora' or 'hypernet'
|
||||
'weight': weight,
|
||||
'hash': lora_hash,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': lora_name,
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
'size': 0,
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Try to get info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
|
||||
|
||||
lora_entry['hash'] = ''
|
||||
lora_entry['hashInvalid'] = True
|
||||
if not resource_lora_count:
|
||||
loras.append(lora_entry)
|
||||
continue
|
||||
|
||||
if lora_hash and recipe_scanner and lora_type == 'lora':
|
||||
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
|
||||
if local_lora:
|
||||
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
merge_or_append_local(local_entry)
|
||||
continue
|
||||
|
||||
hash_resolved = False
|
||||
if lora_hash and metadata_provider:
|
||||
try:
|
||||
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info,
|
||||
recipe_scanner,
|
||||
base_model_counts,
|
||||
lora_hash,
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue
|
||||
lora_entry = populated_entry
|
||||
hash_resolved = not lora_entry.get('isDeleted')
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
|
||||
|
||||
if hash_resolved:
|
||||
merge_or_append_civitai(lora_entry, preserve_existing_weight=not prompt_entries)
|
||||
continue
|
||||
|
||||
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if local_lora:
|
||||
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
merge_or_append_local(local_entry)
|
||||
continue
|
||||
|
||||
if lora_hash and not resource_lora_count:
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Try to get base model from resources or make educated guess
|
||||
base_model = None
|
||||
|
||||
@@ -115,6 +115,27 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
|
||||
):
|
||||
metadata = inner_meta
|
||||
|
||||
# Civitai's image API meta parser mangles the A1111 "Lora hashes"
|
||||
# text field into a quote-wrapped dict entry:
|
||||
# '"Daphne Blake Cosplay_v1": "e67ebd5e315f"'
|
||||
# The 12-char AutoV3 it carries is more reliable than the stale
|
||||
# 10-char AutoV2 value in the "hashes" dict, so recover it and
|
||||
# let it override the conflicting entry.
|
||||
if isinstance(metadata, dict):
|
||||
for key, hash_value in list(metadata.items()):
|
||||
if (
|
||||
isinstance(key, str)
|
||||
and key.startswith('"')
|
||||
and isinstance(hash_value, str)
|
||||
and hash_value.endswith('"')
|
||||
):
|
||||
clean_name = key.strip('"').strip()
|
||||
clean_hash = hash_value.strip('"').strip()
|
||||
if clean_name and clean_hash:
|
||||
hashes_dict = metadata.get("hashes")
|
||||
if isinstance(hashes_dict, dict):
|
||||
hashes_dict[f"lora:{clean_name}"] = clean_hash
|
||||
|
||||
# Initialize result structure
|
||||
result: Dict[str, Any] = {
|
||||
"base_model": None,
|
||||
|
||||
+112
-75
@@ -31,41 +31,106 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
||||
metadata_provider = await get_default_metadata_provider()
|
||||
|
||||
data = json.loads(user_comment)
|
||||
|
||||
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
|
||||
checkpoint = None
|
||||
checkpoint_id = None
|
||||
checkpoint_version_id = None
|
||||
if checkpoint_nodes:
|
||||
checkpoint_node = next(iter(checkpoint_nodes.values()))
|
||||
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
|
||||
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
|
||||
# Some ComfyUI workflows serialize ckpt_name as a
|
||||
# single-element list (e.g. ["model.safetensors"]) or leave
|
||||
# the value unset (None). Neither is a string, so skip the
|
||||
# CivitAI-URN lookup instead of crashing re.search with a
|
||||
# TypeError that fails the whole image import.
|
||||
if isinstance(checkpoint_name, list):
|
||||
checkpoint_name = (
|
||||
checkpoint_name[0] if checkpoint_name else None
|
||||
)
|
||||
if isinstance(checkpoint_name, str):
|
||||
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
|
||||
if checkpoint_match:
|
||||
checkpoint_id = checkpoint_match.group(1)
|
||||
checkpoint_version_id = checkpoint_match.group(2)
|
||||
checkpoint = {
|
||||
'id': checkpoint_version_id,
|
||||
'modelId': checkpoint_id,
|
||||
'name': f"Checkpoint {checkpoint_id}",
|
||||
'version': '',
|
||||
'type': 'checkpoint'
|
||||
}
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
|
||||
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
|
||||
|
||||
recipe_base_model = checkpoint.get('baseModel') if checkpoint else None
|
||||
loras = []
|
||||
|
||||
# Find all LoraLoader nodes
|
||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||
|
||||
# Process each LoraLoader node
|
||||
for node_id, node in lora_nodes.items():
|
||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||
lora_candidates = []
|
||||
for node in data.values():
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
|
||||
lora_name = node['inputs'].get('lora_name', '')
|
||||
|
||||
# Parse the URN to extract model ID and version ID
|
||||
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
|
||||
|
||||
inputs = node.get('inputs')
|
||||
if not isinstance(inputs, dict):
|
||||
continue
|
||||
|
||||
if node.get('class_type') == 'LoraLoader':
|
||||
lora_name = inputs.get('lora_name', '')
|
||||
if isinstance(lora_name, str) and lora_name:
|
||||
lora_candidates.append((lora_name, inputs.get('strength_model', 1.0)))
|
||||
continue
|
||||
|
||||
if node.get('class_type') != 'LoraLoaderLM':
|
||||
continue
|
||||
|
||||
loras_data = inputs.get('loras', [])
|
||||
if isinstance(loras_data, dict):
|
||||
loras_data = loras_data.get('__value__', [])
|
||||
if isinstance(loras_data, list) and len(loras_data) == 1 and isinstance(loras_data[0], list):
|
||||
loras_data = loras_data[0]
|
||||
if not isinstance(loras_data, list):
|
||||
continue
|
||||
|
||||
for lora in loras_data:
|
||||
if not isinstance(lora, dict) or not lora.get('active', False) or lora.get('_isDummy', False):
|
||||
continue
|
||||
lora_name = lora.get('name', '')
|
||||
if isinstance(lora_name, str) and lora_name:
|
||||
lora_candidates.append((lora_name, lora.get('strength', 1.0)))
|
||||
|
||||
for lora_name, weight in lora_candidates:
|
||||
if isinstance(weight, str):
|
||||
try:
|
||||
weight = float(weight)
|
||||
except ValueError:
|
||||
weight = 1.0
|
||||
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
|
||||
if not lora_id_match:
|
||||
continue
|
||||
|
||||
model_id = lora_id_match.group(1)
|
||||
model_version_id = lora_id_match.group(2)
|
||||
|
||||
# Get strength from node inputs
|
||||
weight = node['inputs'].get('strength_model', 1.0)
|
||||
|
||||
# Initialize lora entry with default values
|
||||
if lora_id_match:
|
||||
model_id = lora_id_match.group(1)
|
||||
model_version_id = lora_id_match.group(2)
|
||||
entry_name = f"Lora {model_id}"
|
||||
else:
|
||||
model_id = 0
|
||||
model_version_id = 0
|
||||
entry_name = re.split(r'[\\/]', lora_name)[-1]
|
||||
entry_name = re.sub(r'\.[^.]+$', '', entry_name)
|
||||
|
||||
lora_entry = {
|
||||
'id': model_version_id,
|
||||
'modelId': model_id,
|
||||
'name': f"Lora {model_id}", # Default name
|
||||
'name': entry_name,
|
||||
'version': '',
|
||||
'type': 'lora',
|
||||
'weight': weight,
|
||||
'existsLocally': False,
|
||||
'localPath': None,
|
||||
'file_name': '',
|
||||
'file_name': entry_name,
|
||||
'hash': '',
|
||||
'thumbnailUrl': '/loras_static/images/no-preview.png',
|
||||
'baseModel': '',
|
||||
@@ -73,59 +138,31 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
||||
'downloadUrl': '',
|
||||
'isDeleted': False
|
||||
}
|
||||
|
||||
# Get additional info from Civitai if metadata provider is available
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
||||
# Populate lora entry with Civitai info
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue # Skip invalid LoRA types
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
|
||||
|
||||
if lora_id_match:
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
|
||||
populated_entry = await self.populate_lora_from_civitai(
|
||||
lora_entry,
|
||||
civitai_info_tuple,
|
||||
recipe_scanner
|
||||
)
|
||||
if populated_entry is None:
|
||||
continue
|
||||
lora_entry = populated_entry
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for LoRA: {e}")
|
||||
else:
|
||||
if not recipe_scanner:
|
||||
continue
|
||||
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
|
||||
if not local_lora:
|
||||
continue
|
||||
lora_entry = self.populate_lora_from_local(lora_entry, local_lora)
|
||||
|
||||
loras.append(lora_entry)
|
||||
|
||||
# Find checkpoint info
|
||||
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
|
||||
checkpoint = None
|
||||
checkpoint_id = None
|
||||
checkpoint_version_id = None
|
||||
|
||||
if checkpoint_nodes:
|
||||
# Get the first checkpoint node
|
||||
checkpoint_node = next(iter(checkpoint_nodes.values()))
|
||||
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
|
||||
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
|
||||
# Parse checkpoint URN
|
||||
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
|
||||
if checkpoint_match:
|
||||
checkpoint_id = checkpoint_match.group(1)
|
||||
checkpoint_version_id = checkpoint_match.group(2)
|
||||
checkpoint = {
|
||||
'id': checkpoint_version_id,
|
||||
'modelId': checkpoint_id,
|
||||
'name': f"Checkpoint {checkpoint_id}",
|
||||
'version': '',
|
||||
'type': 'checkpoint'
|
||||
}
|
||||
|
||||
# Get additional checkpoint info from Civitai
|
||||
if metadata_provider:
|
||||
try:
|
||||
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
|
||||
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
|
||||
# Populate checkpoint with Civitai info
|
||||
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
|
||||
|
||||
# Extract generation parameters
|
||||
gen_params = {}
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
filtered_gen_params[key] = value
|
||||
|
||||
return {
|
||||
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
|
||||
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else (recipe_metadata.get('base_model') or None),
|
||||
'loras': loras,
|
||||
'gen_params': filtered_gen_params,
|
||||
'tags': recipe_metadata.get('tags', []),
|
||||
@@ -208,3 +208,24 @@ class RecipeFormatParser(RecipeMetadataParser):
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
|
||||
return {"error": str(e), "loras": []}
|
||||
|
||||
|
||||
def strip_recipe_metadata(metadata_text: str) -> str:
|
||||
"""Strip the ``Recipe metadata: {...}`` block appended by LoRA Manager.
|
||||
|
||||
The saved recipe image carries the original generation metadata followed
|
||||
by an appended recipe JSON block (see ``ExifUtils.append_recipe_metadata``).
|
||||
Re-import wants to re-parse the original embedded metadata, so this returns
|
||||
only the text before the appended marker. The input is returned unchanged
|
||||
when no marker is present.
|
||||
"""
|
||||
if not metadata_text:
|
||||
return metadata_text
|
||||
match = re.search(
|
||||
RecipeFormatParser.METADATA_MARKER,
|
||||
metadata_text,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if not match:
|
||||
return metadata_text
|
||||
return metadata_text[: match.start()].strip()
|
||||
|
||||
@@ -32,6 +32,7 @@ from .handlers.recipe_handlers import (
|
||||
RecipePageView,
|
||||
RecipeQueryHandler,
|
||||
RecipeSharingHandler,
|
||||
RecipeWorkflowHandler,
|
||||
)
|
||||
from .recipe_route_registrar import ROUTE_DEFINITIONS
|
||||
|
||||
@@ -200,6 +201,18 @@ class BaseRecipeRoutes:
|
||||
sharing_service=sharing_service,
|
||||
)
|
||||
|
||||
# Lazy import: standalone mode replaces the ``server`` module with a
|
||||
# mock, so resolve PromptServer at handler-set build time instead of
|
||||
# module import time. The handler's standalone check guards UX.
|
||||
from server import PromptServer # pyright: ignore[reportMissingImports]
|
||||
|
||||
workflow = RecipeWorkflowHandler(
|
||||
ensure_dependencies_ready=self.ensure_dependencies_ready,
|
||||
recipe_scanner_getter=recipe_scanner_getter,
|
||||
prompt_server=PromptServer,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
from ..services.websocket_manager import ws_manager
|
||||
|
||||
batch_import_service = BatchImportService(
|
||||
@@ -224,4 +237,5 @@ class BaseRecipeRoutes:
|
||||
analysis=analysis,
|
||||
sharing=sharing,
|
||||
batch_import=batch_import,
|
||||
workflow=workflow,
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Set
|
||||
from aiohttp import web
|
||||
|
||||
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
|
||||
from ..services.checkpoint_service import CheckpointService
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..config import config
|
||||
from ..utils.utils import _format_model_name_for_comfyui
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,7 +46,46 @@ class CheckpointRoutes(BaseModelRoutes):
|
||||
# Checkpoint roots and Unet roots
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
|
||||
|
||||
# Name/base_model pool for the Checkpoint/Unet Loader nodes' base_model filtering
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
|
||||
|
||||
async def get_loader_pool(self, request: web.Request) -> web.Response:
|
||||
"""Return ComfyUI-formatted model names with their base_model.
|
||||
|
||||
Backing data for the Checkpoint/Unet Loader nodes'
|
||||
control_after_generate feature: the front-end filters the
|
||||
ckpt_name/unet_name combo options by base_model using this pool, so
|
||||
randomize mode picks within the narrowed set.
|
||||
"""
|
||||
try:
|
||||
sub_type = request.query.get("sub_type", "checkpoint")
|
||||
if sub_type not in ("checkpoint", "diffusion_model"):
|
||||
return web.json_response({"error": "invalid sub_type"}, status=400)
|
||||
scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
cache = await scanner.get_cached_data()
|
||||
model_roots = scanner.get_model_roots()
|
||||
items: List[Dict[str, str]] = []
|
||||
for item in cache.raw_data:
|
||||
if item.get("sub_type") != sub_type:
|
||||
continue
|
||||
file_path = item.get("file_path", "")
|
||||
if not file_path or not os.path.exists(file_path):
|
||||
continue
|
||||
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
|
||||
if formatted_name:
|
||||
items.append(
|
||||
{
|
||||
"name": formatted_name,
|
||||
"base_model": item.get("base_model", "") or "",
|
||||
}
|
||||
)
|
||||
items.sort(key=lambda x: x["name"])
|
||||
return web.json_response({"items": items})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting loader pool: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||
"""Validate CivitAI model type for Checkpoint"""
|
||||
return model_type.lower() == 'checkpoint'
|
||||
|
||||
@@ -56,6 +56,7 @@ from ...utils.constants import (
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
from .model_handlers import ModelCivitaiHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import (
|
||||
find_non_compliant_items_in_example_images_root,
|
||||
@@ -648,9 +649,60 @@ class NodeRegistry:
|
||||
|
||||
|
||||
class HealthCheckHandler:
|
||||
def __init__(
|
||||
self,
|
||||
scanner_getters: Mapping[str, Callable[[], Awaitable[Any]]] | None = None,
|
||||
) -> None:
|
||||
self._scanner_getters = scanner_getters or {
|
||||
"lora": ServiceRegistry.get_lora_scanner,
|
||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||
}
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
async def get_init_status(self, request: web.Request) -> web.Response:
|
||||
"""Report aggregate scanner initialization status.
|
||||
|
||||
Used by the initialization page's polling fallback when the
|
||||
/ws/init-progress WebSocket is unavailable. Omits pageType so every
|
||||
page accepts the update and only reloads once all scanners are done.
|
||||
"""
|
||||
pending: list[str] = []
|
||||
for name, getter in self._scanner_getters.items():
|
||||
try:
|
||||
scanner = await getter()
|
||||
except Exception:
|
||||
pending.append(name)
|
||||
continue
|
||||
cache_ready = getattr(scanner, "_cache", None) is not None
|
||||
is_initializing = getattr(scanner, "is_initializing", None)
|
||||
busy = (
|
||||
is_initializing()
|
||||
if callable(is_initializing)
|
||||
else bool(getattr(scanner, "_is_initializing", False))
|
||||
)
|
||||
if busy or not cache_ready:
|
||||
pending.append(name)
|
||||
|
||||
if pending:
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "initializing",
|
||||
"stage": "processing",
|
||||
"details": "Initializing: " + ", ".join(pending),
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"status": "complete",
|
||||
"progress": 100,
|
||||
"details": "Initialization complete",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SupportersHandler:
|
||||
"""Handler for supporters data."""
|
||||
@@ -2061,6 +2113,63 @@ class ModelLibraryHandler:
|
||||
enriched.append(entry)
|
||||
return enriched
|
||||
|
||||
@staticmethod
|
||||
async def _get_downloaded_files(
|
||||
scanner: Any, model_version_id: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return per-file downloaded state for a version in the library.
|
||||
|
||||
This handler has no CivitAI version payload, so the remote file list
|
||||
is taken from the local entries' cached ``civitai`` metadata (the
|
||||
full version payload persisted at download time, see
|
||||
``BaseModelMetadata.from_civitai_info``) and matched with the same
|
||||
D2 rule used by ``get_civitai_versions`` (#1058). Local entries that
|
||||
cannot be matched to a known remote file (e.g. missing metadata or
|
||||
renamed files) are still reported with ``fileId`` set to None.
|
||||
Returns ``[{fileId, fileName, filePath}]``.
|
||||
"""
|
||||
try:
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception: # pragma: no cover - defensive fallback
|
||||
logger.debug(
|
||||
"Failed to read cache for downloaded files of version %s",
|
||||
model_version_id,
|
||||
exc_info=True,
|
||||
)
|
||||
return []
|
||||
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
local_entries = files_getter(model_version_id) if files_getter else []
|
||||
if not local_entries:
|
||||
return []
|
||||
|
||||
version_payload: Mapping[str, Any] = {}
|
||||
for entry in local_entries:
|
||||
civitai = entry.get("civitai") if isinstance(entry, Mapping) else None
|
||||
if isinstance(civitai, Mapping) and isinstance(civitai.get("files"), list):
|
||||
version_payload = civitai
|
||||
break
|
||||
|
||||
downloaded = ModelCivitaiHandler._match_downloaded_files(
|
||||
version_payload, local_entries
|
||||
)
|
||||
|
||||
# Surface local files that D2 could not map to a known remote file
|
||||
matched_paths = {item.get("filePath") for item in downloaded}
|
||||
for entry in local_entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
if entry.get("file_path") in matched_paths:
|
||||
continue
|
||||
downloaded.append(
|
||||
{
|
||||
"fileId": None,
|
||||
"fileName": entry.get("file_name"),
|
||||
"filePath": entry.get("file_path"),
|
||||
}
|
||||
)
|
||||
return downloaded
|
||||
|
||||
async def check_model_exists(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_id_str = request.query.get("modelId")
|
||||
@@ -2096,9 +2205,11 @@ class ModelLibraryHandler:
|
||||
|
||||
exists = False
|
||||
model_type = None
|
||||
matched_scanner = None
|
||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||
exists = True
|
||||
model_type = "lora"
|
||||
matched_scanner = lora_scanner
|
||||
elif (
|
||||
checkpoint_scanner
|
||||
and await checkpoint_scanner.check_model_version_exists(
|
||||
@@ -2107,6 +2218,7 @@ class ModelLibraryHandler:
|
||||
):
|
||||
exists = True
|
||||
model_type = "checkpoint"
|
||||
matched_scanner = checkpoint_scanner
|
||||
elif (
|
||||
embedding_scanner
|
||||
and await embedding_scanner.check_model_version_exists(
|
||||
@@ -2115,6 +2227,7 @@ class ModelLibraryHandler:
|
||||
):
|
||||
exists = True
|
||||
model_type = "embedding"
|
||||
matched_scanner = embedding_scanner
|
||||
|
||||
if exists:
|
||||
return web.json_response(
|
||||
@@ -2123,6 +2236,9 @@ class ModelLibraryHandler:
|
||||
"exists": True,
|
||||
"modelType": model_type,
|
||||
"hasBeenDownloaded": False,
|
||||
"downloadedFiles": await self._get_downloaded_files(
|
||||
matched_scanner, model_version_id
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2144,6 +2260,7 @@ class ModelLibraryHandler:
|
||||
"exists": False,
|
||||
"modelType": history_type,
|
||||
"hasBeenDownloaded": has_been_downloaded,
|
||||
"downloadedFiles": [],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2428,8 +2545,8 @@ class ModelLibraryHandler:
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
|
||||
found_type = None
|
||||
file_path = None
|
||||
found_cache = None
|
||||
entries: list = []
|
||||
|
||||
for model_type, scanner in (
|
||||
("lora", lora_scanner),
|
||||
@@ -2440,27 +2557,43 @@ class ModelLibraryHandler:
|
||||
if cache and model_version_id in cache.version_index:
|
||||
found_type = model_type
|
||||
found_cache = cache
|
||||
entry = cache.version_index[model_version_id]
|
||||
file_path = entry.get("file_path")
|
||||
# A version can have several local files (#1058); collect
|
||||
# them all so the delete below covers every file.
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if files_getter is not None:
|
||||
entries = files_getter(model_version_id)
|
||||
else:
|
||||
entries = [cache.version_index[model_version_id]]
|
||||
break
|
||||
|
||||
if not file_path:
|
||||
file_paths = [
|
||||
entry.get("file_path")
|
||||
for entry in entries
|
||||
if isinstance(entry, dict) and entry.get("file_path")
|
||||
]
|
||||
|
||||
if not file_paths:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Model version not found in any scanner cache"},
|
||||
status=404,
|
||||
)
|
||||
|
||||
target_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, extension = os.path.splitext(base_name)
|
||||
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||
for file_path in file_paths:
|
||||
target_dir = os.path.dirname(file_path)
|
||||
base_name = os.path.basename(file_path)
|
||||
file_name, extension = os.path.splitext(base_name)
|
||||
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
|
||||
|
||||
if found_cache:
|
||||
removed_paths = set(file_paths)
|
||||
found_cache.raw_data = [
|
||||
item
|
||||
for item in found_cache.raw_data
|
||||
if item.get("file_path") != file_path
|
||||
if item.get("file_path") not in removed_paths
|
||||
]
|
||||
rebuild = getattr(found_cache, "rebuild_version_index", None)
|
||||
if rebuild is not None:
|
||||
rebuild()
|
||||
await found_cache.resort()
|
||||
|
||||
scanner_map = {
|
||||
@@ -2483,6 +2616,7 @@ class ModelLibraryHandler:
|
||||
"success": True,
|
||||
"modelType": found_type,
|
||||
"modelVersionId": model_version_id,
|
||||
"deletedFiles": len(file_paths),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -3776,6 +3910,7 @@ class MiscHandlerSet:
|
||||
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
|
||||
return {
|
||||
"health_check": self.health.health_check,
|
||||
"get_init_status": self.health.get_init_status,
|
||||
"get_settings": self.settings.get_settings,
|
||||
"update_settings": self.settings.update_settings,
|
||||
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
|
||||
|
||||
@@ -364,6 +364,7 @@ class ModelListingHandler:
|
||||
== "true",
|
||||
"tags": request.query.get("search_tags", "false").lower() == "true",
|
||||
"creator": request.query.get("search_creator", "false").lower() == "true",
|
||||
"hash": request.query.get("search_hash", "false").lower() == "true",
|
||||
"recursive": request.query.get("recursive", "true").lower() == "true",
|
||||
}
|
||||
|
||||
@@ -633,6 +634,16 @@ class ModelManagementHandler:
|
||||
file_path = data.get("file_path")
|
||||
model_id = data.get("model_id")
|
||||
model_version_id = data.get("model_version_id")
|
||||
source = data.get("source")
|
||||
|
||||
if source not in (None, "", "civarchive"):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": f"Unsupported relink source: {source}",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if not file_path or model_id is None:
|
||||
return web.json_response(
|
||||
@@ -648,20 +659,33 @@ class ModelManagementHandler:
|
||||
metadata_path
|
||||
)
|
||||
|
||||
relink_kwargs = {
|
||||
"file_path": file_path,
|
||||
"metadata": local_metadata,
|
||||
"model_id": int(model_id),
|
||||
"model_version_id": int(model_version_id) if model_version_id else None,
|
||||
}
|
||||
if source == "civarchive":
|
||||
relink_kwargs["provider_name"] = "civarchive_api"
|
||||
|
||||
updated_metadata = await self._metadata_sync.relink_metadata(
|
||||
file_path=file_path,
|
||||
metadata=local_metadata,
|
||||
model_id=int(model_id),
|
||||
model_version_id=int(model_version_id) if model_version_id else None,
|
||||
**relink_kwargs
|
||||
)
|
||||
|
||||
await self._service.scanner.update_single_model_cache(
|
||||
file_path, file_path, updated_metadata
|
||||
)
|
||||
|
||||
message = f"Model successfully re-linked to Civitai model {model_id}" + (
|
||||
f" version {model_version_id}" if model_version_id else ""
|
||||
)
|
||||
if source == "civarchive":
|
||||
message = (
|
||||
f"Model successfully re-linked to CivArchive model {model_id}"
|
||||
+ (f" version {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
else:
|
||||
message = (
|
||||
f"Model successfully re-linked to Civitai model {model_id}"
|
||||
+ (f" version {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
@@ -669,6 +693,8 @@ class ModelManagementHandler:
|
||||
"hash": updated_metadata.get("sha256", ""),
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
if is_expected_offline_error(str(exc)):
|
||||
return web.json_response(
|
||||
@@ -1029,6 +1055,11 @@ class ModelQueryHandler:
|
||||
self._service = service
|
||||
self._logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _parse_include_empty(request: web.Request) -> bool:
|
||||
"""Parse the include_empty query flag (``1``/``true``)."""
|
||||
return request.query.get("include_empty", "").lower() in ("1", "true")
|
||||
|
||||
async def get_top_tags(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
limit = int(request.query.get("limit", "20"))
|
||||
@@ -1123,8 +1154,14 @@ class ModelQueryHandler:
|
||||
|
||||
async def get_folders(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
return web.json_response({"folders": cache.folders})
|
||||
include_empty = self._parse_include_empty(request)
|
||||
if include_empty:
|
||||
# Live enumeration includes empty OS-created directories.
|
||||
folders = await self._service.scanner.get_all_folders()
|
||||
else:
|
||||
cache = await self._service.scanner.get_cached_data()
|
||||
folders = cache.folders
|
||||
return web.json_response({"folders": folders})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting folders: %s", exc)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
@@ -1149,7 +1186,9 @@ class ModelQueryHandler:
|
||||
{"success": False, "error": "model_root parameter is required"},
|
||||
status=400,
|
||||
)
|
||||
folder_tree = await self._service.get_folder_tree(model_root)
|
||||
folder_tree = await self._service.get_folder_tree(
|
||||
model_root, include_empty=self._parse_include_empty(request)
|
||||
)
|
||||
return web.json_response({"success": True, "tree": folder_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting folder tree: %s", exc)
|
||||
@@ -1157,7 +1196,9 @@ class ModelQueryHandler:
|
||||
|
||||
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
unified_tree = await self._service.get_unified_folder_tree()
|
||||
unified_tree = await self._service.get_unified_folder_tree(
|
||||
include_empty=self._parse_include_empty(request)
|
||||
)
|
||||
return web.json_response({"success": True, "tree": unified_tree})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting unified folder tree: %s", exc)
|
||||
@@ -1659,7 +1700,8 @@ class ModelDownloadHandler:
|
||||
import json
|
||||
|
||||
try:
|
||||
data["file_params"] = json.loads(file_params_json)
|
||||
# Normalize falsy payloads (e.g. {}) to None (#1058)
|
||||
data["file_params"] = json.loads(file_params_json) or None
|
||||
except json.JSONDecodeError:
|
||||
self._logger.warning(
|
||||
"Invalid file_params JSON: %s", file_params_json
|
||||
@@ -1811,7 +1853,8 @@ class ModelDownloadHandler:
|
||||
|
||||
model_id = int(model_id_str) if model_id_str else None
|
||||
model_version_id = int(model_version_id_str) if model_version_id_str else None
|
||||
file_params = json.loads(file_params_json) if file_params_json else None
|
||||
# Normalize falsy payloads (e.g. {}) to None (#1058)
|
||||
file_params = (json.loads(file_params_json) if file_params_json else None) or None
|
||||
|
||||
service = await DownloadQueueService.get_instance()
|
||||
item = await service.add_to_queue(
|
||||
@@ -1886,8 +1929,18 @@ class ModelDownloadHandler:
|
||||
try:
|
||||
status_filter = request.query.get("status") or None
|
||||
service = await DownloadQueueService.get_instance()
|
||||
cleared = await service.clear_queue(status_filter=status_filter)
|
||||
return web.json_response({"success": True, "cleared": cleared})
|
||||
cleared_ids = await service.clear_queue(status_filter=status_filter)
|
||||
# Clearing the queue rows alone would orphan any in-memory tasks
|
||||
# and persisted aria2 state for those downloads, leaving them
|
||||
# polling the daemon invisibly. Tear that tracking down too.
|
||||
try:
|
||||
await self._download_coordinator.discard_cleared_downloads(cleared_ids)
|
||||
except Exception:
|
||||
self._logger.warning(
|
||||
"Failed to discard in-memory state for cleared downloads",
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"success": True, "cleared": len(cleared_ids)})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error clearing download queue: %s", exc, exc_info=True
|
||||
@@ -1970,9 +2023,11 @@ class ModelDownloadHandler:
|
||||
item_id=item_id, download_id=download_id
|
||||
)
|
||||
if item is None:
|
||||
# Missing or non-retryable history entry is a business
|
||||
# outcome, not a routing error: 200 lets the extension's
|
||||
# apiFetch 404-fallback and error middleware stay quiet.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "History item not found or not retryable"},
|
||||
status=404,
|
||||
{"success": False, "error": "History item not found or not retryable"}
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
@@ -2023,8 +2078,12 @@ class ModelDownloadHandler:
|
||||
completed_at=completed_at,
|
||||
)
|
||||
if item is None:
|
||||
# A missing queue item (already completed, or never queued) is
|
||||
# a normal business outcome, not a routing error. Return 200
|
||||
# so the browser extension's apiFetch 404-fallback and the
|
||||
# error middleware stay quiet.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"}, status=404
|
||||
{"success": False, "error": "Download not found in queue"}
|
||||
)
|
||||
return web.json_response({"success": True, "item": item})
|
||||
except Exception as exc:
|
||||
@@ -2066,9 +2125,10 @@ class ModelDownloadHandler:
|
||||
service = await DownloadQueueService.get_instance()
|
||||
updated = await service.update_status(download_id, status)
|
||||
if not updated:
|
||||
# Same rationale as complete_download_in_queue: a missing
|
||||
# queue item is a business outcome, not a routing error.
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Download not found in queue"},
|
||||
status=404,
|
||||
{"success": False, "error": "Download not found in queue"}
|
||||
)
|
||||
return web.json_response({"success": True})
|
||||
except Exception as exc:
|
||||
@@ -2187,6 +2247,19 @@ class ModelCivitaiHandler:
|
||||
else:
|
||||
version.pop("localPath", None)
|
||||
|
||||
# Per-file downloaded state so multi-file versions can show
|
||||
# which individual files are already in the library (#1058)
|
||||
local_entries: List[Any] = []
|
||||
if version_id is not None and cache:
|
||||
files_getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if files_getter is not None:
|
||||
local_entries = files_getter(version_id)
|
||||
elif cache_entry is not None:
|
||||
local_entries = [cache_entry]
|
||||
version["downloadedFiles"] = self._match_downloaded_files(
|
||||
version, local_entries
|
||||
)
|
||||
|
||||
model_file = (
|
||||
self._find_model_file(version.get("files", []))
|
||||
if isinstance(version.get("files"), Iterable)
|
||||
@@ -2201,6 +2274,64 @@ class ModelCivitaiHandler:
|
||||
)
|
||||
return web.Response(status=500, text=str(exc))
|
||||
|
||||
@staticmethod
|
||||
def _match_downloaded_files(
|
||||
version: Mapping[str, Any], local_entries: List[Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Map local library entries back to individual files of a version.
|
||||
|
||||
Matching follows rule D2 (#1058): SHA256 is authoritative when the
|
||||
local entry carries one; otherwise fall back to extension-less file
|
||||
name equality. Returns ``[{fileId, fileName, filePath}]``.
|
||||
"""
|
||||
files = version.get("files")
|
||||
if not isinstance(files, list) or not local_entries:
|
||||
return []
|
||||
|
||||
by_hash: Dict[str, Mapping[str, Any]] = {}
|
||||
by_name: Dict[str, Mapping[str, Any]] = {}
|
||||
for file_info in files:
|
||||
if not isinstance(file_info, Mapping):
|
||||
continue
|
||||
sha = str(
|
||||
(file_info.get("hashes") or {}).get("SHA256") or ""
|
||||
).strip().lower()
|
||||
if sha:
|
||||
by_hash.setdefault(sha, file_info)
|
||||
name = str(file_info.get("name") or "").strip()
|
||||
if name:
|
||||
by_name.setdefault(os.path.splitext(name)[0], file_info)
|
||||
|
||||
downloaded: List[Dict[str, Any]] = []
|
||||
seen_keys: set = set()
|
||||
for entry in local_entries:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
matched: Optional[Mapping[str, Any]] = None
|
||||
local_hash = str(entry.get("sha256") or "").strip().lower()
|
||||
if local_hash:
|
||||
matched = by_hash.get(local_hash)
|
||||
if matched is None:
|
||||
local_name = str(entry.get("file_name") or "").strip()
|
||||
if local_name:
|
||||
matched = by_name.get(local_name)
|
||||
if matched is None:
|
||||
continue
|
||||
|
||||
file_id = matched.get("id")
|
||||
dedupe_key = file_id if file_id is not None else matched.get("name")
|
||||
if dedupe_key in seen_keys:
|
||||
continue
|
||||
seen_keys.add(dedupe_key)
|
||||
downloaded.append(
|
||||
{
|
||||
"fileId": file_id,
|
||||
"fileName": matched.get("name"),
|
||||
"filePath": entry.get("file_path"),
|
||||
}
|
||||
)
|
||||
return downloaded
|
||||
|
||||
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
model_version_id = request.match_info.get("modelVersionId")
|
||||
@@ -2548,10 +2679,20 @@ class ModelUpdateHandler:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
same_base_scope = self._uses_same_base_update_scope()
|
||||
|
||||
serialized_records = []
|
||||
for record in records.values():
|
||||
has_update_fn = getattr(record, "has_update", None)
|
||||
if callable(has_update_fn) and has_update_fn(
|
||||
if not callable(has_update_fn):
|
||||
continue
|
||||
scoped_fn = (
|
||||
getattr(record, "has_update_for_local_bases", None)
|
||||
if same_base_scope
|
||||
else None
|
||||
)
|
||||
qualifies_fn = scoped_fn if callable(scoped_fn) else has_update_fn
|
||||
if qualifies_fn(
|
||||
hide_early_access=hide_early_access,
|
||||
hide_paid=hide_paid,
|
||||
):
|
||||
@@ -2564,6 +2705,26 @@ class ModelUpdateHandler:
|
||||
}
|
||||
)
|
||||
|
||||
def _uses_same_base_update_scope(self) -> bool:
|
||||
"""Return True when update reporting must honor same-base scoping.
|
||||
|
||||
Mirrors ``BaseModelService._annotate_update_flags``: the Updates filter
|
||||
evaluates updates per local base model when ``version_grouping`` is
|
||||
``same_base`` (its default). The refresh summary counts with the same
|
||||
scope so the "Found N update(s)" toast matches what the filter
|
||||
displays. See issue #1083.
|
||||
"""
|
||||
|
||||
if self._settings is None:
|
||||
return True
|
||||
try:
|
||||
strategy_value = self._settings.get("version_grouping")
|
||||
except Exception:
|
||||
return True
|
||||
if isinstance(strategy_value, str) and strategy_value.strip():
|
||||
return strategy_value.strip().lower() == "same_base"
|
||||
return True
|
||||
|
||||
async def set_model_update_ignore(self, request: web.Request) -> web.Response:
|
||||
payload = await self._read_json(request)
|
||||
model_id = self._normalize_model_id(payload.get("modelId"))
|
||||
@@ -3031,6 +3192,9 @@ class ModelUpdateHandler:
|
||||
"paidAccess": paid_access_payload,
|
||||
"filePath": context.get("file_path"),
|
||||
"fileName": context.get("file_name"),
|
||||
# Weight-file variant count (None when unknown); lets the UI hide
|
||||
# the download affordance for single-file in-library versions.
|
||||
"fileCount": getattr(version, "file_count", None),
|
||||
}
|
||||
|
||||
async def _build_version_context(
|
||||
|
||||
@@ -10,7 +10,7 @@ import asyncio
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
@@ -26,6 +26,7 @@ from ...services.recipes import (
|
||||
RecipeValidationError,
|
||||
)
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
|
||||
from ...utils.civitai_utils import (
|
||||
build_civitai_image_page_url,
|
||||
extract_civitai_image_id,
|
||||
@@ -45,6 +46,17 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
RecipeScannerGetter = Callable[[], Any]
|
||||
CivitaiClientGetter = Callable[[], Any]
|
||||
|
||||
|
||||
class PromptServerProtocol(Protocol):
|
||||
"""Subset of PromptServer used by the recipe workflow handler."""
|
||||
|
||||
instance: "PromptServerProtocol"
|
||||
|
||||
def send_sync(
|
||||
self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None
|
||||
) -> None: # pragma: no cover - protocol
|
||||
...
|
||||
|
||||
# Cap concurrent preview-dimension reads across requests. With a cold LRU
|
||||
# cache one page can touch up to page_size image files; 16 balances SSD and
|
||||
# HDD throughput without starving the event loop.
|
||||
@@ -73,6 +85,7 @@ class RecipeHandlerSet:
|
||||
analysis: "RecipeAnalysisHandler"
|
||||
sharing: "RecipeSharingHandler"
|
||||
batch_import: "BatchImportHandler"
|
||||
workflow: "RecipeWorkflowHandler"
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -101,6 +114,13 @@ class RecipeHandlerSet:
|
||||
"update_recipe": self.management.update_recipe,
|
||||
"record_recipe_open": self.management.record_recipe_open,
|
||||
"reconnect_lora": self.management.reconnect_lora,
|
||||
"restore_lora": self.management.restore_lora,
|
||||
"get_reconnect_suggestions": self.management.get_reconnect_suggestions,
|
||||
"mark_lora_hash_invalid": self.management.mark_lora_hash_invalid,
|
||||
"reconnect_checkpoint": self.management.reconnect_checkpoint,
|
||||
"restore_checkpoint": self.management.restore_checkpoint,
|
||||
"get_checkpoint_reconnect_suggestions": self.management.get_checkpoint_reconnect_suggestions,
|
||||
"mark_checkpoint_hash_invalid": self.management.mark_checkpoint_hash_invalid,
|
||||
"find_duplicates": self.query.find_duplicates,
|
||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||
"bulk_delete": self.management.bulk_delete,
|
||||
@@ -128,6 +148,7 @@ class RecipeHandlerSet:
|
||||
"import_from_url": self.management.import_from_url,
|
||||
"create_from_example": self.management.create_from_example,
|
||||
"reimport_recipe": self.management.reimport_recipe,
|
||||
"send_recipe_workflow": self.workflow.send_recipe_workflow,
|
||||
}
|
||||
|
||||
|
||||
@@ -163,11 +184,19 @@ class RecipePageView:
|
||||
user_language = self._settings.get("language", "en")
|
||||
self._server_i18n.set_locale(user_language)
|
||||
|
||||
# While the initial scan is running, show the initialization
|
||||
# screen (same as the model pages) instead of an empty grid; the
|
||||
# page reloads itself when the scanner broadcasts completion.
|
||||
is_initializing = (
|
||||
recipe_scanner._cache is None or recipe_scanner.is_initializing()
|
||||
)
|
||||
|
||||
try:
|
||||
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||
if not is_initializing:
|
||||
await recipe_scanner.get_cached_data(force_refresh=False)
|
||||
rendered = self._template_env.get_template(self._template_name).render(
|
||||
recipes=[],
|
||||
is_initializing=False,
|
||||
is_initializing=is_initializing,
|
||||
settings=self._settings,
|
||||
request=request,
|
||||
t=self._server_i18n.get_translation,
|
||||
@@ -253,6 +282,14 @@ class RecipeListingHandler:
|
||||
if tag_filters:
|
||||
filters["tags"] = tag_filters
|
||||
|
||||
lora_availability = {
|
||||
status.strip()
|
||||
for status in request.query.get("lora_availability", "").split(",")
|
||||
if status.strip() in ("ready", "missing", "deleted")
|
||||
}
|
||||
if lora_availability:
|
||||
filters["lora_availability"] = lora_availability
|
||||
|
||||
lora_hash = request.query.get("lora_hash")
|
||||
checkpoint_hash = request.query.get("checkpoint_hash")
|
||||
|
||||
@@ -316,6 +353,17 @@ class RecipeListingHandler:
|
||||
|
||||
if not recipe:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
# Expose the on-disk recipe JSON path so the modal can offer
|
||||
# "open file location" without guessing the storage layout.
|
||||
recipe = dict(recipe)
|
||||
try:
|
||||
json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
except Exception: # pragma: no cover - details must still load
|
||||
json_path = None
|
||||
if json_path:
|
||||
recipe["recipe_json_path"] = json_path
|
||||
|
||||
return web.json_response(recipe)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
@@ -437,17 +485,32 @@ class RecipeQueryHandler:
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
|
||||
base_model_counts: Dict[str, int] = {}
|
||||
unknown_count = 0
|
||||
for recipe in getattr(cache, "raw_data", []):
|
||||
base_model = recipe.get("base_model")
|
||||
if base_model:
|
||||
base_model_counts[base_model] = (
|
||||
base_model_counts.get(base_model, 0) + 1
|
||||
)
|
||||
else:
|
||||
unknown_count += 1
|
||||
|
||||
sorted_models = [
|
||||
{"name": model, "count": count}
|
||||
for model, count in base_model_counts.items()
|
||||
]
|
||||
if unknown_count:
|
||||
# Synthetic "Unknown" bucket for recipes whose base model could
|
||||
# not be determined. `value` carries the filter marker so the
|
||||
# UI can display "Unknown" without colliding with real base
|
||||
# model strings.
|
||||
sorted_models.append(
|
||||
{
|
||||
"name": "Unknown",
|
||||
"value": UNKNOWN_BASE_MODEL_FILTER,
|
||||
"count": unknown_count,
|
||||
}
|
||||
)
|
||||
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
|
||||
if limit > 0:
|
||||
sorted_models = sorted_models[:limit]
|
||||
@@ -589,16 +652,31 @@ class RecipeQueryHandler:
|
||||
include_prompt=include_prompt
|
||||
)
|
||||
url_groups = await recipe_scanner.find_duplicate_recipes_by_source()
|
||||
|
||||
# Assemble the response directly from the cached recipe summaries.
|
||||
# Resolving each id via get_recipe_by_id would re-read every recipe
|
||||
# JSON from disk — thousands of blocking reads on the event loop
|
||||
# for large libraries — while all required fields already live in
|
||||
# the cache.
|
||||
cache = await recipe_scanner.get_cached_data()
|
||||
recipes_by_id = {
|
||||
str(recipe.get("id", "")): recipe for recipe in cache.raw_data
|
||||
}
|
||||
|
||||
response_data = []
|
||||
|
||||
for fingerprint, recipe_ids in fingerprint_groups.items():
|
||||
if len(recipe_ids) <= 1:
|
||||
continue
|
||||
def append_groups(
|
||||
groups: Dict[str, List[Any]], group_type: str
|
||||
) -> None:
|
||||
for group_key, recipe_ids in groups.items():
|
||||
if len(recipe_ids) <= 1:
|
||||
continue
|
||||
|
||||
recipes = []
|
||||
for recipe_id in recipe_ids:
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if recipe:
|
||||
recipes = []
|
||||
for recipe_id in recipe_ids:
|
||||
recipe = recipes_by_id.get(str(recipe_id))
|
||||
if recipe is None:
|
||||
continue
|
||||
recipes.append(
|
||||
{
|
||||
"id": recipe.get("id"),
|
||||
@@ -613,55 +691,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(
|
||||
@@ -1055,12 +1101,14 @@ class RecipeManagementHandler:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def reimport_recipe(self, request: web.Request) -> web.Response:
|
||||
"""Delete a recipe and re-import it from its source URL.
|
||||
"""Delete a recipe and re-import it from its source.
|
||||
|
||||
This gives the recipe a fresh start — re-downloads the image from
|
||||
CivitAI, re-parses EXIF metadata with the current parser, and
|
||||
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
|
||||
are carried over from the old recipe.
|
||||
Gives the recipe a fresh start: URL-sourced recipes re-download the
|
||||
image from CivitAI; local ones re-parse the saved recipe image. Both
|
||||
use the original embedded generation metadata (the appended recipe
|
||||
metadata block is ignored) with the current parser, and re-resolve
|
||||
LoRAs / checkpoint. User edits (title, tags, favorite) are carried
|
||||
over from the old recipe.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -1073,13 +1121,34 @@ class RecipeManagementHandler:
|
||||
if not old_recipe:
|
||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||
|
||||
source_path = old_recipe.get("source_path")
|
||||
if not source_path:
|
||||
old_file_path = old_recipe.get("file_path", "")
|
||||
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
||||
|
||||
source_path = old_recipe.get("source_path") or ""
|
||||
image_id = extract_civitai_image_id(source_path) if source_path else None
|
||||
|
||||
# Local re-import sources: an explicit local source_path, or — when
|
||||
# no source_path was recorded (drag & drop / file-picker imports) —
|
||||
# the recipe's own saved image, which still carries the original
|
||||
# embedded generation metadata next to the recipe metadata block.
|
||||
local_source = None
|
||||
if not image_id and source_path and os.path.isfile(source_path):
|
||||
local_source = source_path
|
||||
elif (
|
||||
not image_id
|
||||
and not source_path
|
||||
and old_file_path
|
||||
and os.path.isfile(old_file_path)
|
||||
):
|
||||
local_source = old_file_path
|
||||
|
||||
if not image_id and not local_source:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Recipe has no source URL — cannot re-import. "
|
||||
"Recipe has no re-importable source (no source URL "
|
||||
"and no accessible local image). "
|
||||
"Use repair or manual import instead."
|
||||
),
|
||||
},
|
||||
@@ -1093,28 +1162,9 @@ class RecipeManagementHandler:
|
||||
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
|
||||
del user_edits["tags"]
|
||||
|
||||
old_file_path = old_recipe.get("file_path", "")
|
||||
old_folder = os.path.dirname(old_file_path) if old_file_path else None
|
||||
|
||||
image_id = extract_civitai_image_id(source_path)
|
||||
is_local_file = not image_id and os.path.isfile(source_path)
|
||||
|
||||
if not image_id and not is_local_file:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": (
|
||||
"Recipe source is neither a valid CivitAI image URL "
|
||||
"nor an accessible local file. "
|
||||
"Use repair or manual import instead."
|
||||
),
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if is_local_file:
|
||||
if local_source:
|
||||
return await self._do_reimport_from_local(
|
||||
source_path,
|
||||
local_source,
|
||||
recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
target_dir=old_folder,
|
||||
@@ -1580,6 +1630,204 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error reconnecting LoRA: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def restore_lora(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
for field in ("recipe_id", "lora_index"):
|
||||
if field not in data:
|
||||
raise RecipeValidationError(f"Missing required field: {field}")
|
||||
|
||||
result = await self._persistence_service.restore_lora(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
lora_index=int(data["lora_index"]),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring LoRA: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_reconnect_suggestions(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
lora_index_raw = request.match_info.get("lora_index")
|
||||
if not recipe_id or lora_index_raw is None:
|
||||
raise RecipeValidationError("recipe_id and lora_index are required")
|
||||
try:
|
||||
lora_index = int(lora_index_raw)
|
||||
except (TypeError, ValueError):
|
||||
raise RecipeValidationError("lora_index must be an integer")
|
||||
|
||||
result = await self._persistence_service.get_reconnect_suggestions(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
lora_index=lora_index,
|
||||
query=request.query.get("query") or None,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error suggesting reconnect candidates: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def mark_lora_hash_invalid(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
for field in ("recipe_id", "lora_index"):
|
||||
if field not in data:
|
||||
raise RecipeValidationError(f"Missing required field: {field}")
|
||||
|
||||
result = await self._persistence_service.mark_lora_hash_invalid(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
lora_index=int(data["lora_index"]),
|
||||
hash_invalid=bool(data.get("hash_invalid", True)),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error marking LoRA hash invalid: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def reconnect_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
for field in ("recipe_id", "target_name"):
|
||||
if field not in data:
|
||||
raise RecipeValidationError(f"Missing required field: {field}")
|
||||
|
||||
result = await self._persistence_service.reconnect_checkpoint(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
target_name=data["target_name"],
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error reconnecting checkpoint: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def restore_checkpoint(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
if "recipe_id" not in data:
|
||||
raise RecipeValidationError("Missing required field: recipe_id")
|
||||
|
||||
result = await self._persistence_service.restore_checkpoint(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error restoring checkpoint: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self, request: web.Request
|
||||
) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info.get("recipe_id")
|
||||
if not recipe_id:
|
||||
raise RecipeValidationError("recipe_id is required")
|
||||
|
||||
result = await self._persistence_service.get_checkpoint_reconnect_suggestions(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=recipe_id,
|
||||
query=request.query.get("query") or None,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error suggesting checkpoint reconnect candidates: %s",
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def mark_checkpoint_hash_invalid(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
if "recipe_id" not in data:
|
||||
raise RecipeValidationError("Missing required field: recipe_id")
|
||||
|
||||
result = await self._persistence_service.mark_checkpoint_hash_invalid(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=data["recipe_id"],
|
||||
hash_invalid=bool(data.get("hash_invalid", True)),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error marking checkpoint hash invalid: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def bulk_delete(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -2012,6 +2260,23 @@ class RecipeManagementHandler:
|
||||
await self._download_remote_media(image_url)
|
||||
)
|
||||
|
||||
# Diagnostics for the recipe modal's "Why no LoRAs?" panel. This path
|
||||
# always comes from a CivitAI image URL (import_from_url validates the
|
||||
# image id), so civitai_image is True.
|
||||
diagnostics: Dict[str, Any] = {
|
||||
"civitai_image": True,
|
||||
"is_video": extension in (".mp4", ".webm"),
|
||||
}
|
||||
if isinstance(civitai_meta_raw, dict):
|
||||
raw_mvids = civitai_meta_raw.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(raw_mvids) if isinstance(raw_mvids, list) else 0
|
||||
)
|
||||
inner_meta_for_diag = civitai_meta_raw.get("meta")
|
||||
if isinstance(inner_meta_for_diag, dict):
|
||||
diagnostics["api_meta_present"] = True
|
||||
diagnostics["api_meta_keys"] = sorted(inner_meta_for_diag.keys())
|
||||
|
||||
# Build a version-cached map of local model hashes to cache items so
|
||||
# CivitaiApiMetadataParser can skip CivitAI API calls for models that
|
||||
# exist on disk. Built once and shared by every parse pass below.
|
||||
@@ -2032,6 +2297,7 @@ class RecipeManagementHandler:
|
||||
raw_embedded = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, temp_img_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_embedded)
|
||||
if raw_embedded:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2039,6 +2305,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_embedded,
|
||||
@@ -2079,6 +2346,7 @@ class RecipeManagementHandler:
|
||||
raw_orig = await asyncio.to_thread(
|
||||
ExifUtils.extract_image_metadata, orig_tmp_path
|
||||
)
|
||||
diagnostics["exif_present"] = bool(raw_orig)
|
||||
if raw_orig:
|
||||
parser = (
|
||||
self._analysis_service._recipe_parser_factory.create_parser(
|
||||
@@ -2086,6 +2354,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
)
|
||||
if parser:
|
||||
diagnostics["exif_parser"] = parser.__class__.__name__
|
||||
if isinstance(parser, CivitaiApiMetadataParser):
|
||||
parsed_embedded = await parser.parse_metadata(
|
||||
raw_orig,
|
||||
@@ -2171,14 +2440,21 @@ class RecipeManagementHandler:
|
||||
civitai_base_model = civitai_parsed.get("base_model")
|
||||
if civitai_base_model and not metadata.get("base_model"):
|
||||
metadata["base_model"] = civitai_base_model
|
||||
elif parsed_embedded:
|
||||
parsed_loras = parsed_embedded.get("loras")
|
||||
if parsed_loras and not metadata.get("loras"):
|
||||
metadata["loras"] = parsed_loras
|
||||
parsed_model = parsed_embedded.get("model")
|
||||
if parsed_model and not metadata.get("checkpoint"):
|
||||
metadata["checkpoint"] = parsed_model
|
||||
if parsed_embedded.get("base_model") and not metadata.get("base_model"):
|
||||
|
||||
# EXIF fills whatever the API-only parse left open — when the image
|
||||
# API meta is null (only modelVersionIds present) the API parse
|
||||
# yields a checkpoint but no LoRAs, while the image EXIF carries the
|
||||
# full resource list.
|
||||
if parsed_embedded:
|
||||
if not metadata.get("loras"):
|
||||
parsed_loras = parsed_embedded.get("loras")
|
||||
if parsed_loras:
|
||||
metadata["loras"] = parsed_loras
|
||||
if not metadata.get("checkpoint"):
|
||||
parsed_model = parsed_embedded.get("model")
|
||||
if parsed_model:
|
||||
metadata["checkpoint"] = parsed_model
|
||||
if not metadata.get("base_model") and parsed_embedded.get("base_model"):
|
||||
metadata["base_model"] = parsed_embedded["base_model"]
|
||||
|
||||
civitai_client = self._civitai_client_getter()
|
||||
@@ -2200,6 +2476,20 @@ class RecipeManagementHandler:
|
||||
else:
|
||||
name = f"Civitai Image {image_id}"
|
||||
|
||||
# Record why this import ended up with no LoRAs so the recipe modal
|
||||
# can explain it (collapsed by default).
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_URL,
|
||||
CHANNEL_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_URL if recipe_id else CHANNEL_URL,
|
||||
diagnostics,
|
||||
metadata.get("loras"),
|
||||
)
|
||||
|
||||
result = await self._persistence_service.save_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_bytes=image_bytes,
|
||||
@@ -2225,8 +2515,10 @@ class RecipeManagementHandler:
|
||||
) -> web.Response:
|
||||
"""Re-import a recipe from a local image file.
|
||||
|
||||
Reads the original source file, re-parses its EXIF metadata, saves a
|
||||
fresh recipe, then deletes the old one.
|
||||
Reads the original source file, re-parses its original embedded
|
||||
generation metadata (the appended recipe metadata block is ignored so
|
||||
the current parser gets a fresh pass), saves a new recipe, then deletes
|
||||
the old one.
|
||||
"""
|
||||
normalized = os.path.normpath(file_path)
|
||||
if not os.path.isfile(normalized):
|
||||
@@ -2242,6 +2534,7 @@ class RecipeManagementHandler:
|
||||
analysis_result = await self._analysis_service.analyze_local_image(
|
||||
file_path=normalized,
|
||||
recipe_scanner=recipe_scanner,
|
||||
ignore_recipe_metadata=True,
|
||||
)
|
||||
analysis_payload: dict[str, Any] = analysis_result.payload
|
||||
|
||||
@@ -2259,6 +2552,17 @@ class RecipeManagementHandler:
|
||||
if checkpoint:
|
||||
metadata["checkpoint"] = checkpoint
|
||||
|
||||
from ...services.recipes.import_info import (
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
metadata["import_info"] = build_import_info(
|
||||
CHANNEL_REIMPORT_LOCAL,
|
||||
analysis_payload.get("diagnostics"),
|
||||
loras,
|
||||
)
|
||||
|
||||
prompt = (
|
||||
gen_params.get("prompt")
|
||||
or gen_params.get("positivePrompt")
|
||||
@@ -2275,6 +2579,10 @@ class RecipeManagementHandler:
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
target_dir=target_dir,
|
||||
# The source is the recipe's own already-optimized preview image;
|
||||
# store its bytes verbatim instead of re-compressing (which would
|
||||
# only degrade quality) and skip the metadata re-append.
|
||||
skip_optimize=True,
|
||||
)
|
||||
|
||||
await self._persistence_service.delete_recipe(
|
||||
@@ -2755,6 +3063,91 @@ class RecipeSharingHandler:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class RecipeWorkflowHandler:
|
||||
"""Extract an embedded workflow from a recipe image and broadcast it."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ensure_dependencies_ready: EnsureDependenciesCallable,
|
||||
recipe_scanner_getter: RecipeScannerGetter,
|
||||
prompt_server: type[PromptServerProtocol],
|
||||
logger: Logger,
|
||||
) -> None:
|
||||
self._ensure_dependencies_ready = ensure_dependencies_ready
|
||||
self._recipe_scanner_getter = recipe_scanner_getter
|
||||
self._prompt_server = prompt_server
|
||||
self._logger = logger
|
||||
|
||||
async def send_recipe_workflow(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
if not recipe:
|
||||
return web.json_response({"error": "Recipe not found"}, status=404)
|
||||
|
||||
if os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1":
|
||||
return web.json_response(
|
||||
{"error": "Standalone Mode Active"}, status=400
|
||||
)
|
||||
|
||||
image_path = recipe.get("file_path")
|
||||
if not image_path:
|
||||
return web.json_response({"error": "no_workflow"}, status=404)
|
||||
|
||||
metadata = await asyncio.to_thread(
|
||||
ExifUtils._load_structured_metadata, image_path
|
||||
)
|
||||
workflow_raw = metadata.get("workflow")
|
||||
if not workflow_raw:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "no_workflow",
|
||||
"message": "No embedded workflow found in recipe image",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
# _load_structured_metadata always yields workflow as a JSON string;
|
||||
# the frontend extension expects a parsed object for loadGraphData.
|
||||
try:
|
||||
workflow = (
|
||||
json.loads(workflow_raw)
|
||||
if isinstance(workflow_raw, str)
|
||||
else workflow_raw
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self._logger.warning(
|
||||
"Recipe %s embeds a non-JSON workflow payload; skipping send",
|
||||
recipe_id,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "no_workflow",
|
||||
"message": "Embedded workflow data is not valid JSON",
|
||||
},
|
||||
status=404,
|
||||
)
|
||||
|
||||
self._prompt_server.instance.send_sync(
|
||||
"lm_load_workflow",
|
||||
{
|
||||
"workflow": workflow,
|
||||
"name": recipe.get("title") or "",
|
||||
"recipe_id": recipe_id,
|
||||
},
|
||||
)
|
||||
return web.json_response({"success": True, "sent": True})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error sending recipe workflow: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
|
||||
class BatchImportHandler:
|
||||
"""Handle batch import operations for recipes."""
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
|
||||
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
|
||||
RouteDefinition("GET", "/api/lm/health-check", "health_check"),
|
||||
RouteDefinition("GET", "/api/lm/init-status", "get_init_status"),
|
||||
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
|
||||
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
|
||||
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
|
||||
|
||||
@@ -49,6 +49,31 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/restore", "restore_lora"),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/recipe/{recipe_id}/lora/{lora_index}/reconnect-suggestions",
|
||||
"get_reconnect_suggestions",
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/checkpoint/reconnect", "reconnect_checkpoint"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/checkpoint/restore", "restore_checkpoint"
|
||||
),
|
||||
RouteDefinition(
|
||||
"GET",
|
||||
"/api/lm/recipe/{recipe_id}/checkpoint/reconnect-suggestions",
|
||||
"get_checkpoint_reconnect_suggestions",
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST",
|
||||
"/api/lm/recipe/checkpoint/mark-hash-invalid",
|
||||
"mark_checkpoint_hash_invalid",
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
RouteDefinition(
|
||||
@@ -90,6 +115,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -82,6 +82,17 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
)
|
||||
|
||||
|
||||
def _is_no_uri_available_error(message: str) -> bool:
|
||||
"""Return True for aria2's "No URI available" transfer failure.
|
||||
|
||||
aria2 reports this when every URI for the transfer has become unusable.
|
||||
For CivitAI downloads this typically means the temporary signed URL
|
||||
expired mid-download; the transfer can be recovered by resolving a fresh
|
||||
signed URL and re-scheduling with ``continue=true``.
|
||||
"""
|
||||
return "no uri available" in message.lower()
|
||||
|
||||
|
||||
class Aria2Error(RuntimeError):
|
||||
"""Raised when aria2 integration fails."""
|
||||
|
||||
@@ -145,8 +156,11 @@ class Aria2Downloader:
|
||||
disappears (e.g. another download restarted the daemon and
|
||||
``close()`` cleared ``_transfers``) or the RPC becomes unreachable,
|
||||
the transfer is re-scheduled with ``continue=true`` so the download
|
||||
resumes from the on-disk ``.aria2`` control file. Recovery is bounded
|
||||
by ``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||
resumes from the on-disk ``.aria2`` control file. The same
|
||||
re-scheduling happens when aria2 fails with "No URI available"
|
||||
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
||||
and the partial download continues. Recovery is bounded by
|
||||
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||
"""
|
||||
|
||||
await self._ensure_process()
|
||||
@@ -201,7 +215,36 @@ class Aria2Downloader:
|
||||
completed_path = self._resolve_completed_path(status, save_path)
|
||||
return True, completed_path
|
||||
if state == "error":
|
||||
return False, status.get("errorMessage") or "aria2 download failed"
|
||||
error_message = status.get("errorMessage") or "aria2 download failed"
|
||||
if (
|
||||
_is_no_uri_available_error(error_message)
|
||||
and recovery_attempts < MAX_TRANSFER_RECOVERY_ATTEMPTS
|
||||
):
|
||||
# The signed URL (e.g. CivitAI's) expired before the
|
||||
# transfer finished. Re-registering resolves a fresh
|
||||
# URL and resumes from the on-disk partial payload and
|
||||
# .aria2 control file via ``continue=true``.
|
||||
recovery_attempts += 1
|
||||
logger.warning(
|
||||
"aria2 transfer %s failed with %r; refreshing the "
|
||||
"URL and resuming the partial download "
|
||||
"(attempt %d/%d)",
|
||||
download_id,
|
||||
error_message,
|
||||
recovery_attempts,
|
||||
MAX_TRANSFER_RECOVERY_ATTEMPTS,
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
await self._ensure_process()
|
||||
async with self._register_lock:
|
||||
transfer = await self._register_transfer(
|
||||
url,
|
||||
save_path,
|
||||
download_id=download_id,
|
||||
headers=headers,
|
||||
)
|
||||
continue
|
||||
return False, error_message
|
||||
if state == "removed":
|
||||
return False, "Download was cancelled"
|
||||
|
||||
@@ -217,8 +260,9 @@ class Aria2Downloader:
|
||||
"""Call get_status with retry for transient RPC failures.
|
||||
|
||||
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
|
||||
``None`` immediately when the download_id is not tracked (a missing
|
||||
transfer is not a transient condition, so retrying is pointless).
|
||||
``None`` immediately when the transfer is not tracked or its GID is
|
||||
gone from the daemon (a missing transfer is not a transient
|
||||
condition, so retrying is pointless).
|
||||
|
||||
A single failed RPC call should not immediately fail the download,
|
||||
because aria2 may be temporarily busy (e.g. finalizing multiple
|
||||
@@ -332,7 +376,13 @@ class Aria2Downloader:
|
||||
return transfer
|
||||
|
||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return the raw aria2 status payload for a known download."""
|
||||
"""Return the raw aria2 status payload for a known download.
|
||||
|
||||
Returns ``None`` when the download_id is not tracked or the daemon no
|
||||
longer knows the transfer's GID (daemon restart / forceRemove). A
|
||||
forgotten GID is permanent, not transient, so the caller's recovery
|
||||
path handles it instead of burning retry attempts on a dead GID.
|
||||
"""
|
||||
|
||||
transfer = self._transfers.get(download_id)
|
||||
if transfer is None:
|
||||
@@ -348,8 +398,17 @@ class Aria2Downloader:
|
||||
"files",
|
||||
]
|
||||
try:
|
||||
status = await self._rpc_call("aria2.tellStatus", [transfer.gid, keys])
|
||||
status = await self._rpc_call(
|
||||
"aria2.tellStatus", [transfer.gid, keys], log_errors=False
|
||||
)
|
||||
except Exception as exc:
|
||||
if "not found" in str(exc).lower():
|
||||
logger.debug(
|
||||
"aria2 GID %s for download %s is gone; treating as lost transfer",
|
||||
transfer.gid,
|
||||
download_id,
|
||||
)
|
||||
return None
|
||||
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
|
||||
|
||||
if isinstance(status, dict):
|
||||
@@ -367,7 +426,9 @@ class Aria2Downloader:
|
||||
"files",
|
||||
]
|
||||
try:
|
||||
status = await self._rpc_call("aria2.tellStatus", [gid, keys])
|
||||
status = await self._rpc_call(
|
||||
"aria2.tellStatus", [gid, keys], log_errors=False
|
||||
)
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "cannot be found" in message.lower() or "not found" in message.lower():
|
||||
@@ -434,8 +495,19 @@ class Aria2Downloader:
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [transfer.gid])
|
||||
except Exception as exc:
|
||||
return {"success": False, "error": str(exc)}
|
||||
if "not found" not in str(exc).lower():
|
||||
return {"success": False, "error": str(exc)}
|
||||
# The daemon already forgot this GID (restart / prior removal),
|
||||
# so the transfer is effectively cancelled.
|
||||
logger.debug(
|
||||
"aria2 GID %s for download %s already gone during cancel",
|
||||
transfer.gid,
|
||||
download_id,
|
||||
)
|
||||
|
||||
# Drop the in-memory entry as well so a concurrent poll loop does
|
||||
# not mistake the removal for a lost transfer and re-register it.
|
||||
self._transfers.pop(download_id, None)
|
||||
await self._state_store.remove(download_id)
|
||||
return {"success": True, "message": "Download cancelled successfully"}
|
||||
|
||||
@@ -725,7 +797,9 @@ class Aria2Downloader:
|
||||
|
||||
return isinstance(result, dict)
|
||||
|
||||
async def _rpc_call(self, method: str, params: list[Any]) -> Any:
|
||||
async def _rpc_call(
|
||||
self, method: str, params: list[Any], *, log_errors: bool = True
|
||||
) -> Any:
|
||||
if not self._rpc_url:
|
||||
raise Aria2Error("aria2 RPC endpoint is not initialized")
|
||||
|
||||
@@ -756,7 +830,10 @@ class Aria2Downloader:
|
||||
error = body["error"] or {}
|
||||
code = error.get("code") if isinstance(error, dict) else None
|
||||
message = error.get("message") if isinstance(error, dict) else str(error)
|
||||
logger.error(
|
||||
# Probing calls (e.g. tellStatus for a GID the daemon may have
|
||||
# forgotten) pass log_errors=False: an expected "not found" must
|
||||
# not spam the log at ERROR level.
|
||||
(logger.error if log_errors else logger.debug)(
|
||||
"aria2 RPC %s failed with HTTP %s, code=%s, message=%s",
|
||||
method,
|
||||
response.status,
|
||||
@@ -771,7 +848,7 @@ class Aria2Downloader:
|
||||
raise Aria2Error(status_message or "Unknown aria2 RPC error")
|
||||
|
||||
if response.status != 200:
|
||||
logger.error(
|
||||
(logger.error if log_errors else logger.debug)(
|
||||
"aria2 RPC %s returned unexpected HTTP status %s without error payload: %s",
|
||||
method,
|
||||
response.status,
|
||||
|
||||
@@ -972,14 +972,25 @@ class BaseModelService(ABC):
|
||||
)
|
||||
return {k: data[k] for k in fields if k in data}
|
||||
|
||||
async def get_folder_tree(self, model_root: str) -> Dict[str, Any]:
|
||||
async def _get_tree_folders(self, cache, include_empty: bool) -> List[str]:
|
||||
"""Return the folder list backing folder tree responses.
|
||||
|
||||
With ``include_empty`` the directories are enumerated live from the
|
||||
filesystem (including empty ones) via the scanner; otherwise the
|
||||
models-only ``cache.folders`` list is used unchanged.
|
||||
"""
|
||||
if include_empty:
|
||||
return await self.scanner.get_all_folders()
|
||||
return cache.folders
|
||||
|
||||
async def get_folder_tree(self, model_root: str, include_empty: bool = False) -> Dict[str, Any]:
|
||||
"""Get hierarchical folder tree for a specific model root"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
# Build tree structure from folders
|
||||
tree = {}
|
||||
|
||||
for folder in cache.folders:
|
||||
for folder in await self._get_tree_folders(cache, include_empty):
|
||||
# Check if this folder belongs to the specified model root
|
||||
folder_belongs_to_root = False
|
||||
for root in self.scanner.get_model_roots():
|
||||
@@ -1001,7 +1012,7 @@ class BaseModelService(ABC):
|
||||
|
||||
return tree
|
||||
|
||||
async def get_unified_folder_tree(self) -> Dict[str, Any]:
|
||||
async def get_unified_folder_tree(self, include_empty: bool = False) -> Dict[str, Any]:
|
||||
"""Get unified folder tree across all model roots"""
|
||||
cache = await self.scanner.get_cached_data()
|
||||
|
||||
@@ -1011,7 +1022,7 @@ class BaseModelService(ABC):
|
||||
# Get all model roots for path normalization
|
||||
model_roots = self.scanner.get_model_roots()
|
||||
|
||||
for folder in cache.folders:
|
||||
for folder in await self._get_tree_folders(cache, include_empty):
|
||||
if not folder: # Skip empty folders
|
||||
continue
|
||||
|
||||
|
||||
@@ -20,6 +20,11 @@ from .recipes import (
|
||||
RecipeDownloadError,
|
||||
RecipeNotFoundError,
|
||||
)
|
||||
from .recipes.import_info import (
|
||||
CHANNEL_BATCH_IMPORT_LOCAL,
|
||||
CHANNEL_BATCH_IMPORT_URL,
|
||||
build_import_info,
|
||||
)
|
||||
|
||||
|
||||
class ImportItemType(Enum):
|
||||
@@ -71,6 +76,9 @@ class BatchImportProgress:
|
||||
tags: List[str] = field(default_factory=list)
|
||||
skip_no_metadata: bool = False
|
||||
skip_duplicates: bool = False
|
||||
# Set once any item is skipped due to vendor rate limiting (#1085); lets
|
||||
# the UI surface a "slowing down / try again later" hint.
|
||||
rate_limited: bool = False
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -82,6 +90,7 @@ class BatchImportProgress:
|
||||
"skipped": self.skipped,
|
||||
"current_item": self.current_item,
|
||||
"status": self.status,
|
||||
"rate_limited": self.rate_limited,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"progress_percent": round((self.completed / self.total) * 100, 1)
|
||||
@@ -118,6 +127,10 @@ class AdaptiveConcurrencyController:
|
||||
self._task_durations: List[float] = []
|
||||
self._recent_errors = 0
|
||||
self._recent_successes = 0
|
||||
# Batch-wide shared semaphore; created lazily on first use so the
|
||||
# controller can also be constructed outside a running event loop.
|
||||
self._semaphore: Optional[asyncio.Semaphore] = None
|
||||
self._semaphore_capacity = initial_concurrency
|
||||
|
||||
def record_result(self, duration: float, success: bool) -> None:
|
||||
self._task_durations.append(duration)
|
||||
@@ -146,7 +159,37 @@ class AdaptiveConcurrencyController:
|
||||
self._recent_successes = 0
|
||||
|
||||
def get_semaphore(self) -> asyncio.Semaphore:
|
||||
return asyncio.Semaphore(self.current_concurrency)
|
||||
"""Return the batch-wide shared semaphore.
|
||||
|
||||
The same semaphore instance is returned for every item of a batch so
|
||||
the configured concurrency bounds are actually enforced. Previously a
|
||||
fresh semaphore was created per call, letting every item run
|
||||
concurrently and hammering remote metadata providers without any
|
||||
limit.
|
||||
"""
|
||||
if self._semaphore is None:
|
||||
self._semaphore = asyncio.Semaphore(self.current_concurrency)
|
||||
self._semaphore_capacity = self.current_concurrency
|
||||
return self._semaphore
|
||||
|
||||
async def apply_concurrency(self) -> None:
|
||||
"""Synchronize the shared semaphore capacity with ``current_concurrency``.
|
||||
|
||||
Call after ``record_result`` (once per completed item). Growing the
|
||||
capacity is immediate (release). Shrinking requires acquiring a permit
|
||||
and holding it, which is best-effort while other tasks are still
|
||||
running — the capacity converges on subsequent calls.
|
||||
"""
|
||||
semaphore = self.get_semaphore()
|
||||
while self._semaphore_capacity < self.current_concurrency:
|
||||
semaphore.release()
|
||||
self._semaphore_capacity += 1
|
||||
while self._semaphore_capacity > self.current_concurrency:
|
||||
try:
|
||||
await asyncio.wait_for(semaphore.acquire(), timeout=0.01)
|
||||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||||
break
|
||||
self._semaphore_capacity -= 1
|
||||
|
||||
|
||||
class BatchImportService:
|
||||
@@ -184,6 +227,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 +317,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 +347,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]
|
||||
|
||||
@@ -334,6 +392,13 @@ class BatchImportService:
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
return ext in self.SUPPORTED_EXTENSIONS
|
||||
|
||||
@staticmethod
|
||||
def _is_rate_limit_error(error: Optional[str]) -> bool:
|
||||
"""Return True when an error payload represents vendor rate limiting."""
|
||||
if not error:
|
||||
return False
|
||||
return "rate limit" in error.lower()
|
||||
|
||||
async def _run_batch_import(
|
||||
self,
|
||||
*,
|
||||
@@ -379,6 +444,9 @@ class BatchImportService:
|
||||
self._concurrency_controller.record_result(
|
||||
duration, result.get("success", False)
|
||||
)
|
||||
# Keep the shared batch semaphore in sync with the adaptively
|
||||
# adjusted concurrency so the bounds actually take effect.
|
||||
await self._concurrency_controller.apply_concurrency()
|
||||
|
||||
if result.get("success"):
|
||||
item.status = ImportStatus.SUCCESS
|
||||
@@ -389,6 +457,17 @@ class BatchImportService:
|
||||
item.status = ImportStatus.SKIPPED
|
||||
item.error_message = result.get("error")
|
||||
progress.skipped += 1
|
||||
elif self._is_rate_limit_error(result.get("error")):
|
||||
# Vendor rate limit is a transient, external condition —
|
||||
# do not pollute the failure count with it (#1085). The
|
||||
# import can simply be re-run later.
|
||||
item.status = ImportStatus.SKIPPED
|
||||
item.error_message = (
|
||||
f"Rate limited by metadata provider; "
|
||||
f"re-run the import later ({result.get('error')})"
|
||||
)
|
||||
progress.skipped += 1
|
||||
progress.rate_limited = True
|
||||
else:
|
||||
item.status = ImportStatus.FAILED
|
||||
item.error_message = result.get("error")
|
||||
@@ -396,13 +475,36 @@ class BatchImportService:
|
||||
|
||||
except Exception as e:
|
||||
self._logger.error(f"Error importing {item.source}: {e}")
|
||||
item.status = ImportStatus.FAILED
|
||||
item.error_message = str(e)
|
||||
item.duration = time.time() - start_time
|
||||
progress.failed += 1
|
||||
if self._is_rate_limit_error(str(e)):
|
||||
item.status = ImportStatus.SKIPPED
|
||||
item.error_message = (
|
||||
f"Rate limited by metadata provider; "
|
||||
f"re-run the import later ({e})"
|
||||
)
|
||||
progress.skipped += 1
|
||||
progress.rate_limited = True
|
||||
else:
|
||||
item.status = ImportStatus.FAILED
|
||||
item.error_message = str(e)
|
||||
progress.failed += 1
|
||||
self._concurrency_controller.record_result(item.duration, False)
|
||||
await self._concurrency_controller.apply_concurrency()
|
||||
|
||||
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 +517,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)
|
||||
@@ -518,6 +629,17 @@ class BatchImportService:
|
||||
"loras": loras,
|
||||
"gen_params": payload.get("gen_params", {}),
|
||||
"source_path": item.source,
|
||||
# Record why this import ended up with no LoRAs so the
|
||||
# recipe modal can explain it (collapsed by default).
|
||||
"import_info": build_import_info(
|
||||
(
|
||||
CHANNEL_BATCH_IMPORT_URL
|
||||
if item.item_type == ImportItemType.URL
|
||||
else CHANNEL_BATCH_IMPORT_LOCAL
|
||||
),
|
||||
payload.get("diagnostics"),
|
||||
loras,
|
||||
),
|
||||
}
|
||||
|
||||
if payload.get("checkpoint"):
|
||||
@@ -595,3 +717,6 @@ class BatchImportService:
|
||||
def _cleanup_operation(self, operation_id: str) -> None:
|
||||
if operation_id in self._cancellation_flags:
|
||||
del self._cancellation_flags[operation_id]
|
||||
if operation_id in self._active_operations:
|
||||
del self._active_operations[operation_id]
|
||||
self._logger.info("Batch import operation %s cleaned up", operation_id)
|
||||
|
||||
@@ -51,6 +51,7 @@ class CheckpointService(BaseModelService):
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
|
||||
@@ -7,6 +7,7 @@ import logging
|
||||
import asyncio
|
||||
from copy import deepcopy
|
||||
from typing import Any, Optional, Dict, Tuple, List, cast
|
||||
from .connectivity_guard import is_expected_offline_error
|
||||
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError
|
||||
@@ -46,7 +47,11 @@ class CivArchiveClient:
|
||||
"""Call CivArchive API and return JSON payload"""
|
||||
success, payload = await self._make_request(path, params=params)
|
||||
if not success:
|
||||
error = payload if isinstance(payload, str) else "Request failed"
|
||||
# Normalize empty-string failure payloads (e.g. a throttled
|
||||
# connection dropped without a message) so callers never see a
|
||||
# falsy error alongside a None payload — that combination used to
|
||||
# crash downstream None.get() calls.
|
||||
error = payload if isinstance(payload, str) and payload else "Request failed"
|
||||
return None, error
|
||||
if not isinstance(payload, dict):
|
||||
return None, "Invalid response structure"
|
||||
@@ -298,6 +303,8 @@ class CivArchiveClient:
|
||||
|
||||
async def _resolve_version_from_files(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Fallback to fetch version data when only file metadata is available"""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
data = self._normalize_payload(payload)
|
||||
files = data.get("files") or payload.get("files") or []
|
||||
if not isinstance(files, list):
|
||||
@@ -332,10 +339,13 @@ class CivArchiveClient:
|
||||
"""Find model by SHA256 hash value using CivArchive API"""
|
||||
try:
|
||||
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
|
||||
if error:
|
||||
if "not found" in error.lower():
|
||||
# Treat a missing payload as an error even when the error string is
|
||||
# falsy; passing None into the split/transform helpers below used to
|
||||
# crash with "'NoneType' object has no attribute 'get'".
|
||||
if error is not None or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None, "Model not found"
|
||||
return None, error
|
||||
return None, error or "Request failed"
|
||||
|
||||
context, version_data, fallback_files = self._split_context(cast(Dict[str, Any], payload))
|
||||
transformed = self._transform_version(context, version_data, fallback_files)
|
||||
@@ -352,7 +362,14 @@ class CivArchiveClient:
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
||||
if is_expected_offline_error(str(e)):
|
||||
logger.debug(
|
||||
"Skipping CivArchive model by hash %s while offline: %s",
|
||||
model_hash[:10],
|
||||
e,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
|
||||
return None, str(e)
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -362,7 +379,14 @@ class CivArchiveClient:
|
||||
if error or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None
|
||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
|
||||
if is_expected_offline_error(error):
|
||||
logger.debug(
|
||||
"Skipping CivArchive model versions fetch for %s while offline: %s",
|
||||
model_id,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
|
||||
return None
|
||||
|
||||
data = self._normalize_payload(payload)
|
||||
@@ -426,7 +450,19 @@ class CivArchiveClient:
|
||||
if error or payload is None:
|
||||
if error and "not found" in error.lower():
|
||||
return None
|
||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
|
||||
# The connectivity guard short-circuits requests during its
|
||||
# offline cooldown; that is an expected, transient state, so
|
||||
# log it as DEBUG instead of spamming one ERROR per request
|
||||
# (batch imports can hit this thousands of times).
|
||||
if is_expected_offline_error(error):
|
||||
logger.debug(
|
||||
"Skipping CivArchive model version fetch %s/%s while offline: %s",
|
||||
model_id,
|
||||
version_id,
|
||||
error,
|
||||
)
|
||||
else:
|
||||
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
|
||||
return None
|
||||
|
||||
context, version_data, fallback_files = self._split_context(payload)
|
||||
|
||||
@@ -21,7 +21,7 @@ from .model_metadata_provider import (
|
||||
from .downloader import get_downloader
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from ..utils.civitai_utils import resolve_license_payload
|
||||
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
||||
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -180,6 +180,11 @@ class CivitaiClient:
|
||||
async def get_model_by_hash(
|
||||
self, model_hash: str
|
||||
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
if is_empty_placeholder_hash(model_hash):
|
||||
# The empty-hash placeholder (SHA256 of an empty byte string)
|
||||
# matches no real file; CivitAI's by-hash index can contain
|
||||
# polluted entries for it, so never resolve it.
|
||||
return None, "Model not found"
|
||||
try:
|
||||
success, version = await self._make_request(
|
||||
"GET",
|
||||
@@ -503,6 +508,8 @@ class CivitaiClient:
|
||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not model_hash:
|
||||
return None
|
||||
if is_empty_placeholder_hash(model_hash):
|
||||
return None
|
||||
|
||||
success, version = await self._make_request(
|
||||
"GET",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
|
||||
|
||||
from .downloader import DownloadProgress
|
||||
|
||||
@@ -87,7 +87,9 @@ class DownloadCoordinator:
|
||||
progress_callback=progress_callback,
|
||||
download_id=download_id,
|
||||
source=payload.get("source"),
|
||||
file_params=payload.get("file_params"),
|
||||
# Normalize falsy file_params (e.g. {}) to None so download gates
|
||||
# treat it as "no explicit file selection" (#1058).
|
||||
file_params=payload.get("file_params") or None,
|
||||
)
|
||||
|
||||
result["download_id"] = download_id
|
||||
@@ -184,6 +186,14 @@ class DownloadCoordinator:
|
||||
download_manager = await self._download_manager_factory()
|
||||
return await download_manager.get_active_downloads()
|
||||
|
||||
async def discard_cleared_downloads(self, download_ids: Iterable[str]) -> int:
|
||||
"""Tear down in-memory/aria2 tracking for queue-cleared downloads."""
|
||||
|
||||
if not download_ids:
|
||||
return 0
|
||||
download_manager = await self._download_manager_factory()
|
||||
return await download_manager.discard_cleared_downloads(download_ids)
|
||||
|
||||
def _parse_optional_int(self, value: Any, field: str) -> Optional[int]:
|
||||
"""Parse an optional integer from user input."""
|
||||
|
||||
|
||||
+424
-84
@@ -2,6 +2,7 @@
|
||||
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||
# import cycles. Breaking them would require an architectural refactor.
|
||||
import contextlib
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
@@ -12,8 +13,9 @@ import shutil
|
||||
import zipfile
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple, cast
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||
from urllib.parse import urlparse
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import (
|
||||
@@ -53,6 +55,12 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
|
||||
NON_DOWNLOADABLE_PRIMARY_TYPES = ("Config", "Archive", "Workflow", "Training Data")
|
||||
|
||||
|
||||
@dataclass
|
||||
class _PathSlot:
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
refs: int = 0
|
||||
|
||||
|
||||
class DownloadManager:
|
||||
_instance = None
|
||||
_lock = asyncio.Lock()
|
||||
@@ -82,6 +90,11 @@ class DownloadManager:
|
||||
self._aria2_state_store = Aria2TransferStateStore()
|
||||
self._restored_persisted_downloads = False
|
||||
self._restore_lock = asyncio.Lock()
|
||||
# Refcounted per-target-path locks: two downloads resolving to the
|
||||
# same save_path (e.g. model versions sharing one filename) must not
|
||||
# overlap, or one task's failure cleanup can delete the other's file.
|
||||
self._path_slot_guard: asyncio.Lock = asyncio.Lock()
|
||||
self._path_slots: dict[str, _PathSlot] = {}
|
||||
|
||||
@staticmethod
|
||||
def _get_model_download_backend() -> str:
|
||||
@@ -213,6 +226,162 @@ class DownloadManager:
|
||||
)
|
||||
return False
|
||||
|
||||
async def _get_scanner_for_model_type(self, model_type: str):
|
||||
"""Return the scanner responsible for the given model type."""
|
||||
if model_type == "checkpoint":
|
||||
return await self._get_checkpoint_scanner()
|
||||
if model_type == "embedding":
|
||||
return await ServiceRegistry.get_embedding_scanner()
|
||||
return await self._get_lora_scanner()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_file(
|
||||
files: Any, file_params: Dict[str, Any] | None
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Resolve the target file within a version's file list from file_params.
|
||||
|
||||
Shared by the existence gate and the actual file selection so both
|
||||
always agree on which file a download refers to (#1058). Returns None
|
||||
when file_params is None or no file matches.
|
||||
"""
|
||||
if not file_params or not isinstance(files, list):
|
||||
return None
|
||||
|
||||
target_file_id = file_params.get("id")
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format")
|
||||
target_size = file_params.get("size")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
logger.debug(
|
||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, "
|
||||
"isPrimary=%s, total_files=%d",
|
||||
target_file_id, target_type, target_format, target_size, target_fp,
|
||||
is_primary, len(files),
|
||||
)
|
||||
|
||||
file_info: Optional[Dict[str, Any]] = None
|
||||
|
||||
if target_file_id:
|
||||
target_id_str = str(target_file_id)
|
||||
for f in files:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
f_id = f.get("id")
|
||||
if str(f_id) == target_id_str:
|
||||
file_info = f
|
||||
logger.debug(
|
||||
"[download] MATCH by ID: id=%s name='%s'",
|
||||
f_id, f.get("name"),
|
||||
)
|
||||
break
|
||||
if not file_info:
|
||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||
|
||||
elif is_primary:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if isinstance(f, dict)
|
||||
and f.get("primary")
|
||||
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Lenient metadata match: only compare fields present on both sides
|
||||
for f in files:
|
||||
if not isinstance(f, dict):
|
||||
continue
|
||||
f_type = f.get("type", "")
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
f_meta = f.get("metadata", {})
|
||||
f_format = f_meta.get("format") or f.get("format")
|
||||
f_size = f_meta.get("size") or f.get("size")
|
||||
f_fp = f_meta.get("fp") or f.get("fp")
|
||||
|
||||
if target_format and f_format != target_format:
|
||||
continue
|
||||
if target_size and f_size and f_size != target_size:
|
||||
continue
|
||||
if target_fp and f_fp and f_fp != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
return file_info
|
||||
|
||||
async def _find_local_file_entry(
|
||||
self,
|
||||
model_type: str,
|
||||
model_version_id: int,
|
||||
target_file: Dict[str, Any],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find a local library entry for a specific file of a model version.
|
||||
|
||||
Matches per design rule D2 (#1058): SHA256 is only compared when both
|
||||
sides carry a non-empty hash; otherwise fall back to (extension-less)
|
||||
file name equality. Never let two empty hashes compare equal.
|
||||
"""
|
||||
try:
|
||||
normalized_version_id = int(model_version_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
try:
|
||||
scanner = await self._get_scanner_for_model_type(model_type)
|
||||
cache = await scanner.get_cached_data()
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Failed to scan local entries for version %s file check: %s",
|
||||
model_version_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
raw_data = getattr(cache, "raw_data", None) if cache else None
|
||||
if not raw_data:
|
||||
return None
|
||||
|
||||
target_hash = str(
|
||||
(target_file.get("hashes") or {}).get("SHA256") or ""
|
||||
).strip().lower()
|
||||
target_name = str(target_file.get("name") or "").strip()
|
||||
target_base = os.path.splitext(target_name)[0] if target_name else ""
|
||||
|
||||
for item in raw_data:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
civitai_data = item.get("civitai")
|
||||
if not isinstance(civitai_data, dict):
|
||||
continue
|
||||
try:
|
||||
item_version_id = int(civitai_data.get("id"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if item_version_id != normalized_version_id:
|
||||
continue
|
||||
|
||||
local_hash = str(item.get("sha256") or "").strip().lower()
|
||||
if target_hash and local_hash:
|
||||
if local_hash == target_hash:
|
||||
return item
|
||||
# Both sides carry hashes that differ: this is a different
|
||||
# file of the same version — do not fall back to name match.
|
||||
continue
|
||||
|
||||
if target_base:
|
||||
local_name = str(item.get("file_name") or "").strip()
|
||||
if local_name == target_base:
|
||||
return item
|
||||
|
||||
return None
|
||||
|
||||
async def download_from_civitai(
|
||||
self,
|
||||
model_id: int | None = None,
|
||||
@@ -242,6 +411,10 @@ class DownloadManager:
|
||||
Returns:
|
||||
Dict with download result
|
||||
"""
|
||||
# Normalize falsy file_params (e.g. an empty dict from API JSON
|
||||
# parsing) to None so gate conditions behave consistently (#1058).
|
||||
file_params = file_params or None
|
||||
|
||||
logger.debug(
|
||||
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
|
||||
"source=%s, file_params=%s",
|
||||
@@ -544,6 +717,47 @@ class DownloadManager:
|
||||
await asyncio.sleep(delay)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _reconcile_failed_aria2_partial(save_path: str) -> None:
|
||||
"""Reconcile on-disk partial state after a failed aria2 transfer.
|
||||
|
||||
The payload and its ``.aria2`` control file form a resumable pair and
|
||||
are preserved together so a retry (with a refreshed URL when needed)
|
||||
can resume via aria2's ``continue=true``. A control file without its
|
||||
payload cannot resume anything, so the orphan is reported and removed.
|
||||
"""
|
||||
control_path = f"{save_path}.aria2"
|
||||
payload_exists = os.path.exists(save_path)
|
||||
control_exists = os.path.exists(control_path)
|
||||
|
||||
if payload_exists and not control_exists:
|
||||
# If the .aria2 control file is missing, aria2 considers the
|
||||
# download complete. A transient RPC failure may have made us
|
||||
# think the download failed even though the file is fully on disk.
|
||||
# Keep the file so a retry can find it already complete.
|
||||
logger.warning(
|
||||
"aria2 download reported failure but .aria2 file is absent "
|
||||
"for %s — the file is likely complete. Preserving it for retry.",
|
||||
save_path,
|
||||
)
|
||||
elif payload_exists and control_exists:
|
||||
logger.info(
|
||||
"Preserving aria2 partial download for resume: %s", save_path
|
||||
)
|
||||
elif control_exists:
|
||||
logger.warning(
|
||||
"Orphaned aria2 control file without payload: %s — removing it",
|
||||
control_path,
|
||||
)
|
||||
try:
|
||||
os.remove(control_path)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to remove orphaned aria2 control file %s: %s",
|
||||
control_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
async def _cleanup_cancelled_download_files(
|
||||
self,
|
||||
download_id: str,
|
||||
@@ -816,6 +1030,7 @@ class DownloadManager:
|
||||
version_info,
|
||||
record.get("model_version_id"),
|
||||
record.get("save_path") or record.get("file_path"),
|
||||
file_info=file_info,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
@@ -939,6 +1154,11 @@ class DownloadManager:
|
||||
|
||||
save_path = self._resolve_save_path_from_persisted_record(record)
|
||||
if save_path is None:
|
||||
# No resolvable target path (e.g. a queued download whose
|
||||
# paths were never resolved before shutdown): the record
|
||||
# can never be restored, so drop it instead of letting it
|
||||
# accumulate in the state store forever.
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
continue
|
||||
|
||||
if (
|
||||
@@ -1047,6 +1267,24 @@ class DownloadManager:
|
||||
)
|
||||
continue
|
||||
|
||||
if not os.path.exists(save_path) and os.path.exists(control_path):
|
||||
# A control file without its payload cannot resume
|
||||
# anything; report it and clean up the orphan.
|
||||
logger.warning(
|
||||
"Orphaned aria2 control file without payload for %s: "
|
||||
"%s — removing it",
|
||||
download_id,
|
||||
control_path,
|
||||
)
|
||||
try:
|
||||
os.remove(control_path)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to remove orphaned aria2 control file %s: %s",
|
||||
control_path,
|
||||
exc,
|
||||
)
|
||||
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
|
||||
self._restored_persisted_downloads = True
|
||||
@@ -1152,9 +1390,13 @@ class DownloadManager:
|
||||
use_save_dir_as_root: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Wrapper for original download_from_civitai implementation"""
|
||||
file_params = file_params or None
|
||||
try:
|
||||
# Check if model version already exists in library
|
||||
if model_version_id is not None:
|
||||
# Check if model version already exists in library.
|
||||
# With an explicit file selection (file_params) the version-level
|
||||
# check is deferred until after the metadata fetch, when the target
|
||||
# file can be resolved and checked individually (#1058).
|
||||
if model_version_id is not None and file_params is None:
|
||||
# Check both scanners
|
||||
lora_scanner = await self._get_lora_scanner()
|
||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||
@@ -1235,8 +1477,26 @@ class DownloadManager:
|
||||
except (TypeError, ValueError):
|
||||
resolved_version_id = None
|
||||
|
||||
# Resolve the explicitly selected file (if any) up front so the
|
||||
# existence gates and the actual file selection below always agree
|
||||
# on the target file (#1058).
|
||||
target_file: Optional[Dict[str, Any]] = None
|
||||
if file_params is not None:
|
||||
target_file = self._resolve_target_file(
|
||||
version_info.get("files") or [], file_params
|
||||
)
|
||||
if target_file is None:
|
||||
logger.warning(
|
||||
"[download] file_params provided but no file matched; "
|
||||
"falling back to version-level checks and primary file "
|
||||
"selection (model_version_id=%s)",
|
||||
resolved_version_id,
|
||||
)
|
||||
explicit_file = target_file is not None
|
||||
|
||||
if (
|
||||
get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||
not explicit_file
|
||||
and get_settings_manager().get_skip_previously_downloaded_model_versions()
|
||||
and resolved_version_id is not None
|
||||
and await self._has_been_downloaded(model_type, resolved_version_id)
|
||||
):
|
||||
@@ -1346,9 +1606,38 @@ class DownloadManager:
|
||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||
)
|
||||
|
||||
# Case 2: model_version_id was None, check after getting version_info
|
||||
if model_version_id is None:
|
||||
version_id = version_info.get("id")
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
# - An explicit file selection only blocks when THIS file is
|
||||
# already in the library; other files of the same version
|
||||
# remain downloadable.
|
||||
# - Without file_params (or when file_params failed to resolve),
|
||||
# keep version-level protection. The case "model_version_id
|
||||
# given + no file_params" was already covered by the early
|
||||
# gate above.
|
||||
if explicit_file and resolved_version_id is not None:
|
||||
existing_entry = await self._find_local_file_entry(
|
||||
model_type, resolved_version_id, target_file
|
||||
)
|
||||
if existing_entry is not None:
|
||||
error_message = (
|
||||
f"File '{target_file.get('name')}' from model version "
|
||||
f"{resolved_version_id} already exists in {model_type} library"
|
||||
)
|
||||
logger.info("[download] %s", error_message)
|
||||
return {"success": False, "error": error_message}
|
||||
logger.info(
|
||||
"[download] File '%s' of model version %s not in %s library — "
|
||||
"download allowed (other files of this version may exist locally)",
|
||||
target_file.get("name"), resolved_version_id, model_type,
|
||||
)
|
||||
elif file_params is not None or model_version_id is None:
|
||||
# Case 2: model_version_id was None, or file_params did not
|
||||
# resolve to a concrete file — check at version level.
|
||||
version_id = (
|
||||
resolved_version_id
|
||||
if resolved_version_id is not None
|
||||
else version_info.get("id")
|
||||
)
|
||||
|
||||
if model_type == "lora":
|
||||
# Check lora scanner
|
||||
@@ -1495,73 +1784,16 @@ class DownloadManager:
|
||||
files = version_info.get("files", [])
|
||||
file_info = None
|
||||
|
||||
# If file_params is provided, try to find matching file
|
||||
if file_params and model_version_id:
|
||||
target_file_id = file_params.get("id")
|
||||
target_type = file_params.get("type", "Model")
|
||||
target_format = file_params.get("format")
|
||||
target_size = file_params.get("size")
|
||||
target_fp = file_params.get("fp")
|
||||
is_primary = file_params.get("isPrimary", False)
|
||||
|
||||
logger.debug(
|
||||
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
|
||||
model_version_id, len(files),
|
||||
)
|
||||
|
||||
if target_file_id:
|
||||
target_id_str = str(target_file_id)
|
||||
for f in files:
|
||||
f_id = f.get("id")
|
||||
if str(f_id) == target_id_str:
|
||||
file_info = f
|
||||
logger.debug(
|
||||
"[download] MATCH by ID: id=%s name='%s'",
|
||||
f_id, f.get("name"),
|
||||
)
|
||||
break
|
||||
if not file_info:
|
||||
logger.debug("[download] No file found with id=%s", target_file_id)
|
||||
|
||||
elif is_primary:
|
||||
file_info = next(
|
||||
(
|
||||
f
|
||||
for f in files
|
||||
if f.get("primary")
|
||||
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
||||
),
|
||||
None,
|
||||
)
|
||||
else:
|
||||
# Lenient metadata match: only compare fields present on both sides
|
||||
for f in files:
|
||||
f_type = f.get("type", "")
|
||||
if f_type != target_type:
|
||||
continue
|
||||
|
||||
f_meta = f.get("metadata", {})
|
||||
f_format = f_meta.get("format") or f.get("format")
|
||||
f_size = f_meta.get("size") or f.get("size")
|
||||
f_fp = f_meta.get("fp") or f.get("fp")
|
||||
|
||||
if target_format and f_format != target_format:
|
||||
continue
|
||||
if target_size and f_size and f_size != target_size:
|
||||
continue
|
||||
if target_fp and f_fp and f_fp != target_fp:
|
||||
continue
|
||||
|
||||
file_info = f
|
||||
break
|
||||
|
||||
# If file_params is provided, reuse the file resolved right after
|
||||
# the metadata fetch so the existence gate and this selection
|
||||
# always agree on the target file (#1058).
|
||||
if file_params is not None:
|
||||
file_info = target_file
|
||||
if not file_info:
|
||||
logger.debug(
|
||||
"[download] No match found via file_params — falling back to primary file lookup",
|
||||
)
|
||||
elif not file_params:
|
||||
else:
|
||||
logger.debug(
|
||||
"[download] No file_params provided (null/None) — will use primary file lookup. "
|
||||
"model_version_id=%s, total_files=%d",
|
||||
@@ -1706,6 +1938,7 @@ class DownloadManager:
|
||||
version_info,
|
||||
model_version_id,
|
||||
save_path,
|
||||
file_info=file_info,
|
||||
)
|
||||
await self._sync_downloaded_version(
|
||||
model_type,
|
||||
@@ -1748,6 +1981,7 @@ class DownloadManager:
|
||||
version_info: Dict[str, Any],
|
||||
fallback_version_id=None,
|
||||
file_path: str | None = None,
|
||||
file_info: Dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
try:
|
||||
history_service = await ServiceRegistry.get_downloaded_version_history_service()
|
||||
@@ -1773,6 +2007,15 @@ class DownloadManager:
|
||||
if version_id is None:
|
||||
version_id = fallback_version_id
|
||||
|
||||
# Per-file identity for multi-file versions (#1058)
|
||||
file_id = None
|
||||
file_name = None
|
||||
if isinstance(file_info, dict):
|
||||
file_id = file_info.get("id")
|
||||
raw_file_name = file_info.get("name")
|
||||
if isinstance(raw_file_name, str) and raw_file_name.strip():
|
||||
file_name = raw_file_name.strip()
|
||||
|
||||
try:
|
||||
await history_service.mark_downloaded(
|
||||
model_type,
|
||||
@@ -1780,6 +2023,8 @@ class DownloadManager:
|
||||
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
|
||||
source="download",
|
||||
file_path=file_path,
|
||||
file_id=file_id,
|
||||
file_name=file_name,
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
logger.debug(
|
||||
@@ -1959,6 +2204,28 @@ class DownloadManager:
|
||||
|
||||
return formatted_path
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def _exclusive_target_slot(self, target_key: str):
|
||||
async with self._path_slot_guard:
|
||||
slot = self._path_slots.get(target_key)
|
||||
if slot is None:
|
||||
slot = _PathSlot()
|
||||
self._path_slots[target_key] = slot
|
||||
slot.refs += 1
|
||||
try:
|
||||
async with slot.lock:
|
||||
yield
|
||||
finally:
|
||||
async with self._path_slot_guard:
|
||||
slot.refs -= 1
|
||||
if slot.refs <= 0:
|
||||
_ = self._path_slots.pop(target_key, None)
|
||||
|
||||
def _target_slot_key(self, save_dir: str, metadata) -> str:
|
||||
return os.path.abspath(
|
||||
os.path.join(save_dir, os.path.basename(metadata.file_path))
|
||||
)
|
||||
|
||||
async def _execute_download(
|
||||
self,
|
||||
download_urls: List[str],
|
||||
@@ -1970,6 +2237,33 @@ class DownloadManager:
|
||||
model_type: str = "lora",
|
||||
download_id: str | None = None,
|
||||
transfer_backend: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the download serialized against other downloads targeting the same path."""
|
||||
target_key = self._target_slot_key(save_dir, metadata)
|
||||
async with self._exclusive_target_slot(target_key):
|
||||
return await self._execute_download_pipeline(
|
||||
download_urls=download_urls,
|
||||
save_dir=save_dir,
|
||||
metadata=metadata,
|
||||
version_info=version_info,
|
||||
relative_path=relative_path,
|
||||
progress_callback=progress_callback,
|
||||
model_type=model_type,
|
||||
download_id=download_id,
|
||||
transfer_backend=transfer_backend,
|
||||
)
|
||||
|
||||
async def _execute_download_pipeline(
|
||||
self,
|
||||
download_urls: List[str],
|
||||
save_dir: str,
|
||||
metadata,
|
||||
version_info: Dict[str, Any],
|
||||
relative_path: str,
|
||||
progress_callback=None,
|
||||
model_type: str = "lora",
|
||||
download_id: str | None = None,
|
||||
transfer_backend: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute the actual download process including preview images and model files"""
|
||||
metadata_entries: List[Any] = []
|
||||
@@ -2188,20 +2482,8 @@ class DownloadManager:
|
||||
break
|
||||
|
||||
last_error = result
|
||||
# For aria2: if the .aria2 control file is missing, aria2 considers
|
||||
# the download complete. A transient RPC failure may have made us
|
||||
# think the download failed even though the file is fully on disk.
|
||||
# Keep the file so a retry can find it already complete.
|
||||
if (
|
||||
transfer_backend == "aria2"
|
||||
and os.path.exists(save_path)
|
||||
and not os.path.exists(f"{save_path}.aria2")
|
||||
):
|
||||
logger.warning(
|
||||
"aria2 download reported failure but .aria2 file is absent "
|
||||
"for %s — the file is likely complete. Preserving it for retry.",
|
||||
save_path,
|
||||
)
|
||||
if transfer_backend == "aria2":
|
||||
self._reconcile_failed_aria2_partial(save_path)
|
||||
elif os.path.exists(save_path):
|
||||
try:
|
||||
os.remove(save_path)
|
||||
@@ -2729,6 +3011,64 @@ class DownloadManager:
|
||||
# Preserve aria2 state store entry so the partial download
|
||||
# info survives restarts and can be resumed later
|
||||
|
||||
async def discard_cleared_downloads(self, download_ids: Iterable[str]) -> int:
|
||||
"""Stop in-memory tracking for downloads cleared from the queue.
|
||||
|
||||
Cancels asyncio tasks, removes live aria2 transfers and drops the
|
||||
persisted aria2 state so cleared downloads cannot keep polling the
|
||||
daemon or be resurrected as ghost entries on the next restart.
|
||||
Partial files on disk are preserved; unlike ``cancel_download`` no
|
||||
files are deleted.
|
||||
|
||||
Returns the number of downloads that had any in-memory or persisted
|
||||
tracking removed.
|
||||
"""
|
||||
discarded = 0
|
||||
aria2_downloader = None
|
||||
|
||||
for download_id in download_ids:
|
||||
task = self._download_tasks.get(download_id)
|
||||
info = self._active_downloads.get(download_id)
|
||||
persisted = await self._aria2_state_store.get(download_id)
|
||||
if task is None and info is None and persisted is None:
|
||||
continue
|
||||
|
||||
discarded += 1
|
||||
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
|
||||
pause_control = self._pause_events.pop(download_id, None)
|
||||
if pause_control is not None:
|
||||
pause_control.resume()
|
||||
|
||||
if task is not None:
|
||||
try:
|
||||
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
|
||||
except (asyncio.CancelledError, asyncio.TimeoutError):
|
||||
pass
|
||||
|
||||
self._download_tasks.pop(download_id, None)
|
||||
self._active_downloads.pop(download_id, None)
|
||||
|
||||
backend = (info or persisted or {}).get("transfer_backend") or "python"
|
||||
if backend == "aria2":
|
||||
if aria2_downloader is None:
|
||||
aria2_downloader = await get_aria2_downloader()
|
||||
if await aria2_downloader.has_transfer(download_id):
|
||||
try:
|
||||
await aria2_downloader.cancel_download(download_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove aria2 transfer for cleared download %s: %s",
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
await self._aria2_state_store.remove(download_id)
|
||||
|
||||
return discarded
|
||||
|
||||
async def pause_download(self, download_id: str) -> Dict[str, Any]:
|
||||
"""Pause an active download without losing progress."""
|
||||
|
||||
|
||||
@@ -6,12 +6,21 @@ import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from ..utils.cache_paths import get_cache_base_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SQL fragment extracting the CivitAI file id from the JSON ``file_params``
|
||||
# column (#1058). ``json_valid`` guards against NULL and legacy/unparseable
|
||||
# values, yielding NULL for rows without a file identity; NULL keys group
|
||||
# together so such rows keep the old version-level dedup behavior.
|
||||
_FILE_ID_SQL = (
|
||||
"CASE WHEN json_valid(file_params) "
|
||||
"THEN json_extract(file_params, '$.id') END"
|
||||
)
|
||||
|
||||
|
||||
def _resolve_database_path() -> str:
|
||||
base_dir = get_cache_base_dir(create=True)
|
||||
@@ -64,6 +73,7 @@ class DownloadQueueService:
|
||||
model_name TEXT NOT NULL DEFAULT '',
|
||||
version_name TEXT DEFAULT '',
|
||||
thumbnail_url TEXT DEFAULT '',
|
||||
file_params TEXT,
|
||||
status TEXT NOT NULL,
|
||||
error TEXT,
|
||||
file_path TEXT,
|
||||
@@ -120,6 +130,18 @@ class DownloadQueueService:
|
||||
with self._connect() as conn:
|
||||
conn.executescript(self._SCHEMA_TABLES)
|
||||
|
||||
# Databases created by older versions lack
|
||||
# download_history.file_params; add it so retry-from-history can
|
||||
# restore the originally selected file (#1058).
|
||||
history_columns = {
|
||||
row["name"]
|
||||
for row in conn.execute("PRAGMA table_info(download_history)")
|
||||
}
|
||||
if "file_params" not in history_columns:
|
||||
conn.execute(
|
||||
"ALTER TABLE download_history ADD COLUMN file_params TEXT"
|
||||
)
|
||||
|
||||
# Creating the unique index on download_history.download_id can
|
||||
# fail if pre-existing rows have duplicate values (e.g. from a
|
||||
# previous version that lacked the index). Deduplicate first so
|
||||
@@ -368,23 +390,31 @@ class DownloadQueueService:
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> int:
|
||||
async def clear_queue(self, status_filter: Optional[str] = None) -> List[str]:
|
||||
"""Remove items from the queue.
|
||||
|
||||
When *status_filter* is provided only items with that status are
|
||||
deleted. Returns the number of deleted rows.
|
||||
deleted. Returns the ``download_id`` values of the deleted rows so
|
||||
callers can also tear down any in-memory tracking for them.
|
||||
"""
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
if status_filter is not None:
|
||||
cursor = conn.execute(
|
||||
rows = conn.execute(
|
||||
"SELECT download_id FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
).fetchall()
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE status = ?",
|
||||
(status_filter,),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute("DELETE FROM download_queue")
|
||||
rows = conn.execute(
|
||||
"SELECT download_id FROM download_queue"
|
||||
).fetchall()
|
||||
conn.execute("DELETE FROM download_queue")
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
return [row["download_id"] for row in rows]
|
||||
|
||||
async def complete_download(
|
||||
self,
|
||||
@@ -418,6 +448,12 @@ class DownloadQueueService:
|
||||
return None
|
||||
|
||||
now = completed_at if completed_at is not None else time.time()
|
||||
# Guard against legacy databases whose download_queue table
|
||||
# predates the file_params column.
|
||||
queue_columns = set(row.keys())
|
||||
file_params_json = (
|
||||
row["file_params"] if "file_params" in queue_columns else None
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM download_queue WHERE download_id = ?",
|
||||
(download_id,),
|
||||
@@ -426,9 +462,9 @@ class DownloadQueueService:
|
||||
"""
|
||||
INSERT OR IGNORE INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
version_name, thumbnail_url, file_params, status, error,
|
||||
file_path, bytes_downloaded, total_bytes, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
row["download_id"],
|
||||
@@ -437,6 +473,7 @@ class DownloadQueueService:
|
||||
row["model_name"],
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
file_params_json,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
@@ -503,6 +540,7 @@ class DownloadQueueService:
|
||||
bytes_downloaded: int = 0,
|
||||
total_bytes: Optional[int] = None,
|
||||
is_already_exists: int = 0,
|
||||
file_params: Optional[dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Insert a record into the download history.
|
||||
|
||||
@@ -510,6 +548,7 @@ class DownloadQueueService:
|
||||
inserted row.
|
||||
"""
|
||||
now = time.time()
|
||||
file_params_json = json.dumps(file_params) if file_params is not None else None
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
@@ -517,9 +556,10 @@ class DownloadQueueService:
|
||||
"""
|
||||
INSERT INTO download_history (
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, status, error, file_path,
|
||||
bytes_downloaded, total_bytes, completed_at, is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
version_name, thumbnail_url, file_params, status, error,
|
||||
file_path, bytes_downloaded, total_bytes, completed_at,
|
||||
is_already_exists
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
download_id,
|
||||
@@ -528,6 +568,7 @@ class DownloadQueueService:
|
||||
model_name,
|
||||
version_name,
|
||||
thumbnail_url,
|
||||
file_params_json,
|
||||
status,
|
||||
error,
|
||||
file_path,
|
||||
@@ -702,7 +743,7 @@ class DownloadQueueService:
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
@@ -712,6 +753,7 @@ class DownloadQueueService:
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
row["file_params"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
@@ -755,7 +797,7 @@ class DownloadQueueService:
|
||||
download_id, model_id, model_version_id, model_name,
|
||||
version_name, thumbnail_url, source, file_params,
|
||||
status, priority, added_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
|
||||
""",
|
||||
(
|
||||
new_id,
|
||||
@@ -765,6 +807,7 @@ class DownloadQueueService:
|
||||
row["version_name"],
|
||||
row["thumbnail_url"],
|
||||
"retry",
|
||||
row["file_params"],
|
||||
now,
|
||||
),
|
||||
)
|
||||
@@ -840,33 +883,44 @@ class DownloadQueueService:
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
|
||||
# 1. History: for each (model_id, model_version_id, status) triplet
|
||||
# keep only the row with the highest id (most recently inserted).
|
||||
conn.execute("""
|
||||
# 1. History: for each (model_id, model_version_id, file_id,
|
||||
# status) group keep only the row with the highest id (most
|
||||
# recently inserted). file_id comes from file_params (#1058)
|
||||
# so distinct files of the same version never collapse.
|
||||
conn.execute(f"""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT MAX(id)
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id, status
|
||||
GROUP BY model_id, model_version_id, status,
|
||||
{_FILE_ID_SQL}
|
||||
)
|
||||
""")
|
||||
result["removed_history"] = conn.execute(
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 2. Cross-status dedup: for each (model_id, model_version_id),
|
||||
# keep only the entry with the highest-priority terminal status.
|
||||
# 2. Cross-status dedup: for each (model_id, model_version_id,
|
||||
# file_id), keep only the entry with the highest-priority
|
||||
# terminal status.
|
||||
# Priority: completed (3) > failed (2) > canceled (1).
|
||||
# This prevents the same model version from having both a
|
||||
# 'failed' and a 'canceled' entry (or a 'completed' alongside
|
||||
# either) after the bug-created duplicates are removed.
|
||||
conn.execute("""
|
||||
# This prevents the same file of a model version from having
|
||||
# both a 'failed' and a 'canceled' entry (or a 'completed'
|
||||
# alongside either) after the bug-created duplicates are
|
||||
# removed. ``IS`` matches NULL file ids against each other so
|
||||
# rows without file identity keep the old behavior.
|
||||
conn.execute(f"""
|
||||
DELETE FROM download_history
|
||||
WHERE id NOT IN (
|
||||
SELECT dh.id
|
||||
FROM download_history dh
|
||||
FROM (
|
||||
SELECT id, model_id, model_version_id, status,
|
||||
{_FILE_ID_SQL} AS file_id
|
||||
FROM download_history
|
||||
) dh
|
||||
INNER JOIN (
|
||||
SELECT model_id, model_version_id,
|
||||
{_FILE_ID_SQL} AS file_id,
|
||||
MAX(CASE status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
@@ -874,17 +928,18 @@ class DownloadQueueService:
|
||||
ELSE 0
|
||||
END) AS best_prio
|
||||
FROM download_history
|
||||
GROUP BY model_id, model_version_id
|
||||
GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
|
||||
) best
|
||||
ON dh.model_id = best.model_id
|
||||
AND dh.model_version_id = best.model_version_id
|
||||
AND dh.file_id IS best.file_id
|
||||
AND CASE dh.status
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'canceled' THEN 1
|
||||
ELSE 0
|
||||
END = best.best_prio
|
||||
GROUP BY dh.model_id, dh.model_version_id
|
||||
GROUP BY dh.model_id, dh.model_version_id, dh.file_id
|
||||
HAVING dh.id = MAX(dh.id)
|
||||
)
|
||||
""")
|
||||
@@ -892,15 +947,17 @@ class DownloadQueueService:
|
||||
"SELECT changes()"
|
||||
).fetchone()[0]
|
||||
|
||||
# 3. Queue: for each (model_id, model_version_id) keep only the
|
||||
# row with the latest added_at (most recently enqueued).
|
||||
conn.execute("""
|
||||
# 3. Queue: for each (model_id, model_version_id, file_id) keep
|
||||
# only the row with the latest added_at (most recently
|
||||
# enqueued). file_id comes from file_params (#1058) so
|
||||
# distinct files of the same version never collapse.
|
||||
conn.execute(f"""
|
||||
DELETE FROM download_queue
|
||||
WHERE rowid NOT IN (
|
||||
SELECT MAX(rowid)
|
||||
FROM download_queue
|
||||
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
GROUP BY model_id, model_version_id
|
||||
GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
|
||||
)
|
||||
AND status IN ('queued', 'downloading', 'paused', 'waiting')
|
||||
""")
|
||||
|
||||
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
|
||||
ON downloaded_model_versions(model_type, model_id);
|
||||
CREATE TABLE IF NOT EXISTS downloaded_version_files (
|
||||
model_type TEXT NOT NULL,
|
||||
version_id INTEGER NOT NULL,
|
||||
file_id INTEGER NOT NULL,
|
||||
file_name TEXT,
|
||||
downloaded_at REAL NOT NULL,
|
||||
PRIMARY KEY (model_type, version_id, file_id)
|
||||
);
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
|
||||
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
|
||||
source: str = "manual",
|
||||
file_path: str | None = None,
|
||||
library_name: str | None = None,
|
||||
file_id: int | None = None,
|
||||
file_name: str | None = None,
|
||||
) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_model_id = _normalize_int(model_id)
|
||||
normalized_file_id = _normalize_int(file_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return
|
||||
|
||||
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
|
||||
active_library_name,
|
||||
),
|
||||
)
|
||||
if normalized_file_id is not None:
|
||||
# Per-file history for multi-file versions (#1058)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO downloaded_version_files (
|
||||
model_type, version_id, file_id, file_name, downloaded_at
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
|
||||
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
|
||||
downloaded_at = excluded.downloaded_at
|
||||
""",
|
||||
(
|
||||
normalized_type,
|
||||
normalized_version_id,
|
||||
normalized_file_id,
|
||||
file_name,
|
||||
timestamp,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_downloaded_bulk(
|
||||
@@ -206,24 +236,33 @@ class DownloadedVersionHistoryService:
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
# The connection is created with check_same_thread=False and all
|
||||
# access is serialized by self._lock, so the executemany upsert +
|
||||
# commit can run in the default executor without blocking the
|
||||
# event loop on large hydration payloads.
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._mark_downloaded_bulk_sync, payload)
|
||||
|
||||
def _mark_downloaded_bulk_sync(self, payload: Sequence[tuple[object, ...]]) -> None:
|
||||
"""Synchronous executemany upsert + commit; runs in a worker thread."""
|
||||
conn = self._get_conn()
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT INTO downloaded_model_versions (
|
||||
model_type, version_id, model_id, first_seen_at, last_seen_at,
|
||||
source, last_file_path, last_library_name, is_deleted_override
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
|
||||
ON CONFLICT(model_type, version_id) DO UPDATE SET
|
||||
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
|
||||
last_seen_at = excluded.last_seen_at,
|
||||
source = excluded.source,
|
||||
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
|
||||
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
|
||||
is_deleted_override = 0
|
||||
""",
|
||||
payload,
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
@@ -255,8 +294,63 @@ class DownloadedVersionHistoryService:
|
||||
self._get_active_library_name(),
|
||||
),
|
||||
)
|
||||
# Whole-version deletion also clears the per-file records (#1058)
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def mark_file_deleted(
|
||||
self, model_type: str, version_id: int, file_id: int
|
||||
) -> None:
|
||||
"""Drop a single file record of a version, keeping siblings (#1058)."""
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
normalized_file_id = _normalize_int(file_id)
|
||||
if (
|
||||
normalized_type is None
|
||||
or normalized_version_id is None
|
||||
or normalized_file_id is None
|
||||
):
|
||||
return
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ? AND file_id = ?
|
||||
""",
|
||||
(normalized_type, normalized_version_id, normalized_file_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
async def get_downloaded_file_ids(
|
||||
self, model_type: str, version_id: int
|
||||
) -> list[int]:
|
||||
"""Return the CivitAI file ids recorded as downloaded for a version."""
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
if normalized_type is None or normalized_version_id is None:
|
||||
return []
|
||||
|
||||
async with self._lock:
|
||||
conn = self._get_conn()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT file_id
|
||||
FROM downloaded_version_files
|
||||
WHERE model_type = ? AND version_id = ?
|
||||
ORDER BY file_id ASC
|
||||
""",
|
||||
(normalized_type, normalized_version_id),
|
||||
).fetchall()
|
||||
return [int(row["file_id"]) for row in rows]
|
||||
|
||||
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
|
||||
normalized_type = _normalize_model_type(model_type)
|
||||
normalized_version_id = _normalize_int(version_id)
|
||||
|
||||
+145
-57
@@ -32,6 +32,7 @@ from .connectivity_guard import (
|
||||
ConnectivityGuard,
|
||||
)
|
||||
from .errors import RateLimitError
|
||||
from .rate_limit_coordinator import RateLimitCoordinator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -156,6 +157,25 @@ class DownloadStalledError(Exception):
|
||||
"""Raised when download progress stalls beyond the configured timeout."""
|
||||
|
||||
|
||||
def _disable_netrc_auth(session: aiohttp.ClientSession) -> None:
|
||||
"""Prevent the session from loading credentials from netrc files.
|
||||
|
||||
``trust_env=True`` is kept so system-level proxies still work, but aiohttp
|
||||
would also auto-apply netrc entries (e.g. ``machine civitai.red``) as
|
||||
BasicAuth. aiohttp refuses to combine those with the explicit
|
||||
``Authorization: Bearer`` header set for CivitAI requests, raising
|
||||
"Cannot combine AUTHORIZATION header with AUTH argument or credentials
|
||||
encoded in URL" before the request is even sent. Subclassing ClientSession
|
||||
is discouraged by aiohttp (emits a DeprecationWarning), so the private
|
||||
hook is patched on the instance instead.
|
||||
"""
|
||||
|
||||
def _no_netrc_auth(*args: Any, **kwargs: Any) -> Optional[aiohttp.BasicAuth]:
|
||||
return None
|
||||
|
||||
setattr(session, "_get_netrc_auth", _no_netrc_auth)
|
||||
|
||||
|
||||
class Downloader:
|
||||
"""Unified downloader for all HTTP/HTTPS downloads in the application."""
|
||||
|
||||
@@ -370,6 +390,7 @@ class Downloader:
|
||||
trust_env=not app_proxy_active,
|
||||
timeout=timeout,
|
||||
)
|
||||
_disable_netrc_auth(self._session)
|
||||
|
||||
# Store proxy URL for per-request use. Stays None for SOCKS because the
|
||||
# ProxyConnector already tunnels everything; passing proxy= for SOCKS
|
||||
@@ -575,6 +596,21 @@ class Downloader:
|
||||
False,
|
||||
"File not found - the download link may be invalid or expired.",
|
||||
)
|
||||
elif response.status == 429:
|
||||
# Register the vendor's cooldown so API calls through
|
||||
# make_request queue behind it (#1085). The download
|
||||
# itself fails as before; retry policy stays with the
|
||||
# caller (download manager).
|
||||
retry_after = self._extract_retry_after(response.headers)
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
if coordinator.enabled:
|
||||
coordinator.register_rate_limit(
|
||||
self._guard_destination(url), retry_after
|
||||
)
|
||||
logger.warning(
|
||||
f"Rate limited (429) for {url}, retry_after={retry_after}"
|
||||
)
|
||||
return False, f"Download rate limited (429), retry after {retry_after}s"
|
||||
else:
|
||||
logger.error(
|
||||
f"Download failed for {url} with status {response.status}"
|
||||
@@ -952,6 +988,11 @@ class Downloader:
|
||||
elif response.status == 429:
|
||||
raw_retry_after = response.headers.get("Retry-After")
|
||||
retry_after = _parse_retry_after(raw_retry_after or "")
|
||||
# Register the vendor's cooldown so API calls through
|
||||
# make_request queue behind it (#1085).
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
if coordinator.enabled:
|
||||
coordinator.register_rate_limit(destination, retry_after)
|
||||
if raw_retry_after:
|
||||
logger.warning(
|
||||
"Rate limited (429) for %s, Retry-After: %ss", url, retry_after
|
||||
@@ -1021,6 +1062,14 @@ class Downloader:
|
||||
if response.status == 200:
|
||||
guard.register_success(destination)
|
||||
return True, dict(response.headers)
|
||||
elif response.status == 429:
|
||||
# Register the vendor's cooldown so API calls through
|
||||
# make_request queue behind it (#1085).
|
||||
retry_after = self._extract_retry_after(response.headers)
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
if coordinator.enabled:
|
||||
coordinator.register_rate_limit(destination, retry_after)
|
||||
return False, f"Head request rate limited (429), retry after {retry_after}s"
|
||||
else:
|
||||
return False, f"Head request failed with status {response.status}"
|
||||
|
||||
@@ -1054,74 +1103,113 @@ class Downloader:
|
||||
|
||||
Returns:
|
||||
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
|
||||
|
||||
When the rate-limit gate is enabled (``rate_limit_gate_enabled``),
|
||||
requests are paced per destination and 429 responses are honored by
|
||||
waiting out the ``Retry-After`` window (bounded by
|
||||
``rate_limit_max_wait_seconds``) before re-sending. A ``RateLimitError``
|
||||
returned after gate involvement is marked with ``gate_handled = True``
|
||||
so downstream retry helpers do not wait a second time.
|
||||
"""
|
||||
guard = await ConnectivityGuard.get_instance()
|
||||
destination = self._guard_destination(url)
|
||||
# Fail fast on transport-level outages before pacing: there is no
|
||||
# point waiting out a vendor cooldown while the network is down.
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
|
||||
try:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
if self.proxy_url:
|
||||
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
|
||||
else:
|
||||
logger.debug(
|
||||
"[make_request] Using system-level proxy (trust_env) if configured."
|
||||
)
|
||||
coordinator = await RateLimitCoordinator.get_instance()
|
||||
gate_enabled = coordinator.enabled
|
||||
# Safety bound on the wait-and-resend loop; each 429 normally exits
|
||||
# via the wait cap in wait_for_slot, this covers pathological 429s
|
||||
# with tiny Retry-After values.
|
||||
max_resend_attempts = 5
|
||||
attempt = 0
|
||||
|
||||
# Prepare headers
|
||||
headers = self._get_auth_headers(use_auth)
|
||||
if custom_headers:
|
||||
headers.update(custom_headers)
|
||||
while True:
|
||||
if gate_enabled:
|
||||
try:
|
||||
await coordinator.wait_for_slot(destination)
|
||||
except RateLimitError as exc:
|
||||
exc.gate_handled = True
|
||||
return False, exc
|
||||
|
||||
# Add proxy to kwargs if not already present
|
||||
if "proxy" not in kwargs:
|
||||
kwargs["proxy"] = self.proxy_url
|
||||
|
||||
async with session.request(
|
||||
method, url, headers=headers, **kwargs
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
guard.register_success(destination)
|
||||
# Try to parse as JSON, fall back to text
|
||||
try:
|
||||
data = await response.json()
|
||||
return True, data
|
||||
except:
|
||||
text = await response.text()
|
||||
return True, text
|
||||
elif response.status == 401:
|
||||
return False, "Unauthorized access - invalid or missing API key"
|
||||
elif response.status == 403:
|
||||
return False, "Access forbidden"
|
||||
elif response.status == 404:
|
||||
return False, "Resource not found"
|
||||
elif response.status == 429:
|
||||
retry_after = self._extract_retry_after(response.headers)
|
||||
error_msg = "Request rate limited"
|
||||
logger.warning(
|
||||
"Rate limit encountered for %s %s; retry_after=%s",
|
||||
method,
|
||||
url,
|
||||
retry_after,
|
||||
)
|
||||
return False, RateLimitError(
|
||||
error_msg,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
try:
|
||||
session = await self.session
|
||||
# Debug log for proxy mode at request time
|
||||
if self.proxy_url:
|
||||
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
|
||||
else:
|
||||
return False, f"Request failed with status {response.status}"
|
||||
logger.debug(
|
||||
"[make_request] Using system-level proxy (trust_env) if configured."
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
logger.debug("Network unavailable for %s %s: %s", method, url, e)
|
||||
# Prepare headers
|
||||
headers = self._get_auth_headers(use_auth)
|
||||
if custom_headers:
|
||||
headers.update(custom_headers)
|
||||
|
||||
# Add proxy to kwargs if not already present
|
||||
if "proxy" not in kwargs:
|
||||
kwargs["proxy"] = self.proxy_url
|
||||
|
||||
async with session.request(
|
||||
method, url, headers=headers, **kwargs
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
guard.register_success(destination)
|
||||
if gate_enabled:
|
||||
coordinator.register_success(destination)
|
||||
# Try to parse as JSON, fall back to text
|
||||
try:
|
||||
data = await response.json()
|
||||
return True, data
|
||||
except:
|
||||
text = await response.text()
|
||||
return True, text
|
||||
elif response.status == 401:
|
||||
return False, "Unauthorized access - invalid or missing API key"
|
||||
elif response.status == 403:
|
||||
return False, "Access forbidden"
|
||||
elif response.status == 404:
|
||||
return False, "Resource not found"
|
||||
elif response.status == 429:
|
||||
retry_after = self._extract_retry_after(response.headers)
|
||||
error_msg = "Request rate limited"
|
||||
if not gate_enabled:
|
||||
logger.warning(
|
||||
"Rate limit encountered for %s %s; retry_after=%s",
|
||||
method,
|
||||
url,
|
||||
retry_after,
|
||||
)
|
||||
return False, RateLimitError(
|
||||
error_msg,
|
||||
retry_after=retry_after,
|
||||
)
|
||||
# The coordinator logs the cooldown notice (INFO once
|
||||
# per window, DEBUG on extension).
|
||||
coordinator.register_rate_limit(destination, retry_after)
|
||||
attempt += 1
|
||||
if attempt >= max_resend_attempts:
|
||||
error = RateLimitError(error_msg, retry_after=retry_after)
|
||||
error.gate_handled = True
|
||||
return False, error
|
||||
# Loop back: wait_for_slot blocks until the cooldown
|
||||
# elapses (or raises once the wait exceeds the cap).
|
||||
continue
|
||||
else:
|
||||
return False, f"Request failed with status {response.status}"
|
||||
|
||||
except Exception as e:
|
||||
if guard.is_network_unreachable_error(e):
|
||||
guard.register_network_failure(e, destination)
|
||||
if guard.should_block_request(destination):
|
||||
return False, OFFLINE_COOLDOWN_ERROR
|
||||
logger.debug("Network unavailable for %s %s: %s", method, url, e)
|
||||
return False, str(e)
|
||||
logger.error(f"Error making {method} request to {url}: {e}")
|
||||
return False, str(e)
|
||||
logger.error(f"Error making {method} request to {url}: {e}")
|
||||
return False, str(e)
|
||||
|
||||
async def close(self):
|
||||
"""Close the HTTP session"""
|
||||
|
||||
@@ -51,6 +51,7 @@ class EmbeddingService(BaseModelService):
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
|
||||
@@ -58,6 +58,7 @@ class LoraService(BaseModelService):
|
||||
"base_model": model_data.get("base_model", ""),
|
||||
"folder": folder,
|
||||
"sha256": model_data.get("sha256", ""),
|
||||
"autov3": model_data.get("autov3"),
|
||||
"file_path": file_path.replace(os.sep, "/"),
|
||||
"file_size": model_data.get("size", 0),
|
||||
"modified": model_data.get("modified", ""),
|
||||
|
||||
@@ -245,16 +245,23 @@ class MetadataSyncService:
|
||||
civitai_api_not_found = False
|
||||
any_rate_limited = False
|
||||
|
||||
skip_network_providers = False
|
||||
for provider_name, provider in provider_attempts:
|
||||
if skip_network_providers and provider_name != "sqlite":
|
||||
# A network provider was already rate-limited; failing
|
||||
# over to another network provider just spreads the flood
|
||||
# (#1085). The local sqlite archive stays as last resort.
|
||||
continue
|
||||
try:
|
||||
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
|
||||
except RateLimitError as exc:
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
provider_name or provider.__class__.__name__,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
any_rate_limited = True
|
||||
skip_network_providers = True
|
||||
continue
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
|
||||
@@ -419,14 +426,37 @@ class MetadataSyncService:
|
||||
metadata: Dict[str, Any],
|
||||
model_id: int,
|
||||
model_version_id: Optional[int],
|
||||
provider_name: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Relink a local metadata record to a specific CivitAI model version."""
|
||||
"""Relink a local metadata record to a specific CivitAI model version.
|
||||
|
||||
When ``provider_name`` is given, the named provider is resolved via the
|
||||
metadata provider selector instead of the default fallback chain. A
|
||||
missing/disabled provider surfaces a user-friendly error instead of the
|
||||
raw selector exception.
|
||||
"""
|
||||
|
||||
if provider_name:
|
||||
try:
|
||||
provider = await self._get_provider(provider_name)
|
||||
except ValueError as exc:
|
||||
logger.warning(
|
||||
"Unable to resolve metadata provider %s: %s", provider_name, exc
|
||||
)
|
||||
raise ValueError(
|
||||
"CivitArchive is not available or not enabled. "
|
||||
"Enable the CivitArchive API in settings to relink via CivArchive."
|
||||
) from exc
|
||||
else:
|
||||
provider = await self._get_default_provider()
|
||||
|
||||
provider = await self._get_default_provider()
|
||||
civitai_metadata = await provider.get_model_version(model_id, model_version_id)
|
||||
if not civitai_metadata:
|
||||
provider_label = (
|
||||
"CivitArchive" if provider_name == "civarchive_api" else "CivitAI"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Model version not found on CivitAI for ID: {model_id}"
|
||||
f"Model version not found on {provider_label} for ID: {model_id}"
|
||||
+ (f" with version: {model_version_id}" if model_version_id else "")
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ class ModelCache:
|
||||
folders: List[str]
|
||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
# Multi-valued companion to version_index: every local file entry of a
|
||||
# CivitAI model version, so versions with several downloaded files stay
|
||||
# consistent (#1058).
|
||||
version_files_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||
name_display_mode: str = "model_name"
|
||||
_lock: Any = field(init=False, repr=False, default=None)
|
||||
# Cache for last sort: (sort_key, order, seed) -> sorted list
|
||||
@@ -116,6 +120,7 @@ class ModelCache:
|
||||
|
||||
self.version_index = {}
|
||||
self.model_id_index = {}
|
||||
self.version_files_index = {}
|
||||
for item in self.raw_data:
|
||||
self.add_to_version_index(item)
|
||||
|
||||
@@ -132,6 +137,17 @@ class ModelCache:
|
||||
|
||||
self.version_index[version_id] = item
|
||||
|
||||
# Register in the multi-valued index, deduplicated by file_path (#1058)
|
||||
files = self.version_files_index.setdefault(version_id, [])
|
||||
for entry in files:
|
||||
if entry is item or (
|
||||
isinstance(entry, dict)
|
||||
and entry.get('file_path') == item.get('file_path')
|
||||
):
|
||||
break
|
||||
else:
|
||||
files.append(item)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
return
|
||||
@@ -159,12 +175,37 @@ class ModelCache:
|
||||
if version_id is None:
|
||||
return
|
||||
|
||||
# Drop only this file's entry from the multi-valued index (#1058)
|
||||
files = self.version_files_index.get(version_id)
|
||||
if files:
|
||||
remaining = [
|
||||
entry
|
||||
for entry in files
|
||||
if not (
|
||||
entry is item
|
||||
or (
|
||||
isinstance(entry, dict)
|
||||
and entry.get('file_path') == item.get('file_path')
|
||||
)
|
||||
)
|
||||
]
|
||||
if remaining:
|
||||
self.version_files_index[version_id] = remaining
|
||||
else:
|
||||
self.version_files_index.pop(version_id, None)
|
||||
|
||||
# A surviving sibling file keeps the version present in the indexes
|
||||
sibling = (self.version_files_index.get(version_id) or [None])[0]
|
||||
|
||||
existing = self.version_index.get(version_id)
|
||||
if existing is item or (
|
||||
isinstance(existing, dict)
|
||||
and existing.get('file_path') == item.get('file_path')
|
||||
):
|
||||
self.version_index.pop(version_id, None)
|
||||
if sibling is not None:
|
||||
self.version_index[version_id] = sibling
|
||||
else:
|
||||
self.version_index.pop(version_id, None)
|
||||
|
||||
model_id = self._normalize_version_id(civitai_data.get('modelId'))
|
||||
if model_id is None:
|
||||
@@ -174,6 +215,20 @@ class ModelCache:
|
||||
if not versions:
|
||||
return
|
||||
|
||||
if sibling is not None:
|
||||
# Update the descriptor to reflect the surviving sibling file
|
||||
descriptor = self._build_version_descriptor(
|
||||
sibling,
|
||||
sibling.get('civitai') if isinstance(sibling, dict) else {},
|
||||
version_id,
|
||||
)
|
||||
for index, existing_desc in enumerate(versions):
|
||||
if existing_desc.get('versionId') == version_id:
|
||||
if descriptor is not None:
|
||||
versions[index] = descriptor
|
||||
break
|
||||
return
|
||||
|
||||
filtered = [v for v in versions if v.get('versionId') != version_id]
|
||||
if filtered:
|
||||
self.model_id_index[model_id] = filtered
|
||||
@@ -206,6 +261,15 @@ class ModelCache:
|
||||
versions = self.model_id_index.get(normalized_id, [])
|
||||
return [dict(version) for version in versions]
|
||||
|
||||
def get_files_by_version_id(self, version_id: Any) -> List[Dict[str, Any]]:
|
||||
"""Return every local file entry for a CivitAI model version (#1058)."""
|
||||
|
||||
normalized_id = self._normalize_version_id(version_id)
|
||||
if normalized_id is None:
|
||||
return []
|
||||
|
||||
return list(self.version_files_index.get(normalized_id, []))
|
||||
|
||||
async def resort(self):
|
||||
"""Resort cached data according to last sort mode if set"""
|
||||
async with self._lock:
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from typing import Dict, Optional, Set, List
|
||||
import os
|
||||
|
||||
from ..utils.constants import is_empty_placeholder_hash
|
||||
|
||||
class ModelHashIndex:
|
||||
"""Index for looking up models by hash or filename"""
|
||||
|
||||
@@ -81,6 +83,8 @@ class ModelHashIndex:
|
||||
# mapping. First-time registrations stay O(1).
|
||||
if autov3:
|
||||
autov3 = autov3.lower()
|
||||
if is_empty_placeholder_hash(autov3):
|
||||
autov3 = None
|
||||
if is_re_registration and (existing_hash != sha256 or autov3):
|
||||
stale_autov3_keys = [
|
||||
key for key, mapped_path in self._autov3_to_path.items()
|
||||
@@ -93,7 +97,7 @@ class ModelHashIndex:
|
||||
|
||||
def add_autov3(self, autov3: str, file_path: str) -> None:
|
||||
"""Add or update an AutoV3-only index entry (used when only AutoV3 is known)"""
|
||||
if not autov3:
|
||||
if not autov3 or is_empty_placeholder_hash(autov3):
|
||||
return
|
||||
autov3 = autov3.lower()
|
||||
self._autov3_to_path[autov3] = file_path
|
||||
@@ -250,6 +254,8 @@ class ModelHashIndex:
|
||||
|
||||
def has_hash(self, hash_value: str) -> bool:
|
||||
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
|
||||
if is_empty_placeholder_hash(hash_value):
|
||||
return False
|
||||
normalized = hash_value.lower()
|
||||
if normalized in self._hash_to_path:
|
||||
return True
|
||||
@@ -261,6 +267,8 @@ class ModelHashIndex:
|
||||
|
||||
def get_path(self, hash_value: str) -> Optional[str]:
|
||||
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
|
||||
if is_empty_placeholder_hash(hash_value):
|
||||
return None
|
||||
normalized = hash_value.lower()
|
||||
path = self._hash_to_path.get(normalized)
|
||||
if path is not None:
|
||||
|
||||
@@ -66,6 +66,14 @@ class _RateLimitRetryHelper:
|
||||
except RateLimitError as exc:
|
||||
attempt += 1
|
||||
|
||||
# The downloader's rate-limit gate already applied the wait
|
||||
# policy for this request (waited out the vendor window or
|
||||
# deliberately refused because it exceeds the cap). Sleeping
|
||||
# again here would double the wait — just propagate.
|
||||
if getattr(exc, "gate_handled", False):
|
||||
exc.provider = exc.provider or label
|
||||
raise
|
||||
|
||||
# Determine effective retry limit based on rate-limit magnitude
|
||||
effective_retry_limit = self._retry_limit # default: 3
|
||||
if exc.retry_after is not None and exc.retry_after >= 120.0:
|
||||
@@ -101,6 +109,12 @@ class _RateLimitRetryHelper:
|
||||
|
||||
return min(self._max_delay, max(0.0, base_delay))
|
||||
|
||||
|
||||
# Labels of providers that are free to consult even while a network provider
|
||||
# is rate-limited (local lookups, no vendor cost).
|
||||
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite"})
|
||||
|
||||
|
||||
class ModelMetadataProvider(ABC):
|
||||
"""Base abstract class for all model metadata providers"""
|
||||
|
||||
@@ -451,7 +465,14 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
|
||||
return None
|
||||
|
||||
class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
"""Try providers in order, return first successful result."""
|
||||
"""Try providers in order, return first successful result.
|
||||
|
||||
Rate-limit policy (#1085): once a *network* provider raises
|
||||
``RateLimitError``, the chain stops consulting further network providers —
|
||||
failing over would just spread the flood to the next vendor. Local-only
|
||||
providers (see ``_LOCAL_PROVIDER_LABELS``) are still allowed as a last
|
||||
resort because they cost the vendor nothing.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -486,7 +507,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
)
|
||||
|
||||
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
rate_limited = False
|
||||
for provider, label in self._iter_providers():
|
||||
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
|
||||
continue
|
||||
try:
|
||||
result, error = await self._call_with_rate_limit(
|
||||
label,
|
||||
@@ -496,8 +520,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result, error
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
@@ -505,11 +530,18 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
|
||||
continue
|
||||
if rate_limited:
|
||||
# Distinct from "Model not found": callers must not mistake a
|
||||
# rate-limited lookup for a confirmed deletion.
|
||||
return None, "Rate limited"
|
||||
return None, "Model not found"
|
||||
|
||||
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
|
||||
not_found_confirmed = False
|
||||
rate_limited = False
|
||||
for provider, label in self._iter_providers():
|
||||
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
|
||||
continue
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
@@ -519,8 +551,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
@@ -539,7 +572,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
return None
|
||||
|
||||
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
rate_limited = False
|
||||
for provider, label in self._iter_providers():
|
||||
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
|
||||
continue
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
@@ -550,8 +586,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
@@ -562,7 +599,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
return None
|
||||
|
||||
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
||||
rate_limited = False
|
||||
for provider, label in self._iter_providers():
|
||||
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
|
||||
continue
|
||||
try:
|
||||
result, error = await self._call_with_rate_limit(
|
||||
label,
|
||||
@@ -572,8 +612,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result:
|
||||
return result, error
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
@@ -581,12 +622,17 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
except Exception as e:
|
||||
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
|
||||
continue
|
||||
if rate_limited:
|
||||
return None, "Rate limited"
|
||||
return None, "No provider could retrieve the data"
|
||||
|
||||
async def get_model_versions_by_hashes(
|
||||
self, hashes: List[str]
|
||||
) -> Optional[List[Dict[str, Any]]]:
|
||||
rate_limited = False
|
||||
for provider, label in self._iter_providers():
|
||||
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
|
||||
continue
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
@@ -598,8 +644,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
except NotImplementedError:
|
||||
continue
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
@@ -614,7 +661,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
return None
|
||||
|
||||
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
rate_limited = False
|
||||
for provider, label in self._iter_providers():
|
||||
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
|
||||
continue
|
||||
try:
|
||||
result = await self._call_with_rate_limit(
|
||||
label,
|
||||
@@ -625,8 +675,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
if result is not None:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
|
||||
@@ -432,6 +432,7 @@ class SearchStrategy:
|
||||
"tags": False,
|
||||
"recursive": True,
|
||||
"creator": False,
|
||||
"hash": False,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
@@ -494,8 +495,28 @@ class SearchStrategy:
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
# Hash search is always exact (never fuzzy): match the full
|
||||
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
|
||||
if options.get("hash", False):
|
||||
hash_query = search_lower.strip()
|
||||
if hash_query and self._matches_hash(item, hash_query):
|
||||
results.append(item)
|
||||
continue
|
||||
|
||||
return results
|
||||
|
||||
def _matches_hash(self, item: Dict[str, Any], hash_query: str) -> bool:
|
||||
"""Exact-match the normalized query against the item's known hashes."""
|
||||
sha256 = item.get("sha256")
|
||||
sha256_lower = sha256.lower() if isinstance(sha256, str) else ""
|
||||
if sha256_lower and hash_query in (sha256_lower, sha256_lower[:10]):
|
||||
return True
|
||||
# autov3 is None when unchecked and "" when checked but unavailable
|
||||
autov3 = item.get("autov3")
|
||||
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _matches(
|
||||
self, candidate: str, search_term: str, search_lower: str, fuzzy: bool
|
||||
) -> bool:
|
||||
|
||||
+296
-64
@@ -5,7 +5,7 @@ import asyncio
|
||||
import time
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Type, Union, cast
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
|
||||
|
||||
from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
|
||||
from ..config import config
|
||||
@@ -25,6 +25,28 @@ from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Canonical set of weight-file extensions stripped when normalizing model
|
||||
# names for matching (ModelScanner.find_matching_models and the recipe rematch
|
||||
# filename key share this set). It is the union of the LoRA scanner set
|
||||
# ({".safetensors"}) and the Checkpoint scanner set (ComfyUI's
|
||||
# supported_pt_extensions plus ".gguf") so type-blind lookups (lora +
|
||||
# checkpoint merged) cover every format either scanner indexes. ".safebin"
|
||||
# is deliberately absent — no scanner indexes it, so a recipe entry
|
||||
# "model.safebin" must not be bound to a local "model.safetensors".
|
||||
WEIGHT_FILE_EXTENSIONS = frozenset(
|
||||
{
|
||||
".safetensors",
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_excluded_dir(name: str) -> bool:
|
||||
"""Return True when a directory entry must be skipped during model walks.
|
||||
@@ -35,6 +57,16 @@ def _is_excluded_dir(name: str) -> bool:
|
||||
return name == PENDING_DELETE_DIR_NAME
|
||||
|
||||
|
||||
def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
"""Return True when any segment of a relative path is a hidden directory."""
|
||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||
|
||||
|
||||
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
|
||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
||||
|
||||
|
||||
def _is_pending_delete_path(path: str) -> bool:
|
||||
"""Return True when any path component is the pending-delete staging dir."""
|
||||
normalized = str(path).replace(os.sep, "/")
|
||||
@@ -104,6 +136,8 @@ class ModelScanner:
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self._cancel_requested = False # Flag for cancellation
|
||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -143,6 +177,7 @@ class ModelScanner:
|
||||
self._excluded_models = []
|
||||
self._is_initializing = False
|
||||
self._name_display_mode = self._resolve_name_display_mode()
|
||||
self.invalidate_all_folders_cache()
|
||||
self.bump_cache_version()
|
||||
|
||||
try:
|
||||
@@ -500,16 +535,21 @@ class ModelScanner:
|
||||
self._is_initializing = False
|
||||
|
||||
async def _load_persisted_cache(self, page_type: str) -> bool:
|
||||
"""Attempt to hydrate the in-memory cache from the SQLite snapshot."""
|
||||
"""Attempt to hydrate the in-memory cache from the SQLite snapshot.
|
||||
|
||||
The SQLite read and the per-model rebuild (entry adjustment, tag
|
||||
counting, validation/repair, hash index reconstruction) run in the
|
||||
default executor so the event loop stays responsive; only applying
|
||||
the result to shared cache state happens on the loop.
|
||||
"""
|
||||
if not getattr(self, '_persistent_cache', None):
|
||||
return False
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
persisted = await loop.run_in_executor(
|
||||
rebuilt = await loop.run_in_executor(
|
||||
None,
|
||||
self._persistent_cache.load_cache,
|
||||
self.model_type
|
||||
self._rebuild_persisted_cache
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
@@ -517,47 +557,14 @@ class ModelScanner:
|
||||
logger.debug("%s Scanner: Could not load persisted cache: %s", self.model_type.capitalize(), exc)
|
||||
return False
|
||||
|
||||
if not persisted or not persisted.raw_data:
|
||||
if rebuilt is None:
|
||||
return False
|
||||
|
||||
hash_index = ModelHashIndex()
|
||||
for sha_value, path in persisted.hash_rows:
|
||||
if sha_value and path:
|
||||
hash_index.add_entry(sha_value.lower(), path)
|
||||
|
||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||
# cover every known autov3 -> path mapping regardless of whether a
|
||||
# sha256 row also exists for the same file.
|
||||
for autov3_value, path in persisted.autov3_hash_rows:
|
||||
if autov3_value and path:
|
||||
hash_index.add_autov3(autov3_value.lower(), path)
|
||||
|
||||
tags_count: Dict[str, int] = {}
|
||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||
for item in persisted.raw_data:
|
||||
adjusted_item = self.adjust_cached_entry(dict(item))
|
||||
adjusted_raw_data.append(adjusted_item)
|
||||
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
# Validate cache entries and check health.
|
||||
# Always use the validated/repaired entries — even when there are no
|
||||
# invalid entries, auto_repair may have filled in missing optional
|
||||
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||
# working_entry. Without this unconditional replacement the repaired
|
||||
# copies are discarded and None values propagate to format_response.
|
||||
# See issue #730.
|
||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||
adjusted_raw_data, auto_repair=True
|
||||
)
|
||||
|
||||
# Always use the validated entries (repaired copies)
|
||||
adjusted_raw_data = valid_entries
|
||||
scan_result, invalid_entries = rebuilt
|
||||
|
||||
if invalid_entries:
|
||||
monitor = CacheHealthMonitor()
|
||||
report = monitor.check_health(adjusted_raw_data, auto_repair=True)
|
||||
report = monitor.check_health(scan_result.raw_data, auto_repair=True)
|
||||
|
||||
if report.status != CacheHealthStatus.HEALTHY:
|
||||
# Broadcast health warning to frontend
|
||||
@@ -567,31 +574,22 @@ class ModelScanner:
|
||||
f"{report.invalid_entries} invalid entries, {report.repaired_entries} repaired"
|
||||
)
|
||||
|
||||
# Use only valid entries
|
||||
adjusted_raw_data = valid_entries
|
||||
|
||||
# Rebuild tags count from valid entries only
|
||||
tags_count = {}
|
||||
for item in adjusted_raw_data:
|
||||
for item in scan_result.raw_data:
|
||||
for tag in item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
scan_result.tags_count = tags_count
|
||||
|
||||
# Remove invalid entries from hash index
|
||||
for invalid_entry in invalid_entries:
|
||||
file_path = CacheEntryValidator.get_file_path_safe(invalid_entry)
|
||||
sha256 = CacheEntryValidator.get_sha256_safe(invalid_entry)
|
||||
if file_path:
|
||||
hash_index.remove_by_path(file_path, sha256)
|
||||
|
||||
scan_result = CacheBuildResult(
|
||||
raw_data=adjusted_raw_data,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
)
|
||||
scan_result.hash_index.remove_by_path(file_path, sha256)
|
||||
|
||||
await self._apply_scan_result(scan_result)
|
||||
await self._sync_download_history(adjusted_raw_data, source='scan')
|
||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||
|
||||
await ws_manager.broadcast_init_progress({
|
||||
'stage': 'loading_cache',
|
||||
@@ -616,6 +614,63 @@ class ModelScanner:
|
||||
|
||||
return True
|
||||
|
||||
def _rebuild_persisted_cache(self) -> Optional[Tuple[CacheBuildResult, List[Dict[str, Any]]]]:
|
||||
"""Load the SQLite snapshot and rebuild a ready-to-apply scan result.
|
||||
|
||||
Runs entirely in a worker thread: it must not touch ``self._cache``,
|
||||
the websocket manager, or any asyncio primitives. Returns ``None``
|
||||
when no usable snapshot exists, otherwise a tuple of the scan result
|
||||
(built from validated/repaired entries) and the invalid entries.
|
||||
"""
|
||||
persisted = self._persistent_cache.load_cache(self.model_type)
|
||||
|
||||
if not persisted or not persisted.raw_data:
|
||||
return None
|
||||
|
||||
hash_index = ModelHashIndex()
|
||||
for sha_value, path in persisted.hash_rows:
|
||||
if sha_value and path:
|
||||
hash_index.add_entry(sha_value.lower(), path)
|
||||
|
||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||
# cover every known autov3 -> path mapping regardless of whether a
|
||||
# sha256 row also exists for the same file.
|
||||
for autov3_value, path in persisted.autov3_hash_rows:
|
||||
if autov3_value and path:
|
||||
hash_index.add_autov3(autov3_value.lower(), path)
|
||||
|
||||
tags_count: Dict[str, int] = {}
|
||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||
for item in persisted.raw_data:
|
||||
# load_cache builds a fresh dict per row, and validate_batch below
|
||||
# works on its own per-entry copy when auto_repair=True, so no
|
||||
# additional dict copy is needed here.
|
||||
adjusted_item = self.adjust_cached_entry(item)
|
||||
adjusted_raw_data.append(adjusted_item)
|
||||
|
||||
for tag in adjusted_item.get('tags') or []:
|
||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||
|
||||
# Validate cache entries and check health.
|
||||
# Always use the validated/repaired entries — even when there are no
|
||||
# invalid entries, auto_repair may have filled in missing optional
|
||||
# fields (model_name, file_name, folder) with safe defaults on a copied
|
||||
# working_entry. Without this unconditional replacement the repaired
|
||||
# copies are discarded and None values propagate to format_response.
|
||||
# See issue #730.
|
||||
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
|
||||
adjusted_raw_data, auto_repair=True
|
||||
)
|
||||
|
||||
# Always use the validated entries (repaired copies)
|
||||
scan_result = CacheBuildResult(
|
||||
raw_data=valid_entries,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
)
|
||||
return scan_result, invalid_entries
|
||||
|
||||
async def _run_autov3_backfill(self) -> None:
|
||||
"""Backfill autov3 for entries loaded from the persisted cache that lack it."""
|
||||
try:
|
||||
@@ -875,12 +930,12 @@ class ModelScanner:
|
||||
new_files = []
|
||||
visited_real_paths = set()
|
||||
discovered_real_files = set()
|
||||
|
||||
|
||||
# Scan all model roots
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
|
||||
# Recursively scan directory
|
||||
for root, dirnames, files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
@@ -888,7 +943,7 @@ class ModelScanner:
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
@@ -933,7 +988,7 @@ class ModelScanner:
|
||||
if self.is_cancelled():
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
|
||||
return
|
||||
|
||||
|
||||
# Process new files in batches
|
||||
total_added = 0
|
||||
if new_files:
|
||||
@@ -1092,6 +1147,56 @@ class ModelScanner:
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get model root directories"""
|
||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||
|
||||
async def get_all_folders(self) -> List[str]:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Unlike the models-only ``cache.folders``, this includes empty
|
||||
directories, so it stays accurate even when the in-memory cache was
|
||||
hydrated from a persisted snapshot without a filesystem walk. Hidden
|
||||
directories (any segment starting with '.') and the pending-delete
|
||||
staging dir are excluded. The result is unioned with the model-derived
|
||||
folders so it is always a superset of ``cache.folders``, and cached
|
||||
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
|
||||
"""
|
||||
now = time.monotonic()
|
||||
if self._all_folders_ttl_cache is not None:
|
||||
cached_at, cached_folders = self._all_folders_ttl_cache
|
||||
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
|
||||
return cached_folders
|
||||
|
||||
discovered: Set[str] = set()
|
||||
visited_real_paths: Set[str] = set()
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for root, dirnames, _files in os.walk(root_path, followlinks=True):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
# realpath is used only for symlink dedup, never for the
|
||||
# recorded path (business paths stay unresolved).
|
||||
real_root = os.path.realpath(root)
|
||||
if real_root in visited_real_paths:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
rel_dir = os.path.relpath(os.path.abspath(root), os.path.abspath(root_path))
|
||||
rel_dir = rel_dir.replace(os.path.sep, "/")
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered.add(rel_dir)
|
||||
|
||||
folders = set(discovered)
|
||||
if self._cache is not None:
|
||||
folders |= {item.get('folder', '') for item in self._cache.raw_data}
|
||||
|
||||
result = sorted(folders, key=lambda x: x.lower())
|
||||
self._all_folders_ttl_cache = (now, result)
|
||||
return result
|
||||
|
||||
def invalidate_all_folders_cache(self) -> None:
|
||||
"""Drop the cached get_all_folders() result (e.g. after a move)."""
|
||||
self._all_folders_ttl_cache = None
|
||||
|
||||
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||
"""Get model file info and metadata (extensible for different model types)"""
|
||||
@@ -1307,8 +1412,8 @@ class ModelScanner:
|
||||
else:
|
||||
self._cache.raw_data = list(scan_result.raw_data)
|
||||
|
||||
self._cache.rebuild_version_index()
|
||||
|
||||
# resort() rebuilds folders and the version index on every path, so a
|
||||
# separate rebuild_version_index() call here would be redundant.
|
||||
await self._cache.resort()
|
||||
|
||||
self._log_duplicate_filename_summary()
|
||||
@@ -1751,6 +1856,10 @@ class ModelScanner:
|
||||
|
||||
await cache.resort()
|
||||
|
||||
# A move may have created new directories; drop the cached live-walk
|
||||
# result so the next include_empty request sees them.
|
||||
self.invalidate_all_folders_cache()
|
||||
|
||||
if cache_modified:
|
||||
await self._persist_current_cache()
|
||||
self.bump_cache_version()
|
||||
@@ -2140,8 +2249,98 @@ class ModelScanner:
|
||||
return sorted_models
|
||||
return sorted_models[:limit]
|
||||
|
||||
async def get_model_info_by_name(self, name):
|
||||
"""Get model information by name"""
|
||||
@staticmethod
|
||||
def find_matching_models(
|
||||
raw_data: List[Dict[str, Any]],
|
||||
name: str,
|
||||
*,
|
||||
base_model: Optional[str] = None,
|
||||
extensions: Optional[Set[str]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return all cached models matching ``name`` (case-insensitive).
|
||||
|
||||
A name containing a path separator must equal the model's
|
||||
folder-relative path; a bare name matches on basename. When
|
||||
``base_model`` is given, confident mismatches are rejected while
|
||||
unknowns on either side stay eligible (lenient guard).
|
||||
``extensions`` should be the scanner's own ``file_extensions`` so
|
||||
suffix stripping only covers formats the scanner actually indexes;
|
||||
when omitted, the shared :data:`WEIGHT_FILE_EXTENSIONS` set is used.
|
||||
"""
|
||||
# Longest first so overlapping suffixes strip correctly.
|
||||
exts = sorted(extensions or WEIGHT_FILE_EXTENSIONS, key=len, reverse=True)
|
||||
|
||||
normalized_name = str(name).replace("\\", "/").casefold()
|
||||
for ext in exts:
|
||||
if normalized_name.endswith(ext):
|
||||
normalized_name = normalized_name[: -len(ext)]
|
||||
break
|
||||
has_path = "/" in normalized_name
|
||||
basename = normalized_name.rsplit("/", 1)[-1]
|
||||
|
||||
matches = []
|
||||
for model in raw_data:
|
||||
file_name = str(model.get("file_name") or "").replace("\\", "/")
|
||||
folder = str(model.get("folder") or "").replace("\\", "/").strip("/")
|
||||
model_path = f"{folder}/{file_name}" if folder else file_name
|
||||
for ext in exts:
|
||||
if model_path.casefold().endswith(ext):
|
||||
model_path = model_path[: -len(ext)]
|
||||
break
|
||||
if (has_path and model_path.casefold() == normalized_name) or (
|
||||
not has_path and model_path.rsplit("/", 1)[-1].casefold() == basename
|
||||
):
|
||||
matches.append(model)
|
||||
|
||||
expected_base = str(base_model or "").strip().casefold()
|
||||
if expected_base and expected_base != "unknown":
|
||||
matches = [
|
||||
model
|
||||
for model in matches
|
||||
if str(model.get("base_model") or "").strip().casefold()
|
||||
in ("", "unknown", expected_base)
|
||||
]
|
||||
return matches
|
||||
|
||||
async def find_models_by_name(
|
||||
self, name: str, *, base_model: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return every cached model matching ``name`` (see ``find_matching_models``)."""
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
return self.find_matching_models(
|
||||
cache.raw_data,
|
||||
name,
|
||||
base_model=base_model,
|
||||
extensions=self.file_extensions,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding models by name: {e}", exc_info=True)
|
||||
return []
|
||||
|
||||
async def get_model_info_by_name(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
require_unique: bool = False,
|
||||
base_model: Optional[str] = None,
|
||||
):
|
||||
"""Get model information by name.
|
||||
|
||||
Default mode keeps the legacy first-match/fallback semantics. With
|
||||
``require_unique`` an ambiguous name is a miss, and ``base_model``
|
||||
rejects confident base-model mismatches (unknowns stay eligible).
|
||||
"""
|
||||
if require_unique or base_model:
|
||||
try:
|
||||
matches = await self.find_models_by_name(name, base_model=base_model)
|
||||
if require_unique and len(matches) != 1:
|
||||
return None
|
||||
return matches[0] if matches else None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model info by name: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
@@ -2302,8 +2501,8 @@ class ModelScanner:
|
||||
})
|
||||
|
||||
# Merge every staged per-file batch into ONE undoable batch. On a
|
||||
# merge failure (cross-volume EXDEV etc.) the response falls back
|
||||
# to the constituent batch_ids array so the frontend can undo them
|
||||
# merge failure (defensive) the response falls back to the
|
||||
# constituent batch_ids array so the frontend can undo them
|
||||
# sequentially.
|
||||
batch_field: Dict[str, Any] = {}
|
||||
if batch_ids:
|
||||
@@ -2446,6 +2645,39 @@ class ModelScanner:
|
||||
logger.error(f"Error checking model version existence: {e}")
|
||||
return False
|
||||
|
||||
async def get_files_for_version(self, model_version_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all local file entries for a specific model version (#1058).
|
||||
|
||||
A Civitai model version can have several weight files downloaded;
|
||||
unlike the single-valued version_index this returns every entry.
|
||||
|
||||
Args:
|
||||
model_version_id: Civitai model version ID
|
||||
|
||||
Returns:
|
||||
List[Dict]: Cache entries (may be empty)
|
||||
"""
|
||||
try:
|
||||
normalized_id = int(model_version_id)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
try:
|
||||
cache = await self.get_cached_data()
|
||||
if not cache:
|
||||
return []
|
||||
|
||||
getter = getattr(cache, "get_files_by_version_id", None)
|
||||
if getter is not None:
|
||||
return getter(normalized_id)
|
||||
|
||||
# Fallback for cache implementations without the multi-file index
|
||||
entry = cache.version_index.get(normalized_id)
|
||||
return [entry] if entry is not None else []
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting files for model version: {e}")
|
||||
return []
|
||||
|
||||
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
|
||||
"""Get all versions of a model by its ID
|
||||
|
||||
|
||||
@@ -13,11 +13,12 @@ import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence
|
||||
|
||||
from .errors import RateLimitError, ResourceNotFoundError
|
||||
from .settings_manager import get_settings_manager
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
||||
from ..utils.civitai_utils import rewrite_preview_url
|
||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||
|
||||
@@ -77,6 +78,10 @@ class ModelVersionRecord:
|
||||
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||
paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO
|
||||
is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate)
|
||||
# Number of downloadable weight files for the version (None when unknown,
|
||||
# e.g. records persisted before this field existed or locally-synthesized
|
||||
# entries). Mirrors the frontend isModelWeightFile() filter.
|
||||
file_count: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -245,6 +250,51 @@ class ModelUpdateRecord:
|
||||
|
||||
return False
|
||||
|
||||
def has_update_for_local_bases(
|
||||
self,
|
||||
hide_early_access: bool = False,
|
||||
hide_non_downloadable: bool = True,
|
||||
hide_paid: bool = False,
|
||||
) -> bool:
|
||||
"""Return True when any locally-held base model scope has an update.
|
||||
|
||||
Aggregates :meth:`has_update_for_base` across every distinct base model
|
||||
present among in-library versions. This mirrors the per-item evaluation
|
||||
performed by ``BaseModelService._annotate_update_flags`` when the
|
||||
``version_grouping`` setting is ``same_base``, so callers reporting
|
||||
"how many models have updates" stay aligned with what the Updates
|
||||
filter displays. Use this instead of :meth:`has_update` for such
|
||||
summaries; see issue #1083.
|
||||
|
||||
When no local base model is known (nothing held locally, or versions
|
||||
never seen in any remote listing), falls back to :meth:`has_update` so
|
||||
a model the item-level filter may still flag is not silently dropped
|
||||
from summaries.
|
||||
"""
|
||||
|
||||
bases = {
|
||||
_normalize_base_model(version.base_model)
|
||||
for version in self.versions
|
||||
if version.is_in_library
|
||||
}
|
||||
bases.discard(None)
|
||||
if not bases:
|
||||
return self.has_update(
|
||||
hide_early_access=hide_early_access,
|
||||
hide_non_downloadable=hide_non_downloadable,
|
||||
hide_paid=hide_paid,
|
||||
)
|
||||
return any(
|
||||
self.has_update_for_base(
|
||||
None,
|
||||
base,
|
||||
hide_early_access=hide_early_access,
|
||||
hide_non_downloadable=hide_non_downloadable,
|
||||
hide_paid=hide_paid,
|
||||
)
|
||||
for base in bases
|
||||
)
|
||||
|
||||
|
||||
class ModelUpdateService:
|
||||
"""Persist and query remote model version metadata."""
|
||||
@@ -273,6 +323,7 @@ class ModelUpdateService:
|
||||
usage_control TEXT,
|
||||
paid_access TEXT,
|
||||
is_paid INTEGER NOT NULL DEFAULT 0,
|
||||
file_count INTEGER,
|
||||
PRIMARY KEY (model_id, version_id),
|
||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -520,6 +571,10 @@ class ModelUpdateService:
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0"
|
||||
),
|
||||
"file_count": (
|
||||
"ALTER TABLE model_update_versions "
|
||||
"ADD COLUMN file_count INTEGER"
|
||||
),
|
||||
}
|
||||
|
||||
for column, statement in migrations.items():
|
||||
@@ -623,6 +678,7 @@ class ModelUpdateService:
|
||||
is_early_access INTEGER NOT NULL DEFAULT 0,
|
||||
paid_access TEXT,
|
||||
is_paid INTEGER NOT NULL DEFAULT 0,
|
||||
file_count INTEGER,
|
||||
PRIMARY KEY (model_id, version_id),
|
||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||
)
|
||||
@@ -644,6 +700,7 @@ class ModelUpdateService:
|
||||
"is_early_access",
|
||||
"paid_access",
|
||||
"is_paid",
|
||||
"file_count",
|
||||
]
|
||||
defaults = {
|
||||
"sort_index": "0",
|
||||
@@ -658,6 +715,7 @@ class ModelUpdateService:
|
||||
"is_early_access": "0",
|
||||
"paid_access": "NULL",
|
||||
"is_paid": "0",
|
||||
"file_count": "NULL",
|
||||
}
|
||||
|
||||
select_parts = []
|
||||
@@ -773,6 +831,11 @@ class ModelUpdateService:
|
||||
target_model_ids=target_filter,
|
||||
)
|
||||
|
||||
local_base_models = await self._collect_local_version_bases(
|
||||
scanner,
|
||||
target_model_ids=target_filter,
|
||||
)
|
||||
|
||||
results: Dict[int, ModelUpdateRecord] = {}
|
||||
prefetched: Dict[int, Mapping[Any, Any]] = {}
|
||||
|
||||
@@ -825,6 +888,7 @@ class ModelUpdateService:
|
||||
force_refresh=force_refresh,
|
||||
prefetched_response=prefetched.get(model_id),
|
||||
all_local_version_ids=all_vids,
|
||||
local_base_models=local_base_models,
|
||||
)
|
||||
if scanner.is_cancelled():
|
||||
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
|
||||
@@ -859,12 +923,14 @@ class ModelUpdateService:
|
||||
|
||||
local_versions = await self._collect_local_versions(scanner)
|
||||
version_ids = local_versions.get(model_id, [])
|
||||
local_base_models = await self._collect_local_version_bases(scanner)
|
||||
return await self._refresh_single_model(
|
||||
model_type,
|
||||
model_id,
|
||||
version_ids,
|
||||
metadata_provider,
|
||||
force_refresh=force_refresh,
|
||||
local_base_models=local_base_models,
|
||||
)
|
||||
|
||||
async def update_in_library_versions(
|
||||
@@ -1040,6 +1106,7 @@ class ModelUpdateService:
|
||||
force_refresh: bool = False,
|
||||
prefetched_response: Optional[Mapping[str, Any]] = None,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
local_base_models: Optional[Mapping[int, str]] = None,
|
||||
) -> Optional[ModelUpdateRecord]:
|
||||
normalized_local = self._normalize_sequence(local_versions)
|
||||
# When folder-filtering, this carries the cross-folder version set
|
||||
@@ -1164,6 +1231,7 @@ class ModelUpdateService:
|
||||
existing,
|
||||
now,
|
||||
all_local_version_ids=normalized_all,
|
||||
local_base_models=local_base_models,
|
||||
)
|
||||
else:
|
||||
record = self._merge_with_local_versions(
|
||||
@@ -1370,27 +1438,17 @@ class ModelUpdateService:
|
||||
await self._enrich_version_entries(metadata_provider, aggregated)
|
||||
return aggregated
|
||||
|
||||
async def _collect_local_versions(
|
||||
self,
|
||||
scanner,
|
||||
@staticmethod
|
||||
def _iter_local_civitai_items(
|
||||
cache,
|
||||
*,
|
||||
target_model_ids: Optional[Sequence[int]] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
) -> Dict[int, List[int]]:
|
||||
cache = await scanner.get_cached_data()
|
||||
mapping: Dict[int, set[int]] = {}
|
||||
target_set: Optional[set[int]] = None,
|
||||
normalized_folder: Optional[str] = None,
|
||||
) -> Iterator[tuple[int, int, Any]]:
|
||||
"""Yield ``(modelId, versionId, base_model)`` for each scannable item."""
|
||||
|
||||
if not cache or not getattr(cache, "raw_data", None):
|
||||
return {}
|
||||
|
||||
target_set = None
|
||||
if target_model_ids:
|
||||
target_set = set(target_model_ids)
|
||||
if not target_set:
|
||||
return {}
|
||||
|
||||
normalized_folder = None
|
||||
if folder_path is not None:
|
||||
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||
return
|
||||
|
||||
for item in cache.raw_data:
|
||||
# Apply folder filter first (cheapest check)
|
||||
@@ -1410,10 +1468,75 @@ class ModelUpdateService:
|
||||
continue
|
||||
if target_set is not None and model_id not in target_set:
|
||||
continue
|
||||
yield model_id, version_id, item.get("base_model")
|
||||
|
||||
def _prepare_collection_filters(
|
||||
self,
|
||||
target_model_ids: Optional[Sequence[int]],
|
||||
folder_path: Optional[str],
|
||||
) -> tuple[Optional[set[int]], Optional[str]]:
|
||||
target_set: Optional[set[int]] = None
|
||||
if target_model_ids:
|
||||
target_set = set(target_model_ids)
|
||||
|
||||
normalized_folder = None
|
||||
if folder_path is not None:
|
||||
normalized_folder = folder_path.replace("\\", "/").strip("/")
|
||||
return target_set, normalized_folder
|
||||
|
||||
async def _collect_local_versions(
|
||||
self,
|
||||
scanner,
|
||||
*,
|
||||
target_model_ids: Optional[Sequence[int]] = None,
|
||||
folder_path: Optional[str] = None,
|
||||
) -> Dict[int, List[int]]:
|
||||
cache = await scanner.get_cached_data()
|
||||
mapping: Dict[int, set[int]] = {}
|
||||
target_set, normalized_folder = self._prepare_collection_filters(
|
||||
target_model_ids, folder_path
|
||||
)
|
||||
|
||||
if target_model_ids and not target_set:
|
||||
return {}
|
||||
|
||||
for model_id, version_id, _base_model in self._iter_local_civitai_items(
|
||||
cache, target_set=target_set, normalized_folder=normalized_folder
|
||||
):
|
||||
mapping.setdefault(model_id, set()).add(version_id)
|
||||
|
||||
return {model_id: sorted(ids) for model_id, ids in mapping.items()}
|
||||
|
||||
async def _collect_local_version_bases(
|
||||
self,
|
||||
scanner,
|
||||
*,
|
||||
target_model_ids: Optional[Sequence[int]] = None,
|
||||
) -> Dict[int, str]:
|
||||
"""Map version id -> base model from cache items.
|
||||
|
||||
Deliberately unfiltered by folder: synthesized in-library entries must
|
||||
carry a base regardless of which folder triggered the refresh.
|
||||
"""
|
||||
|
||||
cache = await scanner.get_cached_data()
|
||||
bases: Dict[int, str] = {}
|
||||
target_set, _normalized_folder = self._prepare_collection_filters(
|
||||
target_model_ids, None
|
||||
)
|
||||
|
||||
if target_model_ids and not target_set:
|
||||
return {}
|
||||
|
||||
for _model_id, version_id, base_model in self._iter_local_civitai_items(
|
||||
cache, target_set=target_set
|
||||
):
|
||||
normalized_base = _normalize_string(base_model)
|
||||
if normalized_base:
|
||||
bases[version_id] = normalized_base
|
||||
|
||||
return bases
|
||||
|
||||
def _merge_with_local_versions(
|
||||
self,
|
||||
existing: Optional[ModelUpdateRecord],
|
||||
@@ -1493,6 +1616,7 @@ class ModelUpdateService:
|
||||
timestamp: float,
|
||||
*,
|
||||
all_local_version_ids: Optional[Sequence[int]] = None,
|
||||
local_base_models: Optional[Mapping[int, str]] = None,
|
||||
) -> ModelUpdateRecord:
|
||||
local_set = set(local_versions)
|
||||
# When folder-filtering, also consider versions in other folders
|
||||
@@ -1504,6 +1628,7 @@ class ModelUpdateService:
|
||||
)
|
||||
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
|
||||
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
|
||||
file_count_map = {version.version_id: version.file_count for version in existing.versions} if existing else {}
|
||||
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
|
||||
existing_map = {version.version_id: version for version in existing.versions} if existing else {}
|
||||
|
||||
@@ -1528,11 +1653,17 @@ class ModelUpdateService:
|
||||
usage_control=remote_version.usage_control,
|
||||
paid_access=remote_version.paid_access,
|
||||
is_paid=remote_version.is_paid,
|
||||
file_count=(
|
||||
remote_version.file_count
|
||||
if remote_version.file_count is not None
|
||||
else file_count_map.get(version_id)
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
missing_local = local_set - seen_ids
|
||||
if missing_local:
|
||||
item_base_models = local_base_models or {}
|
||||
for version_id in sorted(missing_local):
|
||||
existing_version = existing_map.get(version_id)
|
||||
if existing_version:
|
||||
@@ -1547,7 +1678,7 @@ class ModelUpdateService:
|
||||
ModelVersionRecord(
|
||||
version_id=version_id,
|
||||
name=None,
|
||||
base_model=None,
|
||||
base_model=item_base_models.get(version_id),
|
||||
released_at=None,
|
||||
size_bytes=None,
|
||||
preview_url=None,
|
||||
@@ -1620,6 +1751,7 @@ class ModelUpdateService:
|
||||
base_model = _normalize_string(entry.get("baseModel"))
|
||||
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
|
||||
size_bytes = self._extract_size_bytes(entry.get("files"))
|
||||
file_count = self._extract_file_count(entry.get("files"))
|
||||
preview_url = self._extract_preview_url(entry.get("images"))
|
||||
early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt"))
|
||||
|
||||
@@ -1655,6 +1787,7 @@ class ModelUpdateService:
|
||||
usage_control=usage_control,
|
||||
paid_access=paid_access_json,
|
||||
is_paid=is_paid,
|
||||
file_count=file_count,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -1683,6 +1816,25 @@ class ModelUpdateService:
|
||||
return None
|
||||
return {"permanent": permanent, "endsAt": ends_at}
|
||||
|
||||
@staticmethod
|
||||
def _extract_file_count(files) -> Optional[int]:
|
||||
"""Count downloadable weight files in a version entry's ``files`` list.
|
||||
|
||||
Returns None when the payload carries no files array (unknown), so
|
||||
callers can distinguish "no weight files" from "no data".
|
||||
"""
|
||||
|
||||
if not isinstance(files, list):
|
||||
return None
|
||||
count = 0
|
||||
for entry in files:
|
||||
if not isinstance(entry, Mapping):
|
||||
continue
|
||||
entry_type = entry.get("type")
|
||||
if isinstance(entry_type, str) and entry_type in MODEL_WEIGHT_FILE_TYPES:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||
if not isinstance(files, Iterable):
|
||||
return None
|
||||
@@ -1795,7 +1947,7 @@ class ModelUpdateService:
|
||||
f"""
|
||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||
is_early_access, usage_control, paid_access, is_paid
|
||||
is_early_access, usage_control, paid_access, is_paid, file_count
|
||||
FROM model_update_versions
|
||||
WHERE model_id IN ({placeholders})
|
||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||
@@ -1826,6 +1978,7 @@ class ModelUpdateService:
|
||||
usage_control=row["usage_control"],
|
||||
paid_access=row["paid_access"],
|
||||
is_paid=bool(row["is_paid"]),
|
||||
file_count=_normalize_int(row["file_count"]),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1888,8 +2041,8 @@ class ModelUpdateService:
|
||||
INSERT INTO model_update_versions (
|
||||
version_id, model_id, sort_index, name, base_model, released_at,
|
||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||
is_early_access, usage_control, paid_access, is_paid
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
is_early_access, usage_control, paid_access, is_paid, file_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
version.version_id,
|
||||
@@ -1907,6 +2060,7 @@ class ModelUpdateService:
|
||||
version.usage_control,
|
||||
paid_access_value,
|
||||
1 if version.is_paid else 0,
|
||||
version.file_count,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -274,17 +274,21 @@ class PendingDeleteService:
|
||||
async def merge_batches(self, batch_ids: Sequence[str]) -> Optional[str]:
|
||||
"""Merge several batches into the first batch's manifest.
|
||||
|
||||
Winner is ``batch_ids[0]``. The staged files of losing batches are
|
||||
MOVED (os.rename) into the winner's batch dir and their ``staged``
|
||||
paths rewritten in the merged manifest BEFORE any loser dir is
|
||||
removed. ``expires_at`` is re-anchored to ``now + TTL`` at merge time
|
||||
and a FRESH purge timer is armed for the winner.
|
||||
Winner is ``batch_ids[0]``. Merging is MANIFEST-ONLY: staged files
|
||||
are NEVER moved, so the merge is a pure metadata operation with zero
|
||||
data IO and is inherently cross-volume safe (no EXDEV, no rollback).
|
||||
Every loser's entries are appended to the winner's manifest with
|
||||
their ``staged`` paths unchanged (files keep living in the loser's
|
||||
own batch dir - the sibling-of-model staging location), each loser
|
||||
dir is recorded in the winner manifest's ``merged_sources``, and each
|
||||
loser manifest is stamped ``merged_into`` so its own purge timer, a
|
||||
post-restart sweep or a direct undo call no-op. ``expires_at`` is
|
||||
re-anchored to ``now + TTL`` at merge time and a FRESH purge timer is
|
||||
armed for the winner.
|
||||
|
||||
On any move failure every already-moved file is moved BACK and the
|
||||
original batch dirs/manifests are left intact; ``None`` is returned so
|
||||
callers fall back to the ``batch_ids`` array contract. Cross-volume
|
||||
merges hit EXDEV here - expected and fine (the fallback is the normal
|
||||
path for those bulks).
|
||||
Returns the winner id, or ``None`` when the winner batch cannot be
|
||||
resolved (callers then fall back to the ``batch_ids`` array
|
||||
contract).
|
||||
"""
|
||||
if not batch_ids:
|
||||
return None
|
||||
@@ -298,77 +302,68 @@ class PendingDeleteService:
|
||||
if winner_manifest is None:
|
||||
return None
|
||||
|
||||
# Track (entry, original_staged_path, loser_dir) for rollback.
|
||||
moved: List[Tuple[Dict[str, Any], str, str]] = []
|
||||
processed_losers: List[Tuple[str, str]] = [] # (loser_id, loser_dir)
|
||||
|
||||
try:
|
||||
for loser_id in batch_ids[1:]:
|
||||
loser_dir = await self._find_batch_dir(loser_id)
|
||||
if not loser_dir or os.path.normpath(loser_dir) == os.path.normpath(
|
||||
winner_dir
|
||||
):
|
||||
# Build the merged manifest in memory: loser entries are appended
|
||||
# with their staged paths UNCHANGED - no file moves, no IO, no
|
||||
# EXDEV. Loser dirs remain as physical storage until the merged
|
||||
# batch is undone or purged.
|
||||
merged_sources: List[str] = []
|
||||
seen_loser_dirs: Set[str] = set()
|
||||
for loser_id in batch_ids[1:]:
|
||||
loser_dir = await self._find_batch_dir(loser_id)
|
||||
if not loser_dir or os.path.normpath(loser_dir) == os.path.normpath(
|
||||
winner_dir
|
||||
):
|
||||
continue
|
||||
loser_abs = os.path.abspath(loser_dir)
|
||||
if loser_abs in seen_loser_dirs:
|
||||
continue
|
||||
seen_loser_dirs.add(loser_abs)
|
||||
loser_manifest = self._read_manifest(loser_dir)
|
||||
if loser_manifest is None:
|
||||
# Corrupted loser: leave it for the sweep to quarantine.
|
||||
continue
|
||||
for entry in loser_manifest.get("entries") or []:
|
||||
if entry.get("restored"):
|
||||
continue
|
||||
loser_manifest = self._read_manifest(loser_dir)
|
||||
if loser_manifest is None:
|
||||
# Corrupted loser: leave it for the sweep to quarantine.
|
||||
staged_path = entry.get("staged")
|
||||
if not staged_path or not os.path.exists(staged_path):
|
||||
continue
|
||||
for entry in loser_manifest.get("entries") or []:
|
||||
if entry.get("restored"):
|
||||
continue
|
||||
staged_path = entry.get("staged")
|
||||
if not staged_path or not os.path.exists(staged_path):
|
||||
continue
|
||||
new_staged = os.path.join(
|
||||
winner_dir, os.path.basename(staged_path)
|
||||
)
|
||||
if os.path.exists(new_staged):
|
||||
# os.rename would silently overwrite the existing
|
||||
# staged file on POSIX - never drop a staged file.
|
||||
# Abort the merge so callers fall back to the
|
||||
# batch_ids array contract.
|
||||
raise OSError(
|
||||
f"Merge collision: {os.path.basename(staged_path)} "
|
||||
"already staged in winner batch"
|
||||
)
|
||||
os.rename(staged_path, new_staged)
|
||||
original_staged = entry["staged"]
|
||||
entry["staged"] = os.path.abspath(new_staged)
|
||||
winner_manifest["entries"].append(entry)
|
||||
moved.append((entry, original_staged, loser_dir))
|
||||
processed_losers.append((loser_id, loser_dir))
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Merge of %s failed after moving files: %s; rolling back",
|
||||
list(batch_ids),
|
||||
exc,
|
||||
)
|
||||
self._rollback_merge_moves(moved)
|
||||
return None
|
||||
winner_manifest["entries"].append(entry)
|
||||
merged_sources.append(loser_abs)
|
||||
|
||||
# Re-anchor expiry and persist the merged manifest atomically.
|
||||
# Re-anchor expiry and persist the merged manifest atomically - it
|
||||
# becomes the ONLY source of truth for every merged file, wherever
|
||||
# it physically lives.
|
||||
winner_manifest["expires_at"] = (
|
||||
int(time.time()) + PENDING_DELETE_TTL_SECONDS
|
||||
)
|
||||
if merged_sources:
|
||||
winner_manifest["merged_sources"] = merged_sources
|
||||
try:
|
||||
self._write_manifest_atomic(winner_dir, winner_manifest)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to write merged manifest for %s: %s; rolling back",
|
||||
"Failed to write merged manifest for %s: %s",
|
||||
winner_id,
|
||||
exc,
|
||||
)
|
||||
self._rollback_merge_moves(moved)
|
||||
return None
|
||||
|
||||
# All moves committed: remove loser dirs (must be empty by now)
|
||||
# and drop them from the registry. Skipped losers (missing /
|
||||
# corrupted / same-dir) stay registered so the sweep still
|
||||
# quarantines them, exactly as before the registry existed.
|
||||
for loser_id, loser_dir in processed_losers:
|
||||
self._remove_manifest(loser_dir)
|
||||
self._remove_empty_dir(loser_dir)
|
||||
await self._forget_batch(loser_id)
|
||||
# Stamp each loser manifest so its own purge timer / a later sweep
|
||||
# / a direct undo call no-op: the winner owns those files from
|
||||
# here on. Best-effort coordination; a failed stamp only risks the
|
||||
# loser being swept at its own (earlier) expiry after a restart.
|
||||
for loser_dir in merged_sources:
|
||||
try:
|
||||
self._mark_merged(loser_dir, winner_id)
|
||||
except OSError as exc: # pragma: no cover - best-effort
|
||||
logger.warning(
|
||||
"Failed to mark merged loser %s: %s", loser_dir, exc
|
||||
)
|
||||
|
||||
# Losers are no longer independently managed.
|
||||
for loser_dir in merged_sources:
|
||||
await self._forget_batch(os.path.basename(loser_dir))
|
||||
await self._remember_batch(winner_id, winner_dir)
|
||||
|
||||
# Arm a fresh purge timer for the winner with the re-anchored
|
||||
@@ -397,6 +392,16 @@ class PendingDeleteService:
|
||||
if manifest is None:
|
||||
raise ValueError(f"Manifest missing for batch {batch_id}")
|
||||
|
||||
merged_into = manifest.get("merged_into")
|
||||
if merged_into:
|
||||
# The batch was merged into another batch: its staged files
|
||||
# are owned by the winner's manifest. Undo via the winner so
|
||||
# the whole merged batch stays consistent.
|
||||
raise ValueError(
|
||||
f"Batch {batch_id} was merged into batch {merged_into}; "
|
||||
"undo that batch instead"
|
||||
)
|
||||
|
||||
if manifest.get("state") == "restored":
|
||||
return self._undo_result(manifest)
|
||||
|
||||
@@ -448,6 +453,10 @@ class PendingDeleteService:
|
||||
self._remove_manifest(batch_dir)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
await self._forget_batch(batch_id)
|
||||
# Clean up merged loser dirs (their staged files were restored
|
||||
# above) and drop them from the registry too.
|
||||
for loser_id in self._remove_merged_batch_dirs(manifest):
|
||||
await self._forget_batch(loser_id)
|
||||
|
||||
logger.info("Restored pending-delete batch %s", batch_id)
|
||||
return self._undo_result(manifest)
|
||||
@@ -500,10 +509,36 @@ class PendingDeleteService:
|
||||
QUARANTINE them (preserving the pre-registry sweep semantics). The
|
||||
walk only descends into dirs literally named ``.lm-pending-delete``,
|
||||
so false positives are structurally limited.
|
||||
|
||||
The filesystem walk itself runs in a worker thread so a large or slow
|
||||
library cannot block the event loop at startup; only the (rare) batch
|
||||
registration awaits run on the loop.
|
||||
"""
|
||||
roots = await self._get_all_model_roots()
|
||||
loop = asyncio.get_event_loop()
|
||||
staging_parents = await loop.run_in_executor(
|
||||
None, # Use default thread pool
|
||||
self._collect_staging_parents, # Run the tree walk off the loop
|
||||
roots,
|
||||
)
|
||||
for staging_parent in staging_parents:
|
||||
await self._register_batch_candidates(staging_parent)
|
||||
|
||||
def _collect_staging_parents(self, roots: Sequence[str]) -> List[str]:
|
||||
"""Walk every model root and return its staging-parent dirs.
|
||||
|
||||
Pure synchronous filesystem discovery with no awaits: walks with
|
||||
``followlinks=True, topdown=True``, prunes symlink cycles via a
|
||||
per-root ``visited`` realpath set (realpath is used ONLY for this
|
||||
dedup set - the returned paths are the unresolved business paths),
|
||||
filters out :func:`_is_excluded_dir` dirs, and collects every dir
|
||||
named ``.lm-pending-delete`` (including the case where a model root
|
||||
itself is one). Results are returned in walk order.
|
||||
"""
|
||||
from .model_scanner import _is_excluded_dir
|
||||
|
||||
for root in await self._get_all_model_roots():
|
||||
staging_parents: List[str] = []
|
||||
for root in roots:
|
||||
if not os.path.isdir(root):
|
||||
continue
|
||||
visited: Set[str] = set()
|
||||
@@ -518,21 +553,20 @@ class PendingDeleteService:
|
||||
visited.add(real_dir)
|
||||
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
|
||||
# The current dir IS a staging parent (reachable only when
|
||||
# a model root itself is one): register its batches.
|
||||
await self._register_batch_candidates(dirpath)
|
||||
# a model root itself is one): collect its batches.
|
||||
staging_parents.append(dirpath)
|
||||
dirnames[:] = []
|
||||
continue
|
||||
next_dirs: List[str] = []
|
||||
for name in dirnames:
|
||||
if name == PENDING_DELETE_DIR_NAME:
|
||||
await self._register_batch_candidates(
|
||||
os.path.join(dirpath, name)
|
||||
)
|
||||
staging_parents.append(os.path.join(dirpath, name))
|
||||
elif _is_excluded_dir(name):
|
||||
continue
|
||||
else:
|
||||
next_dirs.append(name)
|
||||
dirnames[:] = next_dirs
|
||||
return staging_parents
|
||||
|
||||
async def _register_batch_candidates(self, staging_parent: str) -> None:
|
||||
"""Register every non-orphaned batch subdir of a staging parent."""
|
||||
@@ -754,25 +788,35 @@ class PendingDeleteService:
|
||||
"Failed to remove staged copy %s: %s", staged_path, exc
|
||||
)
|
||||
|
||||
def _rollback_merge_moves(
|
||||
self, moved: Sequence[Tuple[Dict[str, Any], str, str]]
|
||||
) -> None:
|
||||
"""Move already-merged files back to their original loser batch dirs."""
|
||||
for _entry, original_staged, _loser_dir in reversed(list(moved)):
|
||||
current = _entry.get("staged")
|
||||
if not current or not original_staged:
|
||||
def _mark_merged(self, loser_dir: str, winner_id: str) -> None:
|
||||
"""Stamp ``merged_into`` on a loser manifest (best-effort).
|
||||
|
||||
The stamp makes the loser's own purge timer, post-restart sweeps and
|
||||
direct undo calls no-op, so the winner's merged batch stays the only
|
||||
owner of the loser's staged files until it is undone or purged.
|
||||
"""
|
||||
loser_manifest = self._read_manifest(loser_dir)
|
||||
if loser_manifest is None:
|
||||
return
|
||||
loser_manifest["merged_into"] = winner_id
|
||||
self._write_manifest_atomic(loser_dir, loser_manifest)
|
||||
|
||||
def _remove_merged_batch_dirs(self, manifest: Dict[str, Any]) -> List[str]:
|
||||
"""Remove merged loser batch dirs once their files were handled.
|
||||
|
||||
Called after a merged batch has been fully undone or purged: each
|
||||
loser manifest (stamped ``merged_into``) and its now-empty dir are
|
||||
removed so the sweep never quarantines an orphaned staging dir.
|
||||
Best-effort - returns the removed batch ids for registry cleanup.
|
||||
"""
|
||||
removed: List[str] = []
|
||||
for src in manifest.get("merged_sources") or []:
|
||||
if not isinstance(src, str) or not src:
|
||||
continue
|
||||
if not os.path.exists(current):
|
||||
continue
|
||||
try:
|
||||
os.rename(current, original_staged)
|
||||
except OSError as exc: # pragma: no cover - best-effort rollback
|
||||
logger.warning(
|
||||
"Failed to roll back merge move %s -> %s: %s",
|
||||
current,
|
||||
original_staged,
|
||||
exc,
|
||||
)
|
||||
self._remove_manifest(src)
|
||||
self._remove_empty_dir(src)
|
||||
removed.append(os.path.basename(src))
|
||||
return removed
|
||||
|
||||
def _purge_batch_dir(self, batch_dir: str) -> bool:
|
||||
"""Purge one batch dir. Returns True when the batch was purged/removed."""
|
||||
@@ -786,6 +830,13 @@ class PendingDeleteService:
|
||||
self._quarantine_batch_dir(batch_dir)
|
||||
return True
|
||||
|
||||
if manifest.get("merged_into"):
|
||||
# Merged into another batch: the winner owns these staged files.
|
||||
# The loser's own purge timer / post-restart sweep must not remove
|
||||
# them early (the winner re-anchored the merged expiry to give the
|
||||
# whole bulk one undo window).
|
||||
return False
|
||||
|
||||
if manifest.get("state") == "restored":
|
||||
return False
|
||||
|
||||
@@ -817,6 +868,7 @@ class PendingDeleteService:
|
||||
|
||||
self._remove_manifest(batch_dir)
|
||||
self._remove_empty_dir(batch_dir)
|
||||
self._remove_merged_batch_dirs(manifest)
|
||||
return True
|
||||
|
||||
def _quarantine_batch_dir(self, batch_dir: str) -> str:
|
||||
|
||||
@@ -58,6 +58,8 @@ class PersistentRecipeCache:
|
||||
"checkpoint_json",
|
||||
"gen_params_json",
|
||||
"tags_json",
|
||||
"has_workflow",
|
||||
"import_info_json",
|
||||
)
|
||||
_instances: Dict[str, "PersistentRecipeCache"] = {}
|
||||
_instance_lock = threading.Lock()
|
||||
@@ -332,6 +334,44 @@ class PersistentRecipeCache:
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist image_id_map: %s", exc)
|
||||
|
||||
def get_metadata_value(self, key: str) -> Optional[str]:
|
||||
"""Return a value from cache_metadata, or None if missing."""
|
||||
if not self.is_enabled() or not self._schema_initialized:
|
||||
return None
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM cache_metadata WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
return row["value"] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def set_metadata_value(self, key: str, value: str) -> None:
|
||||
"""Store a value in cache_metadata without rewriting the full cache."""
|
||||
if not self.is_enabled() or not self._schema_initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
with self._db_lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to persist cache metadata %s: %s", key, exc)
|
||||
|
||||
def get_indexed_recipe_ids(self) -> Set[str]:
|
||||
"""Return all recipe IDs in the cache.
|
||||
|
||||
@@ -407,7 +447,9 @@ class PersistentRecipeCache:
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
gen_params_json TEXT,
|
||||
tags_json TEXT
|
||||
tags_json TEXT,
|
||||
has_workflow INTEGER DEFAULT 0,
|
||||
import_info_json TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
|
||||
@@ -426,6 +468,20 @@ class PersistentRecipeCache:
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Migration: add has_workflow column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN has_workflow INTEGER DEFAULT 0"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
# Migration: add import_info_json column to existing databases
|
||||
try:
|
||||
conn.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN import_info_json TEXT"
|
||||
)
|
||||
except Exception:
|
||||
pass # column already exists
|
||||
conn.commit()
|
||||
self._schema_initialized = True
|
||||
except Exception as exc:
|
||||
@@ -457,6 +513,9 @@ class PersistentRecipeCache:
|
||||
tags = recipe.get("tags")
|
||||
tags_json = json.dumps(tags) if tags else None
|
||||
|
||||
import_info = recipe.get("import_info")
|
||||
import_info_json = json.dumps(import_info) if import_info else None
|
||||
|
||||
# Get file stats if json_path exists
|
||||
file_mtime = 0.0
|
||||
file_size = 0
|
||||
@@ -488,6 +547,8 @@ class PersistentRecipeCache:
|
||||
checkpoint_json,
|
||||
gen_params_json,
|
||||
tags_json,
|
||||
1 if recipe.get("has_workflow") else 0,
|
||||
import_info_json,
|
||||
)
|
||||
|
||||
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
|
||||
@@ -520,6 +581,13 @@ class PersistentRecipeCache:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
import_info = None
|
||||
if row["import_info_json"]:
|
||||
try:
|
||||
import_info = json.loads(row["import_info_json"])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
recipe = {
|
||||
"id": row["recipe_id"],
|
||||
"file_path": row["file_path"] or "",
|
||||
@@ -533,6 +601,7 @@ class PersistentRecipeCache:
|
||||
"favorite": bool(row["favorite"]),
|
||||
"repair_version": row["repair_version"] or 0,
|
||||
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
|
||||
"has_workflow": bool(row["has_workflow"]),
|
||||
"loras": loras,
|
||||
"gen_params": gen_params,
|
||||
}
|
||||
@@ -543,6 +612,9 @@ class PersistentRecipeCache:
|
||||
if checkpoint:
|
||||
recipe["checkpoint"] = checkpoint
|
||||
|
||||
if import_info:
|
||||
recipe["import_info"] = import_info
|
||||
|
||||
return recipe
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Process-wide, per-destination rate-limit gate for outbound API traffic.
|
||||
|
||||
Implements the pacing/gating layer designed in
|
||||
``docs/plans/issue-1085-rate-limit-design.md``:
|
||||
|
||||
- **Reactive gate**: a 429 response arms ``next_allowed_send`` from the
|
||||
vendor's ``Retry-After`` (or exponential backoff when the header is
|
||||
missing); subsequent requests to the same destination wait out the window.
|
||||
- **Preemptive pacing**: a minimum inter-request interval per destination
|
||||
spaces consecutive sends so bursts never form in the first place.
|
||||
- **Herd-free**: waiters are serialized through a per-destination lock, so
|
||||
each one claims a distinct send slot instead of thousands of coroutines
|
||||
waking up together.
|
||||
- **Bounded**: waits longer than ``rate_limit_max_wait_seconds`` are refused
|
||||
by raising :class:`RateLimitError`, leaving the final decision to callers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Optional
|
||||
|
||||
from .errors import RateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MIN_INTERVAL_SECONDS = 0.75
|
||||
DEFAULT_MAX_WAIT_SECONDS = 300.0
|
||||
BASE_BACKOFF_SECONDS = 30.0
|
||||
MAX_BACKOFF_SECONDS = 1800.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DestinationState:
|
||||
"""Rate-limit bookkeeping for one destination (hostname)."""
|
||||
|
||||
next_allowed_send: float = 0.0 # time.monotonic() timestamp
|
||||
consecutive_429: int = 0
|
||||
last_send_at: float = 0.0 # time.monotonic() timestamp
|
||||
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
|
||||
|
||||
|
||||
class RateLimitCoordinator:
|
||||
"""Coordinates outbound request pacing per destination.
|
||||
|
||||
Singleton mirroring :class:`ConnectivityGuard`'s pattern. All waits are
|
||||
bounded by the ``rate_limit_max_wait_seconds`` setting; when the required
|
||||
wait exceeds the cap, :meth:`wait_for_slot` raises :class:`RateLimitError`
|
||||
instead of parking the caller.
|
||||
"""
|
||||
|
||||
_instance: "RateLimitCoordinator | None" = None
|
||||
_instance_lock = asyncio.Lock()
|
||||
|
||||
@classmethod
|
||||
async def get_instance(cls) -> "RateLimitCoordinator":
|
||||
async with cls._instance_lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
if hasattr(self, "_initialized"):
|
||||
return
|
||||
self._initialized = True
|
||||
self._states: Dict[str, _DestinationState] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Settings (read live so settings edits apply without a restart)
|
||||
|
||||
@staticmethod
|
||||
def _setting(key: str, default):
|
||||
try:
|
||||
from .settings_manager import get_settings_manager
|
||||
|
||||
return get_settings_manager().get(key, default)
|
||||
except Exception: # pragma: no cover - defensive: settings unavailable
|
||||
return default
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self._setting("rate_limit_gate_enabled", True))
|
||||
|
||||
@property
|
||||
def min_interval_seconds(self) -> float:
|
||||
try:
|
||||
return max(0.0, float(self._setting("rate_limit_min_interval_seconds", DEFAULT_MIN_INTERVAL_SECONDS)))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MIN_INTERVAL_SECONDS
|
||||
|
||||
@property
|
||||
def max_wait_seconds(self) -> float:
|
||||
try:
|
||||
return max(0.0, float(self._setting("rate_limit_max_wait_seconds", DEFAULT_MAX_WAIT_SECONDS)))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_WAIT_SECONDS
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State helpers
|
||||
|
||||
@staticmethod
|
||||
def _normalize(destination: Optional[str]) -> str:
|
||||
if destination is None or not destination.strip():
|
||||
return "__global__"
|
||||
return destination.lower().strip()
|
||||
|
||||
def _state_for(self, destination: Optional[str]) -> _DestinationState:
|
||||
key = self._normalize(destination)
|
||||
if key not in self._states:
|
||||
self._states[key] = _DestinationState()
|
||||
return self._states[key]
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Drop all per-destination state. Test seam."""
|
||||
self._states.clear()
|
||||
|
||||
def in_cooldown(self, destination: Optional[str] = None) -> bool:
|
||||
return self.remaining_seconds(destination) > 0
|
||||
|
||||
def remaining_seconds(self, destination: Optional[str] = None) -> float:
|
||||
state = self._state_for(destination)
|
||||
return max(0.0, state.next_allowed_send - time.monotonic())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Gate operations
|
||||
|
||||
async def wait_for_slot(self, destination: Optional[str] = None) -> None:
|
||||
"""Block until this caller may send the next request to *destination*.
|
||||
|
||||
Waits for both the rate-limit cooldown (``next_allowed_send``) and the
|
||||
minimum inter-request interval (``last_send_at + min_interval``).
|
||||
Waiters queue on the per-destination lock, so concurrent callers are
|
||||
spaced out instead of stampeding when a cooldown expires.
|
||||
|
||||
Raises:
|
||||
RateLimitError: when the required wait exceeds
|
||||
``rate_limit_max_wait_seconds``.
|
||||
"""
|
||||
state = self._state_for(destination)
|
||||
deadline = time.monotonic() + self.max_wait_seconds
|
||||
async with state.lock:
|
||||
now = time.monotonic()
|
||||
wake_at = max(
|
||||
state.next_allowed_send,
|
||||
state.last_send_at + self.min_interval_seconds,
|
||||
)
|
||||
if wake_at > deadline:
|
||||
raise RateLimitError(
|
||||
f"Rate limit wait for '{self._normalize(destination)}' "
|
||||
f"exceeds the {self.max_wait_seconds:.0f}s cap",
|
||||
retry_after=wake_at - now,
|
||||
)
|
||||
delay = wake_at - now
|
||||
if delay > 0:
|
||||
logger.debug(
|
||||
"Rate-limit gate: pacing request to '%s' by %.2fs",
|
||||
self._normalize(destination),
|
||||
delay,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
state.last_send_at = time.monotonic()
|
||||
|
||||
def register_rate_limit(
|
||||
self,
|
||||
destination: Optional[str],
|
||||
retry_after: Optional[float] = None,
|
||||
) -> float:
|
||||
"""Record a 429 for *destination* and arm the cooldown window.
|
||||
|
||||
Honors the vendor's ``Retry-After`` when present; otherwise grows an
|
||||
exponential backoff (30s base, doubling per consecutive 429, capped at
|
||||
1800s). Returns the cooldown duration in seconds.
|
||||
"""
|
||||
state = self._state_for(destination)
|
||||
state.consecutive_429 += 1
|
||||
if retry_after is not None and retry_after > 0:
|
||||
backoff = min(MAX_BACKOFF_SECONDS, float(retry_after))
|
||||
else:
|
||||
backoff = min(
|
||||
MAX_BACKOFF_SECONDS,
|
||||
BASE_BACKOFF_SECONDS * (2 ** (state.consecutive_429 - 1)),
|
||||
)
|
||||
now = time.monotonic()
|
||||
already_cooling = state.next_allowed_send > now
|
||||
state.next_allowed_send = max(state.next_allowed_send, now + backoff)
|
||||
if already_cooling:
|
||||
logger.debug(
|
||||
"Rate-limit cooldown for '%s' extended by %.0fs (consecutive_429=%d)",
|
||||
self._normalize(destination),
|
||||
backoff,
|
||||
state.consecutive_429,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Rate limited by '%s'; pausing requests for %.0fs",
|
||||
self._normalize(destination),
|
||||
backoff,
|
||||
)
|
||||
return backoff
|
||||
|
||||
def register_success(self, destination: Optional[str]) -> None:
|
||||
"""Reset rate-limit state after a successful request.
|
||||
|
||||
A 200 proves the vendor is accepting traffic again, so any armed
|
||||
cooldown window is cleared alongside the backoff counter (mirrors
|
||||
``ConnectivityGuard.register_success`` semantics).
|
||||
"""
|
||||
state = self._state_for(destination)
|
||||
state.consecutive_429 = 0
|
||||
state.next_allowed_send = 0.0
|
||||
@@ -7,13 +7,14 @@ enabling sub-100ms search times even with 20k+ recipes.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
|
||||
|
||||
@@ -165,6 +166,7 @@ class RecipeFTSIndex:
|
||||
batch_size = 500
|
||||
total = len(recipes)
|
||||
inserted = 0
|
||||
indexed_ids: Set[str] = set()
|
||||
|
||||
for i in range(0, total, batch_size):
|
||||
batch = recipes[i:i + batch_size]
|
||||
@@ -179,6 +181,7 @@ class RecipeFTSIndex:
|
||||
row = self._prepare_fts_row(recipe)
|
||||
rows.append(row)
|
||||
inserted += 1
|
||||
indexed_ids.add(recipe_id)
|
||||
|
||||
if rows:
|
||||
# Insert into FTS table
|
||||
@@ -213,7 +216,11 @@ class RecipeFTSIndex:
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
('recipe_count', str(inserted))
|
||||
(self._COUNT_METADATA_KEY, str(inserted))
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(indexed_ids))
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
@@ -288,6 +295,12 @@ class RecipeFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
# Check existence via the rowid mapping (fast PK lookup)
|
||||
existed = conn.execute(
|
||||
"SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
|
||||
(recipe_id,)
|
||||
).fetchone() is not None
|
||||
|
||||
# Remove existing entry if present
|
||||
self._remove_recipe_locked(conn, recipe_id)
|
||||
|
||||
@@ -312,6 +325,10 @@ class RecipeFTSIndex:
|
||||
(recipe_id, result[0])
|
||||
)
|
||||
|
||||
# Keep validation metadata in sync (only a new id changes it)
|
||||
if not existed:
|
||||
self._update_mutation_metadata_locked(conn, recipe_id, delta=1)
|
||||
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
@@ -339,7 +356,13 @@ class RecipeFTSIndex:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
existed = conn.execute(
|
||||
"SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
|
||||
(recipe_id,)
|
||||
).fetchone() is not None
|
||||
self._remove_recipe_locked(conn, recipe_id)
|
||||
if existed:
|
||||
self._update_mutation_metadata_locked(conn, recipe_id, delta=-1)
|
||||
conn.commit()
|
||||
return True
|
||||
finally:
|
||||
@@ -371,6 +394,15 @@ class RecipeFTSIndex:
|
||||
try:
|
||||
conn.execute("DELETE FROM recipe_fts")
|
||||
conn.execute("DELETE FROM recipe_rowid")
|
||||
# Reset validation metadata to the empty index state
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, '0')
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(set()))
|
||||
)
|
||||
conn.commit()
|
||||
self._ready.clear()
|
||||
return True
|
||||
@@ -427,10 +459,12 @@ class RecipeFTSIndex:
|
||||
"""Check if the FTS index matches the expected recipes.
|
||||
|
||||
This method validates whether the existing FTS index can be reused
|
||||
without a full rebuild. It checks:
|
||||
1. The index has been initialized
|
||||
2. The count matches
|
||||
3. The recipe IDs match
|
||||
without a full rebuild. It compares the expected count and recipe ID
|
||||
fingerprint against metadata recorded when the index was (re)built,
|
||||
so it does not scan the FTS content table. Indexes built by older
|
||||
versions lack this metadata; for those the validation falls back to
|
||||
a one-time scan of the content table and records the metadata so
|
||||
subsequent startups are cheap.
|
||||
|
||||
Args:
|
||||
recipe_count: Expected number of recipes.
|
||||
@@ -446,7 +480,28 @@ class RecipeFTSIndex:
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = self._read_validation_metadata()
|
||||
if metadata is not None:
|
||||
stored_count, stored_fingerprint = metadata
|
||||
if stored_count != recipe_count:
|
||||
logger.debug(
|
||||
"FTS index count mismatch: indexed=%d, expected=%d",
|
||||
stored_count, recipe_count
|
||||
)
|
||||
return False
|
||||
|
||||
if stored_fingerprint != self._compute_ids_fingerprint(recipe_ids):
|
||||
logger.debug("FTS index recipe ID fingerprint mismatch")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Legacy fallback: no stored metadata, scan the content table once
|
||||
# and persist the metadata so later validations are cheap.
|
||||
indexed_count = self.get_indexed_count()
|
||||
indexed_ids = self.get_indexed_recipe_ids()
|
||||
self._store_validation_metadata(indexed_count, indexed_ids)
|
||||
|
||||
if indexed_count != recipe_count:
|
||||
logger.debug(
|
||||
"FTS index count mismatch: indexed=%d, expected=%d",
|
||||
@@ -454,7 +509,6 @@ class RecipeFTSIndex:
|
||||
)
|
||||
return False
|
||||
|
||||
indexed_ids = self.get_indexed_recipe_ids()
|
||||
if indexed_ids != recipe_ids:
|
||||
missing = recipe_ids - indexed_ids
|
||||
extra = indexed_ids - recipe_ids
|
||||
@@ -471,6 +525,112 @@ class RecipeFTSIndex:
|
||||
|
||||
# Internal helpers
|
||||
|
||||
_FINGERPRINT_METADATA_KEY = 'recipe_ids_fingerprint'
|
||||
_COUNT_METADATA_KEY = 'recipe_count'
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint_recipe_id(recipe_id: str) -> int:
|
||||
"""Return a stable 64-bit fingerprint contribution for a recipe ID."""
|
||||
digest = hashlib.sha256(recipe_id.encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:8], "big")
|
||||
|
||||
@classmethod
|
||||
def _compute_ids_fingerprint(cls, recipe_ids: Set[str]) -> str:
|
||||
"""Order-independent fingerprint of a recipe ID set (XOR of per-id hashes)."""
|
||||
fingerprint = 0
|
||||
for recipe_id in recipe_ids:
|
||||
fingerprint ^= cls._fingerprint_recipe_id(str(recipe_id))
|
||||
return f"{fingerprint:016x}"
|
||||
|
||||
def _read_validation_metadata(self) -> Optional[Tuple[int, str]]:
|
||||
"""Return stored (recipe count, ID fingerprint), or None if absent."""
|
||||
try:
|
||||
with self._lock:
|
||||
conn = self._connect(readonly=True)
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT key, value FROM fts_metadata WHERE key IN (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, self._FINGERPRINT_METADATA_KEY)
|
||||
).fetchall()
|
||||
values = {row[0]: row[1] for row in rows}
|
||||
fingerprint = values.get(self._FINGERPRINT_METADATA_KEY)
|
||||
if fingerprint is None:
|
||||
return None
|
||||
try:
|
||||
count = int(values.get(self._COUNT_METADATA_KEY) or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return count, fingerprint
|
||||
finally:
|
||||
conn.close()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to read FTS validation metadata: %s", exc)
|
||||
return None
|
||||
|
||||
def _store_validation_metadata(self, recipe_count: int, recipe_ids: Set[str]) -> None:
|
||||
"""Persist recipe count and ID fingerprint for cheap future validation."""
|
||||
try:
|
||||
with self._lock:
|
||||
conn = self._connect()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, str(recipe_count))
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(recipe_ids))
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to store FTS validation metadata: %s", exc)
|
||||
|
||||
def _update_mutation_metadata_locked(
|
||||
self,
|
||||
conn: sqlite3.Connection,
|
||||
recipe_id: str,
|
||||
delta: int,
|
||||
) -> None:
|
||||
"""Incrementally maintain validation metadata after add/remove.
|
||||
|
||||
Caller must hold the lock. The fingerprint is only updated when it
|
||||
already exists; without it, validation falls back to a one-time scan
|
||||
that records fresh metadata.
|
||||
"""
|
||||
fingerprint_row = conn.execute(
|
||||
"SELECT value FROM fts_metadata WHERE key = ?",
|
||||
(self._FINGERPRINT_METADATA_KEY,)
|
||||
).fetchone()
|
||||
if fingerprint_row and fingerprint_row[0]:
|
||||
try:
|
||||
fingerprint = int(fingerprint_row[0], 16)
|
||||
except ValueError:
|
||||
fingerprint = None
|
||||
if fingerprint is not None:
|
||||
fingerprint ^= self._fingerprint_recipe_id(recipe_id)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._FINGERPRINT_METADATA_KEY, f"{fingerprint & 0xFFFFFFFFFFFFFFFF:016x}")
|
||||
)
|
||||
|
||||
count_row = conn.execute(
|
||||
"SELECT value FROM fts_metadata WHERE key = ?",
|
||||
(self._COUNT_METADATA_KEY,)
|
||||
).fetchone()
|
||||
if count_row:
|
||||
try:
|
||||
count = max(0, int(count_row[0] or 0) + delta)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
|
||||
(self._COUNT_METADATA_KEY, str(count))
|
||||
)
|
||||
|
||||
def _connect(self, readonly: bool = False) -> sqlite3.Connection:
|
||||
"""Create a database connection."""
|
||||
uri = False
|
||||
|
||||
+874
-61
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"""Recipe service layer implementations."""
|
||||
|
||||
from .analysis_service import RecipeAnalysisService
|
||||
from .import_info import build_import_info, compute_no_loras_reason
|
||||
from .persistence_service import RecipePersistenceService
|
||||
from .sharing_service import RecipeSharingService
|
||||
from .errors import (
|
||||
@@ -15,6 +16,8 @@ __all__ = [
|
||||
"RecipeAnalysisService",
|
||||
"RecipePersistenceService",
|
||||
"RecipeSharingService",
|
||||
"build_import_info",
|
||||
"compute_no_loras_reason",
|
||||
"RecipeServiceError",
|
||||
"RecipeValidationError",
|
||||
"RecipeNotFoundError",
|
||||
|
||||
@@ -72,15 +72,28 @@ class RecipeAnalysisService:
|
||||
metadata = self._exif_utils.extract_image_metadata(temp_path)
|
||||
if not metadata:
|
||||
return AnalysisResult(
|
||||
{"error": "No metadata found in this image", "loras": []}
|
||||
{
|
||||
"error": "No metadata found in this image",
|
||||
"loras": [],
|
||||
"diagnostics": {
|
||||
"channel": "upload",
|
||||
"exif_present": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return await self._parse_metadata(
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=None,
|
||||
include_image_base64=False,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "upload",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
self._safe_cleanup(temp_path)
|
||||
|
||||
@@ -104,9 +117,13 @@ class RecipeAnalysisService:
|
||||
image_info: Optional[dict[str, Any]] = None
|
||||
is_video = False
|
||||
extension = ".jpg" # Default
|
||||
# Diagnostics collected during analysis; surfaced in the payload so
|
||||
# callers can persist an import_info block explaining empty LoRA lists.
|
||||
diagnostics: dict[str, Any] = {"channel": "url"}
|
||||
|
||||
try:
|
||||
civitai_image_id = extract_civitai_image_id(url)
|
||||
diagnostics["civitai_image"] = bool(civitai_image_id)
|
||||
if civitai_image_id:
|
||||
image_info = await civitai_client.get_image_info(
|
||||
civitai_image_id, source_url=url
|
||||
@@ -147,11 +164,23 @@ class RecipeAnalysisService:
|
||||
):
|
||||
metadata = metadata["meta"]
|
||||
|
||||
# Diagnostics: capture the API meta shape before injecting
|
||||
# modelVersionIds / browsingLevel so the recipe modal can
|
||||
# explain why an import ended up without LoRAs.
|
||||
diagnostics["api_meta_present"] = isinstance(metadata, dict)
|
||||
if isinstance(metadata, dict):
|
||||
diagnostics["api_meta_keys"] = sorted(metadata.keys())
|
||||
|
||||
# Include modelVersionIds from root level if available.
|
||||
# CivitAI API returns modelVersionIds at root level, not in meta.
|
||||
# When meta is null (None), create a minimal dict so downstream
|
||||
# parsers can still discover LoRAs and checkpoints.
|
||||
model_version_ids = image_info.get("modelVersionIds")
|
||||
diagnostics["api_model_version_ids"] = (
|
||||
len(model_version_ids)
|
||||
if isinstance(model_version_ids, list)
|
||||
else 0
|
||||
)
|
||||
if model_version_ids:
|
||||
if isinstance(metadata, dict):
|
||||
metadata["modelVersionIds"] = model_version_ids
|
||||
@@ -229,6 +258,8 @@ class RecipeAnalysisService:
|
||||
finally:
|
||||
self._safe_cleanup(orig_temp_path)
|
||||
|
||||
diagnostics["exif_present"] = bool(exif_metadata)
|
||||
|
||||
# Parse EXIF data (typically a string like parameters/prompt/workflow)
|
||||
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
|
||||
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
|
||||
@@ -237,6 +268,7 @@ class RecipeAnalysisService:
|
||||
if isinstance(exif_metadata, str):
|
||||
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
|
||||
if exif_parser:
|
||||
diagnostics["exif_parser"] = exif_parser.__class__.__name__
|
||||
exif_data = await exif_parser.parse_metadata(
|
||||
exif_metadata, recipe_scanner=recipe_scanner,
|
||||
)
|
||||
@@ -270,6 +302,22 @@ class RecipeAnalysisService:
|
||||
if merged_gp:
|
||||
result.payload["gen_params"] = merged_gp
|
||||
|
||||
# The API-only parse (meta=null with only modelVersionIds)
|
||||
# yields a checkpoint but no LoRAs; the image EXIF carries the
|
||||
# full resource list. Fill the gaps the API parse left open.
|
||||
if not result.payload.get("loras"):
|
||||
exif_loras = exif_parsed_result.get("loras") or []
|
||||
if exif_loras:
|
||||
result.payload["loras"] = exif_loras
|
||||
if not result.payload.get("checkpoint") and not result.payload.get("model"):
|
||||
exif_checkpoint = exif_parsed_result.get("model") or exif_parsed_result.get(
|
||||
"checkpoint"
|
||||
)
|
||||
if exif_checkpoint:
|
||||
result.payload["checkpoint"] = exif_checkpoint
|
||||
if not result.payload.get("base_model") and exif_parsed_result.get("base_model"):
|
||||
result.payload["base_model"] = exif_parsed_result["base_model"]
|
||||
|
||||
if civitai_image_id and image_info and not result.payload.get("error"):
|
||||
# Use the metadata dict we built (may contain modelVersionIds
|
||||
# and browsingLevel from the API root level). Do NOT pass
|
||||
@@ -308,6 +356,8 @@ class RecipeAnalysisService:
|
||||
if isinstance(bl, int) and bl > 0:
|
||||
result.payload["preview_nsfw_level"] = bl
|
||||
|
||||
diagnostics["is_video"] = is_video
|
||||
result.payload["diagnostics"] = diagnostics
|
||||
return result
|
||||
finally:
|
||||
if temp_path:
|
||||
@@ -318,6 +368,7 @@ class RecipeAnalysisService:
|
||||
*,
|
||||
file_path: str | None,
|
||||
recipe_scanner,
|
||||
ignore_recipe_metadata: bool = False,
|
||||
) -> AnalysisResult:
|
||||
"""Analyze a file already present on disk."""
|
||||
|
||||
@@ -332,14 +383,41 @@ class RecipeAnalysisService:
|
||||
self._exif_utils.extract_image_metadata, normalized_path
|
||||
)
|
||||
if not metadata:
|
||||
return self._metadata_not_found_response(normalized_path)
|
||||
result = self._metadata_not_found_response(normalized_path)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": False,
|
||||
}
|
||||
return result
|
||||
|
||||
return await self._parse_metadata(
|
||||
if ignore_recipe_metadata:
|
||||
# Re-import: re-parse the original embedded generation metadata
|
||||
# instead of the recipe JSON block LoRA Manager appended on save.
|
||||
from ...recipes.parsers.recipe_format import strip_recipe_metadata
|
||||
|
||||
metadata = strip_recipe_metadata(metadata)
|
||||
if not metadata:
|
||||
result = self._metadata_not_found_response(normalized_path)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": True,
|
||||
"ignore_recipe_metadata": True,
|
||||
"reason": "only_recipe_metadata",
|
||||
}
|
||||
return result
|
||||
|
||||
result = await self._parse_metadata(
|
||||
metadata,
|
||||
recipe_scanner=recipe_scanner,
|
||||
image_path=normalized_path,
|
||||
include_image_base64=True,
|
||||
)
|
||||
result.payload["diagnostics"] = {
|
||||
"channel": "local",
|
||||
"exif_present": True,
|
||||
"exif_parser": result.payload.get("parser"),
|
||||
}
|
||||
return result
|
||||
|
||||
async def analyze_widget_metadata(self, *, recipe_scanner) -> AnalysisResult:
|
||||
"""Analyse the most recent generation metadata for widget saves."""
|
||||
@@ -436,6 +514,10 @@ class RecipeAnalysisService:
|
||||
metadata, recipe_scanner=recipe_scanner
|
||||
)
|
||||
|
||||
# Record which parser handled the metadata so import diagnostics
|
||||
# can distinguish e.g. ComfyUI workflow sources.
|
||||
result["parser"] = parser.__class__.__name__
|
||||
|
||||
if include_image_base64 and image_path:
|
||||
result["image_base64"] = self._encode_file(image_path)
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Import provenance helpers for recipes.
|
||||
|
||||
Builds the ``import_info`` block persisted on a recipe: the import channel
|
||||
(batch import / single URL / local file / upload / widget) and, when the
|
||||
recipe ended up with no LoRAs, a machine-readable reason plus the diagnostic
|
||||
details that led to it. The recipe modal renders this block in a collapsed
|
||||
"Why no LoRAs?" panel; legacy recipes without ``import_info`` fall back to a
|
||||
frontend heuristic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Import channels (how the recipe entered the library).
|
||||
CHANNEL_BATCH_IMPORT_URL = "batch_import_url"
|
||||
CHANNEL_BATCH_IMPORT_LOCAL = "batch_import_local"
|
||||
CHANNEL_URL = "url"
|
||||
CHANNEL_LOCAL = "local"
|
||||
CHANNEL_UPLOAD = "upload"
|
||||
CHANNEL_WIDGET = "widget"
|
||||
CHANNEL_REIMPORT_URL = "reimport_url"
|
||||
CHANNEL_REIMPORT_LOCAL = "reimport_local"
|
||||
|
||||
_URL_CHANNELS = frozenset(
|
||||
{CHANNEL_BATCH_IMPORT_URL, CHANNEL_URL, CHANNEL_REIMPORT_URL}
|
||||
)
|
||||
|
||||
# No-LoRA reason codes (persisted, consumed by the recipe modal).
|
||||
REASON_NO_LORAS_USED = "no_loras_used"
|
||||
REASON_API_NO_LORA_RESOURCES = "api_meta_no_lora_resources"
|
||||
REASON_API_META_MISSING = "api_meta_missing"
|
||||
REASON_NO_EMBEDDED_METADATA = "no_embedded_metadata"
|
||||
REASON_WORKFLOW_METADATA_LIMITED = "workflow_metadata_limited"
|
||||
REASON_VIDEO_NO_METADATA = "video_no_metadata"
|
||||
REASON_METADATA_UNSUPPORTED = "metadata_unsupported"
|
||||
REASON_UNKNOWN = "unknown"
|
||||
|
||||
_COMFY_PARSER_NAME = "ComfyMetadataParser"
|
||||
|
||||
# Cap for api_meta_keys kept in details — enough for the UI bullet without
|
||||
# bloating the recipe JSON.
|
||||
_MAX_DETAIL_KEYS = 12
|
||||
|
||||
|
||||
def compute_no_loras_reason(
|
||||
channel: str, diagnostics: Optional[Dict[str, Any]]
|
||||
) -> str:
|
||||
"""Classify why an import produced no LoRA entries.
|
||||
|
||||
Args:
|
||||
channel: One of the CHANNEL_* constants.
|
||||
diagnostics: Signals collected during analysis (see
|
||||
``RecipeAnalysisService``), or None for channels without analysis
|
||||
(e.g. widget saves).
|
||||
"""
|
||||
diag = diagnostics or {}
|
||||
|
||||
if diag.get("is_video"):
|
||||
return REASON_VIDEO_NO_METADATA
|
||||
|
||||
# Embedded metadata that is a ComfyUI workflow: LoRA extraction from
|
||||
# workflows is limited, so report that specifically.
|
||||
parser = diag.get("exif_parser") or diag.get("parser")
|
||||
if parser == _COMFY_PARSER_NAME:
|
||||
return REASON_WORKFLOW_METADATA_LIMITED
|
||||
|
||||
if channel in _URL_CHANNELS:
|
||||
if not diag.get("civitai_image"):
|
||||
# Generic (non-CivitAI) URL: only embedded metadata is available.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return (
|
||||
REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
)
|
||||
# NOTE: no "parsed EXIF means no LoRAs were used" shortcut here.
|
||||
# CivitAI's onsite generator writes A1111-style EXIF (prompt, seed,
|
||||
# steps, ...) WITHOUT LoRA references — LoRA usage lives only in
|
||||
# CivitAI-internal data — so cleanly parsed EXIF cannot prove the
|
||||
# generation used no LoRAs. Report the API meta shape instead.
|
||||
api_keys = diag.get("api_meta_keys") or []
|
||||
api_mvids = diag.get("api_model_version_ids") or 0
|
||||
if api_keys or api_mvids:
|
||||
return REASON_API_NO_LORA_RESOURCES
|
||||
return REASON_API_META_MISSING
|
||||
|
||||
if channel == CHANNEL_WIDGET:
|
||||
return REASON_NO_LORAS_USED
|
||||
|
||||
# Local file / upload / local re-import: embedded metadata only.
|
||||
if not diag.get("exif_present"):
|
||||
return REASON_NO_EMBEDDED_METADATA
|
||||
return REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
|
||||
|
||||
|
||||
def build_import_info(
|
||||
channel: str,
|
||||
diagnostics: Optional[Dict[str, Any]],
|
||||
loras: Optional[List[Dict[str, Any]]],
|
||||
) -> Dict[str, Any]:
|
||||
"""Build the ``import_info`` block persisted on a recipe.
|
||||
|
||||
Always records the import channel; adds ``reason`` and ``details`` only
|
||||
when the recipe has no LoRAs.
|
||||
"""
|
||||
info: Dict[str, Any] = {"channel": channel}
|
||||
if loras:
|
||||
return info
|
||||
|
||||
info["reason"] = compute_no_loras_reason(channel, diagnostics)
|
||||
|
||||
diag = diagnostics or {}
|
||||
details: Dict[str, Any] = {}
|
||||
api_keys = diag.get("api_meta_keys")
|
||||
if api_keys:
|
||||
details["api_meta_keys"] = list(api_keys)[:_MAX_DETAIL_KEYS]
|
||||
api_mvids = diag.get("api_model_version_ids")
|
||||
if api_mvids is not None:
|
||||
details["api_model_version_ids"] = api_mvids
|
||||
if "exif_present" in diag:
|
||||
details["exif_present"] = bool(diag.get("exif_present"))
|
||||
if diag.get("exif_parser"):
|
||||
details["exif_parser"] = diag["exif_parser"]
|
||||
if diag.get("is_video"):
|
||||
details["is_video"] = True
|
||||
if details:
|
||||
info["details"] = details
|
||||
|
||||
return info
|
||||
@@ -13,9 +13,15 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast
|
||||
|
||||
from ...config import config
|
||||
from ...recipes.constants import GEN_PARAM_KEYS
|
||||
from ...utils.base_model import (
|
||||
RELATION_COMPATIBLE,
|
||||
RELATION_INCOMPATIBLE,
|
||||
base_model_relation,
|
||||
)
|
||||
from ...utils.utils import calculate_recipe_fingerprint
|
||||
from ..pending_delete_service import get_pending_delete_service
|
||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||
from .import_info import CHANNEL_UPLOAD, CHANNEL_WIDGET, build_import_info
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,6 +58,7 @@ class RecipePersistenceService:
|
||||
extension: str | None = None,
|
||||
recipe_id: str | None = None,
|
||||
target_dir: str | None = None,
|
||||
skip_optimize: bool = False,
|
||||
) -> PersistenceResult:
|
||||
"""Persist a user uploaded recipe.
|
||||
|
||||
@@ -61,6 +68,11 @@ class RecipePersistenceService:
|
||||
target_dir: If provided, save recipe files to this directory instead
|
||||
of the default recipes_dir. Used by re-import to preserve the
|
||||
original folder location.
|
||||
skip_optimize: If True, store the image bytes verbatim without
|
||||
resizing/re-encoding (recipe metadata is still embedded via a
|
||||
byte-level EXIF update that leaves the pixels untouched). Used
|
||||
by local re-import, where the source is the recipe's own
|
||||
already-optimized preview image.
|
||||
"""
|
||||
|
||||
missing_fields = []
|
||||
@@ -81,9 +93,12 @@ class RecipePersistenceService:
|
||||
|
||||
recipe_id = recipe_id or str(uuid.uuid4())
|
||||
|
||||
# Handle video formats by bypassing optimization and metadata embedding
|
||||
# Handle video formats by bypassing optimization and metadata embedding.
|
||||
# Local re-import also bypasses optimization: the source is the
|
||||
# recipe's own already-optimized preview image, so re-compressing it
|
||||
# would only degrade quality.
|
||||
is_video = extension in [".mp4", ".webm"]
|
||||
if is_video:
|
||||
if is_video or skip_optimize:
|
||||
optimized_image = resolved_image_bytes
|
||||
# extension is already set
|
||||
else:
|
||||
@@ -117,6 +132,7 @@ class RecipePersistenceService:
|
||||
"loras": loras_data,
|
||||
"gen_params": gen_params,
|
||||
"fingerprint": fingerprint,
|
||||
"has_workflow": self._detect_has_workflow(normalized_image_path),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
@@ -128,6 +144,22 @@ class RecipePersistenceService:
|
||||
if metadata.get("source_path"):
|
||||
recipe_data["source_path"] = metadata.get("source_path")
|
||||
|
||||
# Persist import provenance. Batch import / re-import paths pass a
|
||||
# prebuilt import_info; frontend-driven saves (upload, single URL,
|
||||
# local path) carry the analysis payload's diagnostics, from which
|
||||
# import_info is derived here.
|
||||
import_info = metadata.get("import_info")
|
||||
if not isinstance(import_info, dict):
|
||||
diagnostics = metadata.get("diagnostics")
|
||||
if isinstance(diagnostics, dict):
|
||||
import_info = build_import_info(
|
||||
diagnostics.get("channel") or CHANNEL_UPLOAD,
|
||||
diagnostics,
|
||||
loras_data,
|
||||
)
|
||||
if isinstance(import_info, dict) and import_info:
|
||||
recipe_data["import_info"] = import_info
|
||||
|
||||
nsfw_level = metadata.get("preview_nsfw_level")
|
||||
if nsfw_level is not None and isinstance(nsfw_level, int):
|
||||
recipe_data["preview_nsfw_level"] = nsfw_level
|
||||
@@ -152,7 +184,11 @@ class RecipePersistenceService:
|
||||
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
|
||||
|
||||
if not is_video:
|
||||
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
|
||||
self._exif_utils.append_recipe_metadata(
|
||||
normalized_image_path,
|
||||
recipe_data,
|
||||
pixel_preserving=skip_optimize,
|
||||
)
|
||||
|
||||
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
|
||||
await recipe_scanner.add_recipe(recipe_data)
|
||||
@@ -426,10 +462,34 @@ class RecipePersistenceService:
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||
if not target_lora:
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_base_model = json.load(file_obj).get("base_model", "")
|
||||
|
||||
matches = await recipe_scanner.find_local_loras_by_name(target_name)
|
||||
if not matches:
|
||||
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
|
||||
|
||||
# Three-tier base-model guard: exact/unknown labels pass silently;
|
||||
# labels from the same architecture family (e.g. Pony ↔ Illustrious)
|
||||
# pass but are reported so the UI can warn; confident architecture
|
||||
# mismatches stay hard-rejected because they can never load.
|
||||
eligible: list[tuple[dict, str]] = []
|
||||
for match in matches:
|
||||
relation = base_model_relation(recipe_base_model, match.get("base_model"))
|
||||
if relation != RELATION_INCOMPATIBLE:
|
||||
eligible.append((match, relation))
|
||||
|
||||
if not eligible:
|
||||
raise RecipeValidationError(
|
||||
f"Local LoRA '{target_name}' has a different base model than the recipe"
|
||||
)
|
||||
if len(eligible) > 1:
|
||||
raise RecipeValidationError(
|
||||
f"Multiple local LoRAs match '{target_name}'; "
|
||||
"include the folder path to disambiguate"
|
||||
)
|
||||
target_lora, target_relation = eligible[0]
|
||||
|
||||
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
|
||||
recipe_id,
|
||||
lora_index,
|
||||
@@ -437,6 +497,43 @@ class RecipePersistenceService:
|
||||
target_lora=target_lora,
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(recipe_data["fingerprint"])
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_lora": updated_lora,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
if target_relation == RELATION_COMPATIBLE:
|
||||
# Structured data, not prose — the frontend localizes the warning.
|
||||
payload["base_model_mismatch"] = {
|
||||
"recipe_base_model": recipe_base_model,
|
||||
"lora_base_model": target_lora.get("base_model") or "",
|
||||
}
|
||||
return PersistenceResult(payload)
|
||||
|
||||
async def restore_lora(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
lora_index: int,
|
||||
) -> PersistenceResult:
|
||||
"""Restore a LoRA entry to the state captured before its reconnect."""
|
||||
|
||||
recipe_data, updated_lora = await recipe_scanner.restore_lora_entry(
|
||||
recipe_id, lora_index
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
@@ -456,6 +553,231 @@ class RecipePersistenceService:
|
||||
}
|
||||
)
|
||||
|
||||
async def get_reconnect_suggestions(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
lora_index: int,
|
||||
query: str | None = None,
|
||||
) -> PersistenceResult:
|
||||
"""Return ranked local LoRA candidates for reconnecting a recipe entry."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
loras = recipe_data.get("loras") or []
|
||||
if lora_index < 0 or lora_index >= len(loras):
|
||||
raise RecipeValidationError(f"Invalid lora_index: {lora_index}")
|
||||
|
||||
suggestions = await recipe_scanner.suggest_reconnect_candidates(
|
||||
entry=loras[lora_index],
|
||||
recipe_base_model=recipe_data.get("base_model"),
|
||||
query=query,
|
||||
)
|
||||
|
||||
return PersistenceResult({"success": True, "suggestions": suggestions})
|
||||
|
||||
async def mark_lora_hash_invalid(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
lora_index: int,
|
||||
hash_invalid: bool = True,
|
||||
) -> PersistenceResult:
|
||||
"""Mark a recipe LoRA entry's hash as unresolvable on CivitAI.
|
||||
|
||||
Called when a download attempt by hash returned "Model not found".
|
||||
The flag makes the entry an unresolved rematch candidate without
|
||||
altering its stored hash/file_name.
|
||||
"""
|
||||
|
||||
recipe_data, updated_lora = await recipe_scanner.set_lora_entry_hash_invalid(
|
||||
recipe_id,
|
||||
lora_index,
|
||||
hash_invalid=hash_invalid,
|
||||
)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"hash_invalid": bool(hash_invalid),
|
||||
"updated_lora": updated_lora,
|
||||
}
|
||||
)
|
||||
|
||||
async def reconnect_checkpoint(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
target_name: str,
|
||||
) -> PersistenceResult:
|
||||
"""Reconnect the checkpoint entry within an existing recipe."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_base_model = json.load(file_obj).get("base_model", "")
|
||||
|
||||
matches = await recipe_scanner.find_local_checkpoints_by_name(target_name)
|
||||
if not matches:
|
||||
raise RecipeNotFoundError(
|
||||
f"Local checkpoint not found with name: {target_name}"
|
||||
)
|
||||
|
||||
# Same three-tier base-model guard as reconnect_lora: exact/unknown
|
||||
# labels pass silently; same-architecture-family labels pass but are
|
||||
# reported so the UI can warn; confident mismatches stay hard-rejected.
|
||||
eligible: list[tuple[dict, str]] = []
|
||||
for match in matches:
|
||||
relation = base_model_relation(recipe_base_model, match.get("base_model"))
|
||||
if relation != RELATION_INCOMPATIBLE:
|
||||
eligible.append((match, relation))
|
||||
|
||||
if not eligible:
|
||||
raise RecipeValidationError(
|
||||
f"Local checkpoint '{target_name}' has a different base model "
|
||||
"than the recipe"
|
||||
)
|
||||
if len(eligible) > 1:
|
||||
raise RecipeValidationError(
|
||||
f"Multiple local checkpoints match '{target_name}'; "
|
||||
"include the folder path to disambiguate"
|
||||
)
|
||||
target_checkpoint, target_relation = eligible[0]
|
||||
|
||||
recipe_data, updated_checkpoint = await recipe_scanner.update_checkpoint_entry(
|
||||
recipe_id,
|
||||
target_name=target_name,
|
||||
target_checkpoint=target_checkpoint,
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
recipe_data["fingerprint"]
|
||||
)
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
if target_relation == RELATION_COMPATIBLE:
|
||||
# Structured data, not prose — the frontend localizes the warning.
|
||||
payload["base_model_mismatch"] = {
|
||||
"recipe_base_model": recipe_base_model,
|
||||
"checkpoint_base_model": target_checkpoint.get("base_model") or "",
|
||||
}
|
||||
return PersistenceResult(payload)
|
||||
|
||||
async def restore_checkpoint(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
) -> PersistenceResult:
|
||||
"""Restore the checkpoint entry to the state captured before its reconnect."""
|
||||
|
||||
recipe_data, updated_checkpoint = await recipe_scanner.restore_checkpoint_entry(
|
||||
recipe_id
|
||||
)
|
||||
|
||||
image_path = recipe_data.get("file_path")
|
||||
if image_path and os.path.exists(image_path):
|
||||
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
|
||||
|
||||
matching_recipes = []
|
||||
if "fingerprint" in recipe_data:
|
||||
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
|
||||
recipe_data["fingerprint"]
|
||||
)
|
||||
if recipe_id in matching_recipes:
|
||||
matching_recipes.remove(recipe_id)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
"matching_recipes": matching_recipes,
|
||||
}
|
||||
)
|
||||
|
||||
async def get_checkpoint_reconnect_suggestions(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
query: str | None = None,
|
||||
) -> PersistenceResult:
|
||||
"""Return ranked local checkpoint candidates for reconnecting a recipe entry."""
|
||||
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_path, "r", encoding="utf-8") as file_obj:
|
||||
recipe_data = json.load(file_obj)
|
||||
|
||||
checkpoint = recipe_data.get("checkpoint")
|
||||
if not isinstance(checkpoint, dict):
|
||||
raise RecipeValidationError("Recipe has no checkpoint entry")
|
||||
|
||||
suggestions = await recipe_scanner.suggest_checkpoint_reconnect_candidates(
|
||||
entry=checkpoint,
|
||||
recipe_base_model=recipe_data.get("base_model"),
|
||||
query=query,
|
||||
)
|
||||
|
||||
return PersistenceResult({"success": True, "suggestions": suggestions})
|
||||
|
||||
async def mark_checkpoint_hash_invalid(
|
||||
self,
|
||||
*,
|
||||
recipe_scanner,
|
||||
recipe_id: str,
|
||||
hash_invalid: bool = True,
|
||||
) -> PersistenceResult:
|
||||
"""Mark the recipe checkpoint entry's hash as unresolvable on CivitAI.
|
||||
|
||||
Called when a download attempt by hash returned "Model not found".
|
||||
The flag makes the entry an unresolved rematch candidate without
|
||||
altering its stored hash/file_name.
|
||||
"""
|
||||
|
||||
recipe_data, updated_checkpoint = (
|
||||
await recipe_scanner.set_checkpoint_entry_hash_invalid(
|
||||
recipe_id,
|
||||
hash_invalid=hash_invalid,
|
||||
)
|
||||
)
|
||||
|
||||
return PersistenceResult(
|
||||
{
|
||||
"success": True,
|
||||
"recipe_id": recipe_id,
|
||||
"hash_invalid": bool(hash_invalid),
|
||||
"updated_checkpoint": updated_checkpoint,
|
||||
}
|
||||
)
|
||||
|
||||
async def bulk_delete(
|
||||
self,
|
||||
*,
|
||||
@@ -519,7 +841,7 @@ class RecipePersistenceService:
|
||||
# Merge succeeded: one undo action covers the whole bulk.
|
||||
payload["batch_id"] = merged_batch_id
|
||||
else:
|
||||
# Merge failure (e.g. cross-volume move): expose the constituent
|
||||
# Merge unresolvable (defensive): expose the constituent
|
||||
# batches so the caller can undo them one at a time.
|
||||
payload["batch_ids"] = batch_ids
|
||||
else:
|
||||
@@ -602,6 +924,12 @@ class RecipePersistenceService:
|
||||
if key not in ["checkpoint", "loras"]
|
||||
},
|
||||
"loras_stack": lora_stack,
|
||||
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
|
||||
# embedded metadata chunks, so a workflow can never be present.
|
||||
"has_workflow": False,
|
||||
# Widget saves read LoRAs straight from the current workflow; an
|
||||
# empty list means the workflow used no LoRAs.
|
||||
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
|
||||
}
|
||||
if checkpoint_entry:
|
||||
recipe_data["checkpoint"] = checkpoint_entry
|
||||
@@ -626,6 +954,20 @@ class RecipePersistenceService:
|
||||
|
||||
# Helper methods ---------------------------------------------------
|
||||
|
||||
def _detect_has_workflow(self, image_path: str) -> bool:
|
||||
"""Detect whether the saved recipe image embeds a ComfyUI workflow.
|
||||
|
||||
Extraction failures (missing file, corrupt image, unsupported format)
|
||||
map to ``False`` and never propagate, mirroring the scanner's behavior.
|
||||
"""
|
||||
if not image_path or not os.path.exists(image_path):
|
||||
return False
|
||||
try:
|
||||
metadata = self._exif_utils._load_structured_metadata(image_path)
|
||||
return bool(metadata.get("workflow"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
async def _build_widget_checkpoint_entry(
|
||||
self,
|
||||
recipe_scanner,
|
||||
@@ -762,6 +1104,7 @@ class RecipePersistenceService:
|
||||
"modelName": lora.get("name", ""),
|
||||
"modelVersionName": lora.get("version", ""),
|
||||
"isDeleted": lora.get("isDeleted", False),
|
||||
"hashInvalid": lora.get("hashInvalid", False),
|
||||
"exclude": lora.get("exclude", False),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -68,6 +70,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"enable_metadata_archive_db": False,
|
||||
"enable_civarchive_api": True,
|
||||
"metadata_provider_order": "civitai_archive_sqlite",
|
||||
"rate_limit_gate_enabled": True,
|
||||
"rate_limit_max_wait_seconds": 300,
|
||||
"rate_limit_min_interval_seconds": 0.75,
|
||||
"proxy_enabled": False,
|
||||
"proxy_host": "",
|
||||
"proxy_port": "",
|
||||
@@ -156,7 +161,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 +1649,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")
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Base-model architecture families and compatibility relations.
|
||||
|
||||
CivitAI base-model labels describe fine-tune lineages, not architectures.
|
||||
A LoRA physically loads on any checkpoint sharing its tensor architecture,
|
||||
so e.g. Pony / Illustrious / NoobAI / SDXL 1.0 LoRAs are interchangeable
|
||||
(quality varies, but nothing breaks). Different architectures (SD 1.5 vs
|
||||
SDXL vs Flux) are guaranteed failures and must stay hard-rejected.
|
||||
|
||||
Only families with high-confidence architecture equivalence are listed.
|
||||
Anything not in the table is treated as its own family, i.e. only an exact
|
||||
label match is accepted — unknown new labels never get wrongly waved through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Normalized (casefolded, stripped) base-model label -> architecture family.
|
||||
_BASE_MODEL_FAMILIES = {
|
||||
# SD 1.x — all share the original 512px latent UNet.
|
||||
"sd 1.4": "sd1",
|
||||
"sd 1.5": "sd1",
|
||||
"sd 1.5 lcm": "sd1",
|
||||
"sd 1.5 hyper": "sd1",
|
||||
# SDXL lineage — Pony / Illustrious / NoobAI are SDXL fine-tunes.
|
||||
# Note: Pony V7 is AuraFlow-based, NOT SDXL, so it is deliberately absent.
|
||||
"sdxl 1.0": "sdxl",
|
||||
"sdxl lightning": "sdxl",
|
||||
"sdxl hyper": "sdxl",
|
||||
"pony": "sdxl",
|
||||
"pony diffusion": "sdxl",
|
||||
"pony diffusion v6 xl": "sdxl",
|
||||
"illustrious": "sdxl",
|
||||
"illustrious 0.1": "sdxl",
|
||||
"illustrious 1.0": "sdxl",
|
||||
"illustrious 1.1": "sdxl",
|
||||
"noobai": "sdxl",
|
||||
# Flux.1 — dev/schnell/Krea share the 12B rectified-flow transformer.
|
||||
"flux.1 d": "flux1",
|
||||
"flux.1 s": "flux1",
|
||||
"flux.1 krea": "flux1",
|
||||
# SD 3.5 Large and its Turbo distill share the 8B MMDiT. SD 3 (2B) and
|
||||
# SD 3.5 Medium (2.5B) have different shapes and stay unlisted.
|
||||
"sd 3.5 large": "sd35-large",
|
||||
"sd 3.5 large turbo": "sd35-large",
|
||||
}
|
||||
|
||||
_UNKNOWN_TOKENS = {"", "unknown", "other", "none", "null"}
|
||||
|
||||
# Relation constants returned by base_model_relation().
|
||||
RELATION_UNKNOWN = "unknown" # at least one side has no usable label
|
||||
RELATION_SAME = "same" # identical labels
|
||||
RELATION_COMPATIBLE = "compatible" # different labels, same architecture family
|
||||
RELATION_INCOMPATIBLE = "incompatible" # different labels, different/unknown family
|
||||
|
||||
|
||||
def _normalize(label: Optional[str]) -> str:
|
||||
return (label or "").strip().casefold()
|
||||
|
||||
|
||||
def base_model_relation(a: Optional[str], b: Optional[str]) -> str:
|
||||
"""Classify how two base-model labels relate for reconnect purposes.
|
||||
|
||||
``RELATION_UNKNOWN`` when either side has no usable label (callers treat
|
||||
it as lenient-allow), ``RELATION_SAME`` for identical labels,
|
||||
``RELATION_COMPATIBLE`` when both labels map to the same architecture
|
||||
family, and ``RELATION_INCOMPATIBLE`` otherwise — including when a label
|
||||
is missing from the family table (conservative fallback).
|
||||
"""
|
||||
na, nb = _normalize(a), _normalize(b)
|
||||
if na in _UNKNOWN_TOKENS or nb in _UNKNOWN_TOKENS:
|
||||
return RELATION_UNKNOWN
|
||||
if na == nb:
|
||||
return RELATION_SAME
|
||||
fa = _BASE_MODEL_FAMILIES.get(na)
|
||||
fb = _BASE_MODEL_FAMILIES.get(nb)
|
||||
if fa is not None and fa == fb:
|
||||
return RELATION_COMPATIBLE
|
||||
return RELATION_INCOMPATIBLE
|
||||
+26
-5
@@ -1,3 +1,5 @@
|
||||
from typing import Any
|
||||
|
||||
NSFW_LEVELS = {
|
||||
"PG": 1,
|
||||
"PG13": 2,
|
||||
@@ -99,11 +101,30 @@ DEFAULT_HASH_CHUNK_SIZE_MB = 4
|
||||
# absurd 64-bit header length from forcing a multi-GB allocation during scan.
|
||||
MAX_SAFETENSORS_HEADER_BYTES = 64 * 1024 * 1024
|
||||
|
||||
# First 12 chars of the SHA256 of an empty byte string. Some (re-packaging)
|
||||
# training tools write this placeholder into safetensors metadata instead of a
|
||||
# real hash; it must never be treated as a valid AutoV3 — several broken
|
||||
# models sharing it would collide in the hash index and falsely match recipes.
|
||||
INVALID_AUTOV3_EMPTY_HASH = "e3b0c44298fc"
|
||||
# SHA256 of an empty byte string. Some (re-packaging) training tools write a
|
||||
# truncated form of this placeholder into safetensors metadata (as
|
||||
# ``modelspec.hash_sha256`` / ``sshs_model_hash``), and hashing an empty or
|
||||
# unreadable file produces it directly. It must never be treated as a valid
|
||||
# hash: several broken models share it, CivitAI's by-hash index can contain
|
||||
# such polluted entries, and matching it falsely attributes recipes.
|
||||
EMPTY_HASH_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
INVALID_AUTOV3_EMPTY_HASH = EMPTY_HASH_SHA256[:12]
|
||||
INVALID_AUTOV2_EMPTY_HASH = EMPTY_HASH_SHA256[:10]
|
||||
|
||||
|
||||
def is_empty_placeholder_hash(value: Any) -> bool:
|
||||
"""True for a 10/12/64-hex-char spelling of the empty-hash placeholder.
|
||||
|
||||
These are the AutoV2, AutoV3 and full-SHA256 forms of the placeholder;
|
||||
such values identify no real model and must never be resolved against
|
||||
local files or CivitAI.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
v = value.strip().lower()
|
||||
if len(v) not in (10, 12, 64):
|
||||
return False
|
||||
return v == EMPTY_HASH_SHA256[: len(v)]
|
||||
|
||||
# Auto-organize settings
|
||||
AUTO_ORGANIZE_BATCH_SIZE = (
|
||||
|
||||
+67
-3
@@ -348,8 +348,14 @@ class ExifUtils:
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def append_recipe_metadata(image_path, recipe_data) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data"""
|
||||
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
|
||||
"""Append recipe metadata to an image's EXIF data
|
||||
|
||||
When ``pixel_preserving`` is True (and the image is a WebP) only the
|
||||
EXIF container is rewritten at the byte level, so the preview pixels
|
||||
are never re-encoded. Local re-import uses this because its source is
|
||||
the recipe's own already-optimized preview image.
|
||||
"""
|
||||
try:
|
||||
if image_path:
|
||||
ext = os.path.splitext(image_path)[1].lower()
|
||||
@@ -417,13 +423,71 @@ class ExifUtils:
|
||||
|
||||
# Append to existing metadata or create new one
|
||||
new_metadata = f"{metadata} \n {recipe_metadata_marker}" if metadata else recipe_metadata_marker
|
||||
|
||||
|
||||
# Write back to the image. Re-import keeps the already-optimized
|
||||
# preview pixels untouched and updates only the WebP EXIF chunk
|
||||
# instead of re-encoding the whole image.
|
||||
if pixel_preserving and image_path.lower().endswith(".webp"):
|
||||
metadata_fields = ExifUtils._load_structured_metadata(image_path)
|
||||
metadata_fields["parameters"] = new_metadata
|
||||
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
|
||||
with open(image_path, "rb") as file_obj:
|
||||
image_bytes = file_obj.read()
|
||||
try:
|
||||
updated = ExifUtils._replace_webp_exif(image_bytes, exif_bytes)
|
||||
except ValueError:
|
||||
# Container without an EXIF chunk; fall back to re-encoding.
|
||||
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
||||
with open(image_path, "wb") as file_obj:
|
||||
file_obj.write(updated)
|
||||
return image_path
|
||||
|
||||
# Write back to the image
|
||||
return ExifUtils.update_image_metadata(image_path, new_metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
|
||||
return image_path
|
||||
|
||||
@staticmethod
|
||||
def _replace_webp_exif(image_bytes: bytes, exif_bytes: bytes) -> bytes:
|
||||
"""Replace the EXIF chunk of a WebP file without re-encoding pixels."""
|
||||
if image_bytes[:4] != b"RIFF" or image_bytes[8:12] != b"WEBP":
|
||||
raise ValueError("Not a WebP file")
|
||||
# The WebP EXIF chunk stores raw TIFF data; strip the JPEG-style
|
||||
# "Exif\\0\\0" prefix that piexif.dump may prepend.
|
||||
tiff = exif_bytes[6:] if exif_bytes[:6] == b"Exif\x00\x00" else exif_bytes
|
||||
|
||||
out = bytearray(image_bytes[:12])
|
||||
pos = 12
|
||||
exif_payload = None
|
||||
while pos + 8 <= len(image_bytes):
|
||||
fourcc = image_bytes[pos : pos + 4]
|
||||
size = struct.unpack("<I", image_bytes[pos + 4 : pos + 8])[0]
|
||||
chunk_data = image_bytes[pos + 8 : pos + 8 + size]
|
||||
pad = size % 2
|
||||
if fourcc == b"EXIF":
|
||||
exif_payload = tiff
|
||||
else:
|
||||
out += (
|
||||
fourcc
|
||||
+ struct.pack("<I", size)
|
||||
+ chunk_data
|
||||
+ (b"\x00" * pad)
|
||||
)
|
||||
pos += 8 + size + pad
|
||||
|
||||
if exif_payload is None:
|
||||
raise ValueError("WebP has no EXIF chunk")
|
||||
|
||||
out += (
|
||||
b"EXIF"
|
||||
+ struct.pack("<I", len(exif_payload))
|
||||
+ exif_payload
|
||||
+ (b"\x00" * (len(exif_payload) % 2))
|
||||
)
|
||||
out[4:8] = struct.pack("<I", len(out) - 8)
|
||||
return bytes(out)
|
||||
|
||||
@staticmethod
|
||||
def remove_recipe_metadata(user_comment):
|
||||
"""Remove recipe metadata from user comment"""
|
||||
|
||||
@@ -13,6 +13,15 @@ from platformdirs import user_config_dir
|
||||
|
||||
APP_NAME = "ComfyUI-LoRA-Manager"
|
||||
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
|
||||
|
||||
# Explicit settings-directory override. Setting this (env var, or standalone's
|
||||
# ``--settings-path`` which publishes it) pins the settings location: settings.json,
|
||||
# cache/, wildcards/, backups/, logs/, stats/ all resolve under this directory,
|
||||
# bypassing portable mode and the platform user config dir. Useful for sandboxed
|
||||
# development/E2E runs that must not touch the real user data or the project root.
|
||||
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
|
||||
_settings_dir_override: Optional[str] = None
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -22,6 +31,51 @@ def get_project_root() -> str:
|
||||
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
|
||||
def _normalize_settings_dir(path: str) -> str:
|
||||
"""Expand ``~`` and absolutize a user-supplied settings directory."""
|
||||
|
||||
return os.path.abspath(os.path.expanduser(path))
|
||||
|
||||
|
||||
def set_settings_dir_override(path: Optional[str]) -> Optional[str]:
|
||||
"""Set or clear the programmatic settings-directory override.
|
||||
|
||||
Args:
|
||||
path: Absolute/relative directory to pin, or ``None`` to clear the
|
||||
override. ``~`` is expanded and the path absolutized.
|
||||
|
||||
Returns:
|
||||
The previous override value (``None`` when none was active).
|
||||
"""
|
||||
|
||||
global _settings_dir_override
|
||||
previous = _settings_dir_override
|
||||
_settings_dir_override = (
|
||||
_normalize_settings_dir(path) if path else None
|
||||
)
|
||||
return previous
|
||||
|
||||
|
||||
def get_settings_dir_override() -> Optional[str]:
|
||||
"""Return the active explicit settings-directory override, if any.
|
||||
|
||||
The ``LORA_MANAGER_SETTINGS_DIR`` environment variable takes precedence over
|
||||
the programmatic override so that standalone's ``--settings-path`` (which
|
||||
publishes itself through the environment) wins over embedded callers.
|
||||
"""
|
||||
|
||||
env_path = os.environ.get(SETTINGS_DIR_ENV)
|
||||
if env_path:
|
||||
return _normalize_settings_dir(env_path)
|
||||
return _settings_dir_override
|
||||
|
||||
|
||||
def is_settings_dir_pinned() -> bool:
|
||||
"""Return ``True`` when an explicit settings-directory override is active."""
|
||||
|
||||
return get_settings_dir_override() is not None
|
||||
|
||||
|
||||
def get_legacy_settings_path() -> str:
|
||||
"""Return the legacy location of ``settings.json`` within the project tree."""
|
||||
|
||||
@@ -31,6 +85,11 @@ def get_legacy_settings_path() -> str:
|
||||
def get_settings_dir(create: bool = True) -> str:
|
||||
"""Return the user configuration directory for the application.
|
||||
|
||||
An explicit override (``LORA_MANAGER_SETTINGS_DIR`` or
|
||||
:func:`set_settings_dir_override`) takes precedence. Otherwise the portable
|
||||
project-root ``settings.json`` is used when enabled, falling back to the
|
||||
platform-specific user configuration directory.
|
||||
|
||||
Args:
|
||||
create: Whether to create the directory if it does not already exist.
|
||||
|
||||
@@ -38,11 +97,15 @@ def get_settings_dir(create: bool = True) -> str:
|
||||
The absolute path to the user configuration directory.
|
||||
"""
|
||||
|
||||
legacy_path = get_legacy_settings_path()
|
||||
if _should_use_portable_settings(legacy_path, _LOGGER):
|
||||
config_dir = os.path.dirname(legacy_path)
|
||||
override = get_settings_dir_override()
|
||||
if override:
|
||||
config_dir = override
|
||||
else:
|
||||
config_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
legacy_path = get_legacy_settings_path()
|
||||
if _should_use_portable_settings(legacy_path, _LOGGER):
|
||||
config_dir = os.path.dirname(legacy_path)
|
||||
else:
|
||||
config_dir = user_config_dir(APP_NAME, appauthor=False)
|
||||
|
||||
if create and config_dir:
|
||||
os.makedirs(config_dir, exist_ok=True)
|
||||
@@ -58,9 +121,14 @@ def get_settings_file_path(create_dir: bool = True) -> str:
|
||||
def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
"""Ensure the settings file resides in the user configuration directory.
|
||||
|
||||
If a legacy ``settings.json`` is detected in the project root it is migrated to
|
||||
the platform-specific user configuration folder. The caller receives the path
|
||||
to the settings file irrespective of whether a migration was needed.
|
||||
An explicit override (``LORA_MANAGER_SETTINGS_DIR`` or
|
||||
:func:`set_settings_dir_override`) pins the settings file to
|
||||
``<override>/settings.json`` and skips legacy migration entirely.
|
||||
|
||||
Otherwise, if a legacy ``settings.json`` is detected in the project root it is
|
||||
migrated to the platform-specific user configuration folder. The caller
|
||||
receives the path to the settings file irrespective of whether a migration was
|
||||
needed.
|
||||
|
||||
Args:
|
||||
logger: Optional logger used for migration messages. Falls back to a
|
||||
@@ -71,6 +139,12 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
|
||||
"""
|
||||
|
||||
logger = logger or _LOGGER
|
||||
|
||||
override = get_settings_dir_override()
|
||||
if override:
|
||||
os.makedirs(override, exist_ok=True)
|
||||
return os.path.join(override, "settings.json")
|
||||
|
||||
legacy_path = get_legacy_settings_path()
|
||||
|
||||
if _should_use_portable_settings(legacy_path, logger):
|
||||
|
||||
+47
-1
@@ -8,12 +8,36 @@ from typing import Any, cast
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from py.middleware.cache_middleware import cache_control
|
||||
from py.middleware.error_middleware import api_json_error
|
||||
from py.utils.settings_paths import ensure_settings_file
|
||||
from py.utils.settings_paths import SETTINGS_DIR_ENV, ensure_settings_file
|
||||
|
||||
# Set environment variable to indicate standalone mode
|
||||
os.environ["LORA_MANAGER_STANDALONE"] = "1"
|
||||
|
||||
|
||||
def _apply_settings_dir_from_argv(argv=None):
|
||||
"""Apply ``--settings-path`` from argv before any settings resolution runs.
|
||||
|
||||
Standalone resolves the settings location at import time (session logging and
|
||||
the settings manager run before ``main()`` parses arguments), so pre-scan
|
||||
argv and publish the explicit directory through ``LORA_MANAGER_SETTINGS_DIR``,
|
||||
which ``py.utils.settings_paths`` honors in both standalone and plugin modes.
|
||||
|
||||
Args:
|
||||
argv: Argument list to scan; defaults to ``sys.argv[1:]``.
|
||||
"""
|
||||
args = list(sys.argv[1:] if argv is None else argv)
|
||||
for index, arg in enumerate(args):
|
||||
if arg == "--settings-path" and index + 1 < len(args):
|
||||
os.environ[SETTINGS_DIR_ENV] = args[index + 1]
|
||||
return
|
||||
if arg.startswith("--settings-path="):
|
||||
os.environ[SETTINGS_DIR_ENV] = arg.split("=", 1)[1]
|
||||
return
|
||||
|
||||
|
||||
_apply_settings_dir_from_argv()
|
||||
|
||||
|
||||
# Create mock modules for py/nodes directory - add this before any other imports
|
||||
def mock_nodes_directory():
|
||||
"""Create mock modules for all Python files in the py/nodes directory"""
|
||||
@@ -395,6 +419,16 @@ def parse_args():
|
||||
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
|
||||
# parser.add_argument("--checkpoints", type=str, nargs="+",
|
||||
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
|
||||
parser.add_argument(
|
||||
"--settings-path",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="DIR",
|
||||
help="Explicit settings directory: settings.json, cache/, wildcards/, "
|
||||
"backups/, logs/, stats/ all live under this directory. Overrides portable "
|
||||
"mode and the default user config dir. Equivalent to the "
|
||||
"LORA_MANAGER_SETTINGS_DIR environment variable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
type=str,
|
||||
@@ -414,6 +448,18 @@ async def main():
|
||||
"""Main entry point for standalone mode"""
|
||||
args = parse_args()
|
||||
|
||||
# Normalize and validate the explicit settings directory (the pre-import
|
||||
# argv scan already applied it; re-derive so --settings-path wins over any
|
||||
# pre-existing LORA_MANAGER_SETTINGS_DIR and is canonicalized the same way).
|
||||
if args.settings_path:
|
||||
settings_dir = os.path.abspath(os.path.expanduser(args.settings_path))
|
||||
if os.path.exists(settings_dir) and not os.path.isdir(settings_dir):
|
||||
logger.error(
|
||||
"--settings-path '%s' exists but is not a directory.", settings_dir
|
||||
)
|
||||
return
|
||||
os.environ[SETTINGS_DIR_ENV] = settings_dir
|
||||
|
||||
# Set log level (verbose flag overrides to DEBUG)
|
||||
log_level = "DEBUG" if args.verbose else args.log_level
|
||||
logging.getLogger().setLevel(getattr(logging, log_level))
|
||||
|
||||
+100
-21
@@ -49,34 +49,113 @@
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
/* Remove bulk base model modal specific styles - now using shared components */
|
||||
/* Use shared metadata editing styles instead */
|
||||
/* ── Bulk base model modal — dedicated inline-list layout ───────────────
|
||||
Unlike the single-model modal (overlay dropdown), the bulk modal renders
|
||||
the option list inline so it never covers the footer buttons and only the
|
||||
list itself scrolls. Dropdown internals reuse the shared .base-model-*
|
||||
styles from lora-modal.css. */
|
||||
|
||||
/* Override for bulk base model select to ensure proper width */
|
||||
.bulk-base-model-select {
|
||||
#bulkBaseModelModal .modal-content {
|
||||
width: min(720px, calc(100vw - 2rem));
|
||||
height: min(640px, calc(100vh - var(--header-height, 48px) - 5.5rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .modal-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .modal-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .bulk-add-tags-info {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .bulk-base-model-label {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--space-1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.bulk-base-model-picker {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .bulk-base-model-picker {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .bulk-base-model-picker .base-model-search-wrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
z-index: auto;
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .base-model-search-input-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Inline list instead of overlay dropdown */
|
||||
#bulkBaseModelModal .base-model-dropdown {
|
||||
position: relative;
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: none;
|
||||
overflow-y: auto;
|
||||
margin-top: var(--space-1);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
background-color: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
height: 32px;
|
||||
box-shadow: none;
|
||||
z-index: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--lora-border) transparent;
|
||||
}
|
||||
|
||||
.bulk-base-model-select:focus {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Dark theme support for bulk base model select */
|
||||
[data-theme="dark"] .bulk-base-model-select {
|
||||
background-color: rgba(30, 30, 30, 0.9);
|
||||
color: var(--text-color);
|
||||
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-thumb {
|
||||
background: var(--lora-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .bulk-base-model-select option {
|
||||
background-color: #2d2d2d;
|
||||
color: var(--text-color);
|
||||
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The shared dropdown header uses opacity for its muted look, which makes the
|
||||
sticky background translucent — scrolled items bleed through. Keep the
|
||||
muted text color but restore a fully opaque background (bulk scope only). */
|
||||
#bulkBaseModelModal .base-model-dropdown-header {
|
||||
opacity: 1;
|
||||
color: var(--text-muted, var(--text-color));
|
||||
}
|
||||
|
||||
#bulkBaseModelModal .bulk-base-model-footer {
|
||||
flex-shrink: 0;
|
||||
padding-top: var(--space-2);
|
||||
margin-top: var(--space-2);
|
||||
border-top: 1px solid var(--lora-border);
|
||||
}
|
||||
@@ -671,23 +671,46 @@ body.hide-card-version .hl-badge {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Compact LoRA status pill: state icon + available/total fraction (e.g. "2/3").
|
||||
The icon switches by state (warning/check/layers) so status never relies on
|
||||
color alone; the tooltip spells out the full details. */
|
||||
.lora-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
flex-shrink: 0;
|
||||
/* Pin to the bottom-right corner of the footer, matching how model card
|
||||
footer .card-actions behave when the title wraps to multiple lines */
|
||||
align-self: flex-end;
|
||||
font-size: 0.85em;
|
||||
position: relative;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.lora-count.ready {
|
||||
background: rgba(46, 204, 113, 0.3);
|
||||
border-color: rgba(46, 204, 113, 0.6);
|
||||
}
|
||||
|
||||
.lora-count.missing {
|
||||
background: rgba(231, 76, 60, 0.3);
|
||||
background: rgba(231, 76, 60, 0.35);
|
||||
border-color: rgba(231, 76, 60, 0.65);
|
||||
}
|
||||
|
||||
/* Partial: usable but degraded — some LoRAs are unobtainable (deleted from
|
||||
the source or unresolvable hash) and are skipped when the recipe is used.
|
||||
Amber sits between ready green and missing red. */
|
||||
.lora-count.partial {
|
||||
background: rgba(243, 156, 18, 0.35);
|
||||
border-color: rgba(243, 156, 18, 0.65);
|
||||
}
|
||||
|
||||
/* Unavailable: no usable LoRA at all — gray marks the recipe as dead. */
|
||||
.lora-count.unavailable {
|
||||
background: rgba(149, 165, 166, 0.35);
|
||||
border-color: rgba(149, 165, 166, 0.65);
|
||||
}
|
||||
|
||||
.placeholder-message {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: var(--z-header);
|
||||
height: 48px;
|
||||
height: var(--header-height, 48px);
|
||||
/* Reduced height */
|
||||
width: 100%;
|
||||
box-shadow: var(--shadow-md);
|
||||
|
||||
@@ -77,41 +77,84 @@
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
/* File Input Styles */
|
||||
.file-input-wrapper {
|
||||
position: relative;
|
||||
margin-bottom: var(--space-1);
|
||||
.import-description {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.file-input-wrapper input[type="file"] {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.file-input-button {
|
||||
/* Unified Drop Zone */
|
||||
.import-drop-zone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
background: var(--lora-accent);
|
||||
color: var(--lora-text);
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-weight: 500;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-4) var(--space-3);
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
transition: border-color 0.2s, background-color 0.2s;
|
||||
}
|
||||
|
||||
.file-input-button:hover {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
.import-drop-zone:hover,
|
||||
.import-drop-zone:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.file-input-wrapper:hover .file-input-button {
|
||||
background: oklch(from var(--lora-accent) l c h / 0.9);
|
||||
.import-drop-zone.drag-over {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(var(--lora-accent) / 0.08);
|
||||
}
|
||||
|
||||
.drop-zone-icon {
|
||||
font-size: 1.8em;
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.drop-zone-primary {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.drop-zone-filename {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Divider between drop zone and URL input */
|
||||
.import-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0;
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.import-divider::before,
|
||||
.import-divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Loading state for the fetch button */
|
||||
#fetchImageBtn.loading {
|
||||
opacity: 0.8;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* Inputs sit flush against the scrollable step's content edge; an outset
|
||||
outline (global offset: 2px) gets clipped by overflow-x. Draw the focus
|
||||
outline inset instead so the full ring stays visible. */
|
||||
#importModal input:focus-visible,
|
||||
#importModal select:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Recipe Details Layout */
|
||||
@@ -626,25 +669,6 @@
|
||||
/* Hide the old style */
|
||||
}
|
||||
|
||||
/* Update deleted badge to be more prominent */
|
||||
.deleted-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-warning);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.8em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deleted-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Error message styling */
|
||||
.error-message {
|
||||
color: var(--lora-error);
|
||||
|
||||
@@ -68,6 +68,39 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Destructive modal action: ghost icon button right-anchored by its own auto
|
||||
margin, revealing the danger color only on hover/focus. Shared by the model
|
||||
modal and the recipe modal. */
|
||||
.modal-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-delete-btn:hover,
|
||||
.modal-delete-btn:focus-visible {
|
||||
color: var(--lora-error);
|
||||
border-color: var(--lora-error);
|
||||
background: oklch(from var(--lora-error) l c h / 0.08);
|
||||
}
|
||||
|
||||
.modal-delete-btn i {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* When license icons directly precede the delete button, they carry the auto
|
||||
margin instead, so the [license][delete] cluster stays right-anchored as
|
||||
one group with the delete button flush at the right edge and no split gap. */
|
||||
.modal-header-actions .license-restrictions {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -76,6 +109,11 @@
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modal-header-actions .license-restrictions + .modal-delete-btn,
|
||||
.modal-header-actions .license-permissions + .modal-delete-btn {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.license-restrictions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -216,6 +254,62 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Hashes footnote — borderless full-width muted line; reads as a footnote
|
||||
to the file info grid rather than a peer field */
|
||||
.hash-footnote {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.hash-footnote .hash-entry {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-kind {
|
||||
font-size: 0.7em;
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hash-footnote .model-hash-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.8em;
|
||||
opacity: 0.6;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-sep {
|
||||
opacity: 0.3;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.35;
|
||||
font-size: 0.7em;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hash-footnote .hash-copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Toggle button — icon only, inline with the label */
|
||||
.notes-toggle-btn {
|
||||
display: none; /* shown by JS when content exceeds threshold */
|
||||
@@ -984,6 +1078,19 @@
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
/* Partial: usable but degraded — some LoRAs are unobtainable and skipped.
|
||||
Orange sits between ready green and missing amber. */
|
||||
.recipe-card__badge--partial {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #fb923c;
|
||||
}
|
||||
|
||||
/* Unavailable: no usable LoRA at all — red marks the recipe as dead. */
|
||||
.recipe-card__badge--unavailable {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.recipe-card__badge--empty {
|
||||
background: rgba(148, 163, 184, 0.18);
|
||||
color: #e2e8f0;
|
||||
@@ -999,6 +1106,16 @@
|
||||
background: rgba(245, 199, 43, 0.22);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--partial {
|
||||
color: #c2410c;
|
||||
background: rgba(249, 115, 22, 0.18);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--unavailable {
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.16);
|
||||
}
|
||||
|
||||
[data-theme="light"] .recipe-card__badge--empty {
|
||||
color: rgba(71, 85, 105, 0.9);
|
||||
background: rgba(148, 163, 184, 0.2);
|
||||
|
||||
@@ -4,19 +4,297 @@
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.carousel {
|
||||
transition: max-height 0.3s ease-in-out;
|
||||
/* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
|
||||
|
||||
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
|
||||
.gallery-indicator-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.gallery-preview-thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.gallery-preview-thumb img,
|
||||
.gallery-preview-thumb video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-indicator-bar .gallery-show-btn {
|
||||
flex: 1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.gallery-indicator-bar .gallery-import-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Expanded gallery toolbar */
|
||||
.gallery-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
/* Position badge floats over the main media, bottom-right */
|
||||
.gallery-position-badge {
|
||||
position: absolute;
|
||||
right: var(--space-2);
|
||||
bottom: var(--space-2);
|
||||
z-index: 6;
|
||||
padding: 2px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-size: 0.8em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* While the gallery is expanded the thumbnail strip sits in the modal's
|
||||
bottom-right corner, where the back-to-top button would overlap it */
|
||||
.modal-content.showcase-expanded .back-to-top {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-toolbar .gallery-import-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.gallery-show-btn,
|
||||
.gallery-import-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.gallery-show-btn:hover,
|
||||
.gallery-import-btn:hover {
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.nsfw-filter-notification {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Main viewer — the container hugs the active media's aspect ratio
|
||||
(--media-aspect = width/height, set per item) so no dead space remains.
|
||||
overflow: hidden also clips the hoisted metadata panel while it is
|
||||
translated below the bottom edge, so it never extends the modal's
|
||||
scrollable height (which caused a scroll jump when it appeared) */
|
||||
.gallery-main {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius-sm);
|
||||
/* Horizontal touch pans are claimed for swipe navigation (ShowcaseView);
|
||||
vertical pans still scroll the modal */
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.main-media-container {
|
||||
position: relative;
|
||||
margin: 0 auto;
|
||||
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
|
||||
aspect-ratio: var(--media-aspect, 1.3333);
|
||||
max-height: min(75vh, 800px);
|
||||
background: var(--lora-surface);
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.carousel.collapsed {
|
||||
max-height: 0;
|
||||
.main-media-container .media-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.carousel-container {
|
||||
/* Direction-aware slide on example switches (set by updateMainDisplay) */
|
||||
.main-media-container.slide-from-right .media-wrapper {
|
||||
animation: gallery-slide-from-right 0.25s ease;
|
||||
}
|
||||
|
||||
.main-media-container.slide-from-left .media-wrapper {
|
||||
animation: gallery-slide-from-left 0.25s ease;
|
||||
}
|
||||
|
||||
@keyframes gallery-slide-from-right {
|
||||
from { transform: translateX(32px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes gallery-slide-from-left {
|
||||
from { transform: translateX(-32px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.main-media-container.slide-from-right .media-wrapper,
|
||||
.main-media-container.slide-from-left .media-wrapper {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.main-media-container .media-wrapper img,
|
||||
.main-media-container .media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* Nav buttons float over the media, visible on hover */
|
||||
.gallery-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 6;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-nav.prev {
|
||||
left: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-nav.next {
|
||||
right: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-main:hover .gallery-nav,
|
||||
.gallery-nav:focus-visible {
|
||||
opacity: 0.9;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gallery-nav:hover {
|
||||
opacity: 1;
|
||||
border-color: var(--lora-accent);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Thumbnail strip */
|
||||
.gallery-strip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
gap: var(--space-1);
|
||||
margin-top: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.gallery-thumb {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.gallery-thumb:hover {
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.gallery-thumb.active {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-media {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-media.blurred {
|
||||
filter: blur(8px);
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-video-badge,
|
||||
.gallery-thumb .thumb-nsfw-badge {
|
||||
position: absolute;
|
||||
bottom: 3px;
|
||||
right: 3px;
|
||||
font-size: 10px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 1px 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-thumb .thumb-nsfw-badge {
|
||||
top: 3px;
|
||||
bottom: auto;
|
||||
}
|
||||
|
||||
.gallery-strip::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.gallery-strip::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Inline import zone toggled from the toolbar */
|
||||
.gallery-import-zone {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.gallery-import-zone.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-import-zone .example-import-area {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.media-wrapper {
|
||||
@@ -31,16 +309,6 @@
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.media-wrapper img,
|
||||
.media-wrapper video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.no-examples {
|
||||
text-align: center;
|
||||
padding: var(--space-3);
|
||||
@@ -48,11 +316,6 @@
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Adjust the media wrapper for tab system */
|
||||
#showcase-tab .carousel-container {
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Add styles for blurred showcase content */
|
||||
.nsfw-media-wrapper {
|
||||
position: relative;
|
||||
@@ -217,6 +480,24 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
|
||||
.gallery-main > .image-metadata-panel {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 7;
|
||||
max-height: 60%;
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.gallery-main > .image-metadata-panel.visible {
|
||||
transform: translateY(0);
|
||||
opacity: 0.98;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Adjust to dark theme */
|
||||
[data-theme="dark"] .image-metadata-panel {
|
||||
background: var(--card-bg);
|
||||
@@ -388,31 +669,6 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Scroll Indicator */
|
||||
.scroll-indicator {
|
||||
cursor: pointer;
|
||||
padding: var(--space-2);
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--space-2);
|
||||
transition: background-color 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.scroll-indicator:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.scroll-indicator span {
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.lazy {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
@@ -6,11 +6,23 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
|
||||
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
z-index: var(--z-modal);
|
||||
overflow: auto; /* Change from hidden to auto to allow scrolling */
|
||||
}
|
||||
|
||||
/* Software-rendering fallback (set by applyModalBackdropBlurPolicy): a
|
||||
full-viewport backdrop-filter forces per-frame CPU rasterization of
|
||||
everything behind the modal and freezes the browser (issue #1092) */
|
||||
html.no-modal-backdrop-blur .modal,
|
||||
html.no-modal-backdrop-blur .delete-modal,
|
||||
html.no-modal-backdrop-blur .batch-preview-select-all {
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
/* Prevent body scroll when modal is open */
|
||||
body.modal-open {
|
||||
position: fixed;
|
||||
|
||||
@@ -13,7 +13,10 @@
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Darker than --modal-backdrop-bg to stress destructive actions, but keeps the shared blur */
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
|
||||
|
||||
@@ -514,6 +514,7 @@
|
||||
background: oklch(var(--lora-accent) / 0.18);
|
||||
color: var(--lora-accent);
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
@@ -603,6 +604,51 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-option-radio input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--lora-accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Files already in the library are greyed out and not clickable */
|
||||
.file-option.disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-option.disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-option.disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Options of the other routing group are temporarily disabled once a
|
||||
selection is made (mixed-type multi-select is not allowed) */
|
||||
.file-option.group-disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-option.group-disabled:hover {
|
||||
border-color: var(--border-color);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.file-option.group-disabled input[type="checkbox"] {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.file-tag.in-library {
|
||||
background: oklch(var(--lora-accent) / 0.15);
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.file-option-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -875,8 +921,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
|
||||
}
|
||||
|
||||
.batch-preview-select-all input[type="checkbox"] {
|
||||
|
||||
@@ -9,6 +9,21 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Header row: title + nav controls. Padding reserves space for the
|
||||
absolutely positioned nav buttons (see .modal-nav-controls in lora-modal.css). */
|
||||
.recipe-modal-header-row {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
padding-right: 152px;
|
||||
}
|
||||
|
||||
/* 56px right offset keeps the nav buttons clear of the close (x) button,
|
||||
which is absolutely positioned at the modal-content top-right corner. */
|
||||
.recipe-modal-header-row .modal-nav-controls {
|
||||
right: 56px;
|
||||
}
|
||||
|
||||
#recipeTagsContainer {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -107,12 +122,19 @@
|
||||
#recipeModal .modal-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* Content-sized shell: grows with content up to the viewport limit, inner panes scroll past it */
|
||||
box-sizing: border-box; /* Include padding/border so the shell never exceeds the viewport */
|
||||
width: min(1600px, 94vw);
|
||||
max-width: min(1600px, 94vw);
|
||||
height: auto;
|
||||
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#recipeModal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
display: grid;
|
||||
grid-template-columns: 320px minmax(0, 1fr) 420px;
|
||||
gap: var(--space-3);
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -168,25 +190,66 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Icon-only companion to the Send button: same pill style as its neighbors,
|
||||
just without the text label. */
|
||||
.modal-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: var(--border-radius-sm);
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-copy-btn {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--lora-border);
|
||||
}
|
||||
|
||||
.modal-copy-btn:hover {
|
||||
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.modal-copy-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.modal-copy-btn i {
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-height: 860px) {
|
||||
.recipe-header-actions {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Top Section: Preview and Gen Params */
|
||||
.recipe-top-section {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
/* Left Column: Preview */
|
||||
.recipe-media-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
|
||||
}
|
||||
|
||||
/* Recipe Preview */
|
||||
.recipe-preview-container {
|
||||
width: 100%;
|
||||
height: 360px;
|
||||
box-sizing: border-box; /* Keep the 1px border inside the column width */
|
||||
height: auto;
|
||||
max-height: 42vh;
|
||||
border-radius: var(--border-radius-sm);
|
||||
overflow: hidden;
|
||||
background: var(--lora-surface);
|
||||
@@ -196,18 +259,19 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 42vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.recipe-preview-media {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
max-height: 42vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
@@ -340,9 +404,10 @@
|
||||
|
||||
/* Generation Parameters */
|
||||
.recipe-gen-params {
|
||||
height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.gen-params-header-row {
|
||||
@@ -399,8 +464,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.param-group {
|
||||
@@ -453,8 +516,6 @@
|
||||
color: var(--text-color);
|
||||
font-size: 0.9em;
|
||||
line-height: 1.5;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
@@ -526,14 +587,12 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Bottom Section: Resources */
|
||||
/* Right Column: Resources */
|
||||
.recipe-bottom-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
@@ -656,6 +715,72 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Empty LoRA list + collapsible "Why no LoRAs?" explanation */
|
||||
.no-loras {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
padding: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.no-loras-reason {
|
||||
margin: var(--space-1) 0 var(--space-2);
|
||||
border: 1px solid var(--lora-border);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.no-loras-reason summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Hide the native disclosure triangle; rotate the icon instead. */
|
||||
.no-loras-reason summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-loras-reason summary i {
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.no-loras-reason[open] summary i {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.no-loras-reason summary:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.no-loras-reason-body {
|
||||
padding: 0 var(--space-3) var(--space-3);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.no-loras-reason-body ul {
|
||||
margin: 0;
|
||||
padding-left: var(--space-5);
|
||||
}
|
||||
|
||||
.no-loras-reason-body li {
|
||||
margin: var(--space-1) 0;
|
||||
}
|
||||
|
||||
.no-loras-bullet-label {
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.no-loras-inferred-note {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -670,6 +795,9 @@
|
||||
|
||||
.recipe-lora-item {
|
||||
display: flex;
|
||||
/* The reconnect panel is a full-width child that wraps below the
|
||||
thumbnail + content row. */
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
padding: 10px var(--space-2);
|
||||
border: 1px solid var(--border-color);
|
||||
@@ -679,26 +807,42 @@
|
||||
will-change: transform;
|
||||
/* Create a new containing block for absolutely positioned descendants */
|
||||
transform: translateZ(0);
|
||||
cursor: pointer; /* Make it clear the item is clickable */
|
||||
/* Rows are not clickable by default; only in-library rows navigate */
|
||||
cursor: default;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-lora-item:hover {
|
||||
/* Click affordance (pointer + hover lift) is reserved for rows that
|
||||
actually navigate: in-library items open the local detail view. */
|
||||
.recipe-lora-item.exists-locally {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recipe-lora-item.exists-locally:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-header);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-lora-item.exists-locally:focus-visible {
|
||||
outline: 2px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.recipe-lora-item.exists-locally {
|
||||
background: oklch(var(--lora-accent) / 0.05);
|
||||
border-left: 4px solid var(--lora-accent);
|
||||
}
|
||||
|
||||
.recipe-lora-item.checkpoint-item {
|
||||
cursor: pointer;
|
||||
cursor: default;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 8px;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.recipe-lora-item.checkpoint-item.exists-locally {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recipe-lora-item.missing-locally {
|
||||
@@ -755,12 +899,24 @@
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.recipe-lora-title {
|
||||
display: flex;
|
||||
/* Top-align so the inline Civitai link stays glued to the FIRST line
|
||||
even when a long model name wraps to two lines. */
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0; /* Allow the clamped title to shrink next to the badge */
|
||||
}
|
||||
|
||||
.recipe-lora-content h4 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
color: var(--text-color);
|
||||
flex: 1;
|
||||
max-width: calc(100% - 120px); /* Make room for the badge */
|
||||
/* Shrink (for the 2-line clamp) but don't grow: the inline Civitai link
|
||||
should sit right after the name, not pushed to the far edge. */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
@@ -800,8 +956,36 @@
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Restore icon for manually reconnected entries: its presence on the info
|
||||
row doubles as the "was reconnected" marker. Shared by LoRA and
|
||||
checkpoint entries, which use the same info-row flex layout. */
|
||||
.lora-undo-reconnect,
|
||||
.checkpoint-undo-reconnect {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-color);
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
font-size: 0.95em;
|
||||
line-height: 1;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.lora-undo-reconnect:hover,
|
||||
.lora-undo-reconnect:focus-visible,
|
||||
.checkpoint-undo-reconnect:hover,
|
||||
.checkpoint-undo-reconnect:focus-visible {
|
||||
opacity: 1;
|
||||
color: var(--lora-accent);
|
||||
background: var(--lora-surface);
|
||||
}
|
||||
|
||||
.local-badge,
|
||||
.missing-badge {
|
||||
.missing-badge,
|
||||
.invalid-hash-badge {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
@@ -812,21 +996,12 @@
|
||||
|
||||
/* Specific styles for recipe modal badges - update z-index */
|
||||
.recipe-lora-header .local-badge,
|
||||
.recipe-lora-header .missing-badge {
|
||||
.recipe-lora-header .missing-badge,
|
||||
.recipe-lora-header .invalid-hash-badge {
|
||||
z-index: 2; /* Ensure the badge is above other elements */
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Ensure local-path tooltip is properly positioned and won't move during scroll */
|
||||
.recipe-lora-header .local-badge .local-path {
|
||||
z-index: 3;
|
||||
top: calc(100% + 4px); /* Position tooltip below the badge */
|
||||
right: -4px; /* Align with the badge */
|
||||
max-width: 250px;
|
||||
/* Force hardware acceleration for Chrome */
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.missing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -864,49 +1039,42 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* Add reconnect functionality styles */
|
||||
.deleted-badge.reconnectable {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.deleted-badge.reconnectable:hover {
|
||||
background-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.deleted-badge .reconnect-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
padding: 8px 12px;
|
||||
/* Unresolvable-hash badge: the entry has identity fields, but its hash is
|
||||
not registered on CivitAI (stale or invalid). */
|
||||
.invalid-hash-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: var(--lora-warning);
|
||||
color: white;
|
||||
padding: 3px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-header);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
top: calc(100% + 5px);
|
||||
left: 0;
|
||||
margin-left: -100px;
|
||||
font-size: 0.75em;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.deleted-badge.reconnectable:hover .reconnect-tooltip {
|
||||
display: block;
|
||||
.invalid-hash-badge i {
|
||||
margin-right: 4px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
/* LoRA reconnect container */
|
||||
/* Deleted badge is a pure status indicator; the reconnect action lives on
|
||||
an explicit ghost button in the item's action row. */
|
||||
|
||||
/* LoRA reconnect container: an inline extension of the item, not a nested
|
||||
card — a dashed separator reads lighter than another bordered box inside
|
||||
an already bordered item. It is a direct child of .recipe-lora-item and
|
||||
spans the full row (thumbnail column included). */
|
||||
.lora-reconnect-container {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
background: var(--lora-surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
padding: 12px;
|
||||
margin-top: 10px;
|
||||
flex-basis: 100%;
|
||||
/* Flex items default to min-width:auto — never let content force the
|
||||
panel wider than the row. */
|
||||
min-width: 0;
|
||||
border-top: 1px dashed var(--border-color);
|
||||
padding-top: 10px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -933,18 +1101,6 @@
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.reconnect-instructions code {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .reconnect-instructions code {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.reconnect-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -952,13 +1108,108 @@
|
||||
}
|
||||
|
||||
.reconnect-input {
|
||||
width: calc(100% - 20px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.reconnect-error {
|
||||
display: none;
|
||||
margin: 0;
|
||||
color: var(--lora-error);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.reconnect-error.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reconnect-suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.reconnect-suggestions:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reconnect-suggestions-loading,
|
||||
.reconnect-suggestions-empty {
|
||||
font-size: 0.85em;
|
||||
color: var(--text-color);
|
||||
opacity: 0.7;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.reconnect-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
/* Buttons default to content-box: without this, width:100% + padding +
|
||||
border overflows the panel by 18px and forces a horizontal scrollbar. */
|
||||
box-sizing: border-box;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-xs);
|
||||
background: var(--lora-surface, var(--bg-color));
|
||||
color: var(--text-color);
|
||||
font-size: 0.95em;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.reconnect-suggestion:hover,
|
||||
.reconnect-suggestion:focus-visible {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
.reconnect-suggestion-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
.reconnect-suggestion-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.reconnect-suggestion-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-suggestion-secondary {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.7;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-suggestion-reason {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--lora-accent);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.reconnect-actions {
|
||||
@@ -1010,18 +1261,43 @@
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
@media (max-width: 1500px) {
|
||||
#recipeModal .modal-body {
|
||||
grid-template-columns: 300px minmax(0, 1fr) 380px;
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
#recipeModal .modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
.recipe-media-column {
|
||||
overflow-y: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-preview-container,
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video,
|
||||
.recipe-preview-media {
|
||||
max-height: 40vh;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
overflow-y: visible;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.recipe-loras-list {
|
||||
max-height: 45vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1045,19 +1321,11 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recipe-top-section {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-preview-container {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-gen-params {
|
||||
height: auto;
|
||||
max-height: 210px;
|
||||
.recipe-preview-container,
|
||||
.recipe-preview-container img,
|
||||
.recipe-preview-container video,
|
||||
.recipe-preview-media {
|
||||
max-height: 32vh;
|
||||
}
|
||||
|
||||
.recipe-gen-params h3 {
|
||||
@@ -1070,7 +1338,6 @@
|
||||
}
|
||||
|
||||
.param-content {
|
||||
max-height: 90px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
@@ -1083,10 +1350,6 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.recipe-bottom-section {
|
||||
padding-top: var(--space-1);
|
||||
}
|
||||
|
||||
.recipe-section-header {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
@@ -1098,69 +1361,78 @@
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
min-width: 110px;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Update the local-badge and missing-badge to be positioned within the badge-container */
|
||||
/* Badges are pure status indicators; actions live in .recipe-lora-actions */
|
||||
.badge-container .local-badge,
|
||||
.badge-container .missing-badge,
|
||||
.badge-container .deleted-badge {
|
||||
.badge-container .deleted-badge,
|
||||
.badge-container .invalid-hash-badge {
|
||||
position: static; /* Override absolute positioning */
|
||||
transform: none; /* Remove the transform */
|
||||
}
|
||||
|
||||
/* Ensure the tooltip is still properly positioned */
|
||||
.badge-container .local-badge .local-path {
|
||||
position: fixed; /* Keep as fixed for Chrome */
|
||||
z-index: 100;
|
||||
/* Tonal (soft) status badges: a tinted fill + colored text reads calmer
|
||||
than solid blocks when several rows stack, and matches the tonal
|
||||
"N missing" summary pill above the list. */
|
||||
.badge-container .local-badge {
|
||||
background: oklch(var(--lora-accent) / 0.12);
|
||||
color: var(--lora-accent);
|
||||
border: 1px solid oklch(var(--lora-accent) / 0.35);
|
||||
}
|
||||
|
||||
.badge-container .resource-action {
|
||||
margin-left: auto;
|
||||
.badge-container .missing-badge {
|
||||
background: oklch(var(--lora-error) / 0.14);
|
||||
color: var(--lora-error);
|
||||
border: 1px solid oklch(var(--lora-error) / 0.35);
|
||||
}
|
||||
|
||||
/* Add styles for missing LoRAs download feature */
|
||||
.recipe-status.missing {
|
||||
.badge-container .deleted-badge {
|
||||
background: rgba(127, 127, 127, 0.15);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(127, 127, 127, 0.35);
|
||||
}
|
||||
|
||||
.badge-container .invalid-hash-badge {
|
||||
background: oklch(var(--lora-warning) / 0.14);
|
||||
color: var(--lora-warning);
|
||||
border: 1px solid oklch(var(--lora-warning) / 0.35);
|
||||
}
|
||||
|
||||
/* Pin the recipe status-badge family (missing / deleted / invalid-hash) to its
|
||||
compact size. import-modal.css defines unscoped .missing-badge/.deleted-badge
|
||||
and is loaded AFTER this file, so without this higher-specificity rule its
|
||||
padding/font-size would clobber recipe modal's, leaving invalid-hash-badge
|
||||
(which has no import counterpart) at a different size. local-badge
|
||||
intentionally keeps the global shared.css size. */
|
||||
#recipeModal .badge-container .missing-badge,
|
||||
#recipeModal .badge-container .deleted-badge,
|
||||
#recipeModal .badge-container .invalid-hash-badge {
|
||||
padding: 3px 6px;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
/* Missing LoRAs status is a real button: the affordance must be visible at
|
||||
rest (persistent border), not only on hover. */
|
||||
.recipe-status.missing.clickable {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
font: inherit;
|
||||
background: oklch(var(--lora-error) / 0.12);
|
||||
color: var(--lora-error);
|
||||
border: 1px solid oklch(var(--lora-error) / 0.45);
|
||||
transition: background-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-status.missing:hover {
|
||||
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
|
||||
.recipe-status.missing.clickable:hover {
|
||||
background: oklch(var(--lora-error) / 0.22);
|
||||
box-shadow: 0 0 0 2px oklch(var(--lora-error) / 0.25);
|
||||
}
|
||||
|
||||
.recipe-status.missing .missing-tooltip {
|
||||
position: absolute;
|
||||
display: none;
|
||||
background-color: var(--card-bg);
|
||||
color: var(--text-color);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--shadow-header);
|
||||
z-index: var(--z-overlay);
|
||||
width: max-content;
|
||||
max-width: 200px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: normal;
|
||||
margin-left: -100px;
|
||||
margin-top: -65px;
|
||||
}
|
||||
|
||||
.recipe-status.missing:hover .missing-tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.recipe-status.clickable {
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.recipe-status.clickable:hover {
|
||||
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
|
||||
.recipe-status.missing.clickable:focus-visible {
|
||||
outline: 2px solid var(--lora-error);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-meta {
|
||||
@@ -1172,11 +1444,11 @@
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.recipe-checkpoint-meta .checkpoint-type {
|
||||
background: var(--lora-surface);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
color: var(--text-color);
|
||||
/* Checkpoint type is low-information text (the entry's position above the
|
||||
divider already implies "checkpoint"), so it renders as plain muted text
|
||||
instead of a chip competing with the base-model chip. */
|
||||
.recipe-checkpoint-meta .checkpoint-type-text {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.recipe-resource-actions {
|
||||
@@ -1220,3 +1492,148 @@
|
||||
.resource-action.primary:hover {
|
||||
background: color-mix(in oklch, var(--lora-accent), black 10%);
|
||||
}
|
||||
|
||||
/* Ghost variant: secondary remediation actions (e.g. Reconnect), matching
|
||||
the ghost action pattern used in the versions tab. */
|
||||
.resource-action.ghost {
|
||||
background: transparent;
|
||||
color: var(--lora-accent);
|
||||
border-color: oklch(var(--lora-accent) / 0.4);
|
||||
}
|
||||
|
||||
.resource-action.ghost:hover {
|
||||
background: oklch(var(--lora-accent) / 0.1);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
/* Per-item action row: remediation lives next to the status badge that
|
||||
surfaced the problem (download / reconnect / external link). Right-aligned
|
||||
so the reading order stays: name → status → meta → actions. */
|
||||
.recipe-lora-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* External-link affordance, mirroring .version-civitai-link in the
|
||||
versions tab: leaving the app is always an explicit, signposted action. */
|
||||
.recipe-civitai-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
flex: 0 0 auto;
|
||||
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.recipe-civitai-link:hover,
|
||||
.recipe-civitai-link:focus-visible {
|
||||
color: var(--lora-accent);
|
||||
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* In titles, size the icon box to the first line box (1em * 1.3 line-height)
|
||||
so it aligns with the first line of both short and wrapped names. */
|
||||
.recipe-lora-title .recipe-civitai-link {
|
||||
width: 20px;
|
||||
height: calc(1em * 1.3);
|
||||
}
|
||||
|
||||
/* Meta footer: de-emphasized location + recipe ID line below the modal body,
|
||||
mirroring the hash footnote in the shared model modal. Location sits left
|
||||
(tail of the path survives truncation), ID + copy button sit right. */
|
||||
.recipe-meta-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 6px;
|
||||
margin-top: 8px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 0.75em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.recipe-meta-footer[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.recipe-meta-location {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.recipe-meta-location i {
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.recipe-meta-location-path {
|
||||
font-family: var(--font-mono, monospace);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.recipe-meta-location:hover .recipe-meta-location-path,
|
||||
.recipe-meta-location:focus-visible .recipe-meta-location-path {
|
||||
opacity: 1;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.recipe-meta-location:focus-visible {
|
||||
outline: 1px solid var(--lora-accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.recipe-meta-id {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-meta-id-label {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.5;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
.recipe-meta-id-value {
|
||||
font-family: var(--font-mono, monospace);
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.recipe-meta-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 2px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.35;
|
||||
font-size: 0.95em;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.recipe-meta-copy-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9em;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
.toast.toast-copy.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
@@ -19,14 +19,15 @@
|
||||
right: 20px;
|
||||
left: auto;
|
||||
transform: translateX(120%);
|
||||
min-width: 300px;
|
||||
box-sizing: border-box;
|
||||
min-width: 200px;
|
||||
max-width: 400px;
|
||||
background: var(--lora-surface);
|
||||
color: var(--text-color);
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: var(--shadow-toast);
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
z-index: var(--z-toast);
|
||||
opacity: 0;
|
||||
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -130,7 +131,6 @@
|
||||
.toast {
|
||||
width: calc(100% - 40px);
|
||||
max-width: none;
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,16 +166,17 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Toast Container for stacked notifications */
|
||||
/* Toast Container for stacked notifications (top-right, flush below the header) */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
top: var(--header-height, 48px); /* Start right below the fixed header */
|
||||
right: 0;
|
||||
z-index: calc(var(--z-overlay) + 10);
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 20px;
|
||||
padding: 8px 20px 0; /* Small breathing room below the header */
|
||||
pointer-events: none; /* Allow clicking through the container */
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
@@ -215,8 +216,7 @@
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 480px) {
|
||||
.toast-container {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
--shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.25);
|
||||
--shadow-inset-top: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
|
||||
--modal-backdrop-blur: 6px;
|
||||
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 200ms ease;
|
||||
--transition-slow: 300ms ease;
|
||||
|
||||
@@ -1206,9 +1206,13 @@ export class BaseModelApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fetchUnifiedFolderTree() {
|
||||
async fetchUnifiedFolderTree(options = {}) {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||
const { includeEmpty = false } = options;
|
||||
const url = includeEmpty
|
||||
? `${this.apiConfig.endpoints.unifiedFolderTree}?include_empty=1`
|
||||
: this.apiConfig.endpoints.unifiedFolderTree;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch unified folder tree`);
|
||||
}
|
||||
@@ -1337,6 +1341,9 @@ export class BaseModelApiClient {
|
||||
if (pageState.searchOptions.creator !== undefined) {
|
||||
params.append('search_creator', pageState.searchOptions.creator.toString());
|
||||
}
|
||||
if (pageState.searchOptions.hash !== undefined) {
|
||||
params.append('search_hash', pageState.searchOptions.hash.toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,28 @@ export async function fetchRecipeDetails(recipeId) {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function sendRecipeWorkflow(recipeId) {
|
||||
if (!recipeId) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const encodedRecipeId = encodeURIComponent(recipeId);
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}/send-workflow`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { success: false, error: result.error || response.statusText };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recipes with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
@@ -152,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Add LoRA availability filter (no statuses selected = no filtering)
|
||||
if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) {
|
||||
params.append('lora_availability', pageState.filters.loraAvailability.join(','));
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
|
||||
@@ -28,8 +28,14 @@ export class Combobox {
|
||||
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
|
||||
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
|
||||
* Async function returning dynamic suggestions for the current input.
|
||||
* @param {string} [options.placeholder] Placeholder text for the empty state.
|
||||
* @param {string} [options.placeholder] Placeholder text for the input and the
|
||||
* dropdown empty state (see emptyText to override the latter).
|
||||
* @param {string} [options.emptyText] Text for the dropdown empty state;
|
||||
* defaults to `placeholder`, then 'No options'. Unlike `placeholder`
|
||||
* it never touches the input element.
|
||||
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
|
||||
* @param {(value: string) => void} [options.onCommit] Callback when Enter is
|
||||
* pressed without a highlighted option (free-text commit).
|
||||
*/
|
||||
constructor(inputElement, options = {}) {
|
||||
if (!inputElement || inputElement.tagName !== 'INPUT') {
|
||||
@@ -41,7 +47,9 @@ export class Combobox {
|
||||
this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
|
||||
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
|
||||
this.placeholder = options.placeholder || '';
|
||||
this.emptyText = options.emptyText || '';
|
||||
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
|
||||
this.onCommit = typeof options.onCommit === 'function' ? options.onCommit : null;
|
||||
|
||||
// Internal state
|
||||
this._isOpen = false;
|
||||
@@ -109,19 +117,24 @@ export class Combobox {
|
||||
// ---- event wiring ----
|
||||
|
||||
_bindEvents() {
|
||||
this.input.addEventListener('focus', () => {
|
||||
// Keep references so destroy() can detach input listeners — callers
|
||||
// may destroy a Combobox while its input stays in the DOM.
|
||||
this._focusHandler = () => {
|
||||
if (this._suppressInputOpen) return;
|
||||
this._open();
|
||||
});
|
||||
};
|
||||
this.input.addEventListener('focus', this._focusHandler);
|
||||
|
||||
this.input.addEventListener('input', () => {
|
||||
this._inputHandler = () => {
|
||||
if (this._suppressInputOpen) return;
|
||||
this._open(); // no-op if already open
|
||||
this._refresh(); // re-filter by current input value
|
||||
this._scheduleFetch();
|
||||
});
|
||||
};
|
||||
this.input.addEventListener('input', this._inputHandler);
|
||||
|
||||
this.input.addEventListener('keydown', (event) => this._onKeyDown(event));
|
||||
this._keyDownHandler = (event) => this._onKeyDown(event);
|
||||
this.input.addEventListener('keydown', this._keyDownHandler);
|
||||
|
||||
// Click an option (delegated)
|
||||
this.panel.addEventListener('click', (event) => {
|
||||
@@ -167,6 +180,9 @@ export class Combobox {
|
||||
event.preventDefault();
|
||||
this._open();
|
||||
this._setActiveIndex(0);
|
||||
} else if (event.key === 'Enter' && typeof this.onCommit === 'function') {
|
||||
event.preventDefault();
|
||||
this.onCommit(this.input.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -184,11 +200,17 @@ export class Combobox {
|
||||
|
||||
case 'Enter':
|
||||
// Only intercept Enter to pick an option when one is actively
|
||||
// highlighted; otherwise let the input's default behavior
|
||||
// (form submit / free-text commit) proceed.
|
||||
// highlighted; otherwise commit the free-text value (when an
|
||||
// onCommit handler is registered) and let the input's default
|
||||
// behavior proceed otherwise.
|
||||
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
|
||||
event.preventDefault();
|
||||
this._choose(this._renderedOptions[this._activeIndex]);
|
||||
} else if (typeof this.onCommit === 'function') {
|
||||
event.preventDefault();
|
||||
const value = this.input.value;
|
||||
this._close();
|
||||
this.onCommit(value);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -254,7 +276,7 @@ export class Combobox {
|
||||
if (items.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'lm-combobox-empty';
|
||||
empty.textContent = this.placeholder ? this.placeholder : 'No options';
|
||||
empty.textContent = this.emptyText || this.placeholder || 'No options';
|
||||
this.panel.appendChild(empty);
|
||||
this._activeIndex = -1;
|
||||
return;
|
||||
@@ -333,11 +355,19 @@ export class Combobox {
|
||||
if (this.panel && this.panel.parentNode) {
|
||||
this.panel.parentNode.removeChild(this.panel);
|
||||
}
|
||||
this.input.removeEventListener('focus', this._focusHandler);
|
||||
this.input.removeEventListener('input', this._inputHandler);
|
||||
this.input.removeEventListener('keydown', this._keyDownHandler);
|
||||
document.removeEventListener('mousedown', this._outsideClickHandler);
|
||||
window.removeEventListener('resize', this._resizeHandler);
|
||||
window.removeEventListener('scroll', this._resizeHandler, true);
|
||||
}
|
||||
|
||||
/** Whether the dropdown panel is currently open. */
|
||||
isOpen() {
|
||||
return this._isOpen;
|
||||
}
|
||||
|
||||
_choose(value) {
|
||||
this.input.value = value;
|
||||
this._close();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,8 +37,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
|
||||
if (recipeId && missingLorasItem) {
|
||||
// Check if this card has missing LoRAs
|
||||
const loraCountElement = card.querySelector('.lora-count');
|
||||
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
|
||||
const hasMissingLoras = Boolean(card.querySelector('.lora-count.missing'));
|
||||
|
||||
// Show/hide the download missing LoRAs option based on missing status
|
||||
if (hasMissingLoras) {
|
||||
@@ -205,8 +204,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
const recipe = await response.json();
|
||||
|
||||
// Get missing LoRAs
|
||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
|
||||
// Get missing LoRAs (still downloadable: not deleted from the
|
||||
// source and hash still resolvable)
|
||||
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid);
|
||||
|
||||
if (missingLoras.length === 0) {
|
||||
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+202
-127
@@ -41,9 +41,36 @@ class RecipeCard {
|
||||
const loras = this.recipe.loras || [];
|
||||
const lorasCount = loras.length;
|
||||
|
||||
// Check if all LoRAs are available in the library
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
|
||||
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
|
||||
// Count LoRAs by availability: in library, missing (still downloadable
|
||||
// from the source), or unobtainable (deleted from the source, or an
|
||||
// unresolvable hash) which is silently skipped when the recipe is used.
|
||||
const availableLorasCount = loras.filter(lora => lora.inLibrary).length;
|
||||
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid).length;
|
||||
const unavailableLorasCount = lorasCount - availableLorasCount - missingLorasCount;
|
||||
|
||||
// Compact status pill: state icon + available/total fraction.
|
||||
// Icon switches by state so status never relies on color alone.
|
||||
// - missing (red): something can still be downloaded, most actionable
|
||||
// - partial (amber): usable but degraded, unobtainable LoRAs are skipped
|
||||
// - unavailable (gray, ban): no usable LoRA at all
|
||||
let loraCountStateClass = '';
|
||||
let loraCountIcon = 'fa-layer-group';
|
||||
if (lorasCount > 0) {
|
||||
if (availableLorasCount === lorasCount) {
|
||||
loraCountStateClass = 'ready';
|
||||
loraCountIcon = 'fa-check';
|
||||
} else if (missingLorasCount > 0) {
|
||||
loraCountStateClass = 'missing';
|
||||
loraCountIcon = 'fa-exclamation-triangle';
|
||||
} else if (availableLorasCount > 0) {
|
||||
loraCountStateClass = 'partial';
|
||||
loraCountIcon = 'fa-circle-minus';
|
||||
} else {
|
||||
loraCountStateClass = 'unavailable';
|
||||
loraCountIcon = 'fa-ban';
|
||||
}
|
||||
}
|
||||
const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`;
|
||||
|
||||
// Ensure file_url exists, fallback to API URL if needed
|
||||
let previewUrl = this.recipe.file_url;
|
||||
@@ -128,9 +155,8 @@ class RecipeCard {
|
||||
<span class="model-name">${this.recipe.title}</span>
|
||||
</div>
|
||||
${!isDuplicatesMode ? `
|
||||
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
|
||||
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
|
||||
<i class="fas fa-layer-group"></i> ${lorasCount}
|
||||
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount)}">
|
||||
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
@@ -148,10 +174,39 @@ class RecipeCard {
|
||||
return card;
|
||||
}
|
||||
|
||||
getLoraStatusTitle(totalCount, missingCount) {
|
||||
if (totalCount === 0) return "No LoRAs in this recipe";
|
||||
if (missingCount === 0) return "All LoRAs available - Ready to use";
|
||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||
getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
|
||||
if (totalCount === 0) {
|
||||
return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
|
||||
}
|
||||
if (availableCount === totalCount) {
|
||||
return translate('recipes.loraStatus.allAvailable', {}, 'All LoRAs available - Ready to use');
|
||||
}
|
||||
if (missingCount > 0 && unavailableCount > 0) {
|
||||
return translate(
|
||||
'recipes.loraStatus.missingAndUnavailable',
|
||||
{ missing: missingCount, unavailable: unavailableCount, total: totalCount },
|
||||
`${missingCount} of ${totalCount} LoRAs missing, ${unavailableCount} unavailable (deleted from source or unresolvable hash)`
|
||||
);
|
||||
}
|
||||
if (missingCount > 0) {
|
||||
return translate(
|
||||
'recipes.loraStatus.missing',
|
||||
{ missing: missingCount, total: totalCount },
|
||||
`${missingCount} of ${totalCount} LoRAs missing`
|
||||
);
|
||||
}
|
||||
if (availableCount > 0) {
|
||||
return translate(
|
||||
'recipes.loraStatus.partial',
|
||||
{ unavailable: unavailableCount, total: totalCount },
|
||||
`${unavailableCount} of ${totalCount} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used`
|
||||
);
|
||||
}
|
||||
return translate(
|
||||
'recipes.loraStatus.noneUsable',
|
||||
{ unavailable: unavailableCount, total: totalCount },
|
||||
`No usable LoRAs - ${unavailableCount} of ${totalCount} deleted from source or unresolvable hash`
|
||||
);
|
||||
}
|
||||
|
||||
async toggleFavorite(card) {
|
||||
@@ -240,9 +295,12 @@ class RecipeCard {
|
||||
|
||||
// Recipe card click event - only attach if not in duplicates mode
|
||||
if (!isDuplicatesMode) {
|
||||
card.addEventListener('click', () => {
|
||||
card.addEventListener('click', (e) => {
|
||||
if (state.bulkMode) {
|
||||
bulkManager.toggleCardSelection(card);
|
||||
if (e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
bulkManager.toggleCardSelection(card, e.shiftKey);
|
||||
return;
|
||||
}
|
||||
this.clickHandler(this.recipe);
|
||||
@@ -339,124 +397,11 @@ class RecipeCard {
|
||||
}
|
||||
|
||||
showDeleteConfirmation() {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = this.recipe.id;
|
||||
const filePath = this.recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${this.recipe.title}</h3>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
deleteModal.dataset.filePath = filePath;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => this.confirmDeleteRecipe();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
showRecipeDeleteConfirmation(this.recipe);
|
||||
}
|
||||
|
||||
confirmDeleteRecipe() {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
confirmRecipeDelete(this.recipe);
|
||||
}
|
||||
|
||||
shareRecipe() {
|
||||
@@ -507,4 +452,134 @@ class RecipeCard {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the delete confirmation modal for a recipe. Shared by RecipeCard and
|
||||
* RecipeModal so the flow stays identical regardless of where it starts.
|
||||
* @param {Object} recipe - The recipe to delete
|
||||
*/
|
||||
export function showRecipeDeleteConfirmation(recipe) {
|
||||
try {
|
||||
// Get recipe ID
|
||||
const recipeId = recipe.id;
|
||||
const filePath = recipe.file_path;
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create delete modal content
|
||||
const previewUrl = recipe.file_url || '/loras_static/images/no-preview.png';
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
|
||||
const deleteModalContent = `
|
||||
<div class="modal-content delete-modal-content">
|
||||
<h2>Delete Recipe</h2>
|
||||
<p class="delete-message">Are you sure you want to delete this recipe?</p>
|
||||
<div class="delete-model-info">
|
||||
<div class="delete-preview">
|
||||
${isVideo ?
|
||||
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
|
||||
`<img src="${previewUrl}" alt="${recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
|
||||
}
|
||||
</div>
|
||||
<div class="delete-info">
|
||||
<h3>${recipe.title}</h3>
|
||||
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
|
||||
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show the modal with custom content and setup callbacks
|
||||
modalManager.showModal('deleteModal', deleteModalContent, () => {
|
||||
// This is the onClose callback
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
deleteBtn.textContent = 'Delete';
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Set up the delete and cancel buttons with proper event handlers
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelBtn = deleteModal.querySelector('.cancel-btn');
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
|
||||
// Store recipe ID in the modal for the delete confirmation handler
|
||||
deleteModal.dataset.recipeId = recipeId;
|
||||
deleteModal.dataset.filePath = filePath;
|
||||
|
||||
// Update button event handlers
|
||||
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
|
||||
deleteBtn.onclick = () => confirmRecipeDelete(recipe);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error showing delete confirmation:', error);
|
||||
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the recipe deletion after the user confirms in the delete modal.
|
||||
* @param {Object} recipe - The recipe being deleted (used for toast messaging)
|
||||
*/
|
||||
function confirmRecipeDelete(recipe) {
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const recipeId = deleteModal.dataset.recipeId;
|
||||
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.cannotDelete', {}, 'error');
|
||||
modalManager.closeModal('deleteModal');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
const deleteBtn = deleteModal.querySelector('.delete-btn');
|
||||
const originalText = deleteBtn.textContent;
|
||||
deleteBtn.textContent = 'Deleting...';
|
||||
deleteBtn.disabled = true;
|
||||
|
||||
// Call API to delete the recipe
|
||||
fetch(`/api/lm/recipe/${recipeId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete recipe');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.batch_id) {
|
||||
// Staged delete: offer undo instead of the plain success toast
|
||||
const batchId = data.batch_id;
|
||||
showActionToast('toast.undo.deleted', { name: recipe.title }, 'success', {
|
||||
actionText: translate('toast.undo.action'),
|
||||
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||
});
|
||||
} else {
|
||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||
}
|
||||
|
||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||
|
||||
modalManager.closeModal('deleteModal');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error deleting recipe:', error);
|
||||
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
|
||||
|
||||
// Reset button state
|
||||
deleteBtn.textContent = originalText;
|
||||
deleteBtn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
export { RecipeCard };
|
||||
|
||||
+1557
-191
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user