mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed7cf418b4 |
@@ -0,0 +1,146 @@
|
||||
---
|
||||
name: lora-manager-e2e
|
||||
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
|
||||
|
||||
End-to-end testing of LoRa Manager standalone mode using Chrome DevTools MCP.
|
||||
|
||||
## When to Use — and When NOT To
|
||||
|
||||
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)
|
||||
|
||||
> Every E2E run MUST target a throwaway sandbox, never real user 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"
|
||||
}
|
||||
```
|
||||
|
||||
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/`:
|
||||
|
||||
```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.
|
||||
```
|
||||
|
||||
## 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"
|
||||
# 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
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Server restart after config/fixture changes:
|
||||
|
||||
```bash
|
||||
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)
|
||||
```
|
||||
|
||||
`--restart` only kills the E2E server the script itself started (via its pidfile) and
|
||||
aborts instead of killing unrelated processes on the port.
|
||||
|
||||
## Abort Rule
|
||||
|
||||
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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"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
|
||||
|
||||
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. `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,360 @@
|
||||
# Chrome DevTools MCP Cheatsheet for LoRa Manager
|
||||
|
||||
Quick reference for common MCP commands used in LoRa Manager E2E testing.
|
||||
|
||||
> **Port convention**: `{PORT}` is the port chosen for the E2E run (default candidate `8188`, but only if actually free — see the SKILL.md Port Selection section; use e.g. `8199` when `8188` is occupied by a live ComfyUI). Always run against the **sandboxed** standalone server, never a live instance.
|
||||
|
||||
## Navigation
|
||||
|
||||
```python
|
||||
# Navigate to LoRA list page
|
||||
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||
|
||||
# Reload page with cache clear
|
||||
navigate_page(type="reload", ignoreCache=True)
|
||||
|
||||
# Go back/forward
|
||||
navigate_page(type="back")
|
||||
navigate_page(type="forward")
|
||||
```
|
||||
|
||||
## Waiting
|
||||
|
||||
```python
|
||||
# Wait for text to appear
|
||||
wait_for(text="LoRAs", timeout=10000)
|
||||
|
||||
# Wait for specific element (via evaluate_script)
|
||||
evaluate_script(function="""
|
||||
() => {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (document.querySelector('.lora-card')) {
|
||||
resolve(true);
|
||||
} else {
|
||||
setTimeout(check, 100);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
""")
|
||||
```
|
||||
|
||||
## Taking Snapshots
|
||||
|
||||
```python
|
||||
# Full page snapshot
|
||||
snapshot = take_snapshot()
|
||||
|
||||
# Verbose snapshot (more details)
|
||||
snapshot = take_snapshot(verbose=True)
|
||||
|
||||
# Save to file
|
||||
take_snapshot(filePath="test-snapshots/page-load.json")
|
||||
```
|
||||
|
||||
## Element Interaction
|
||||
|
||||
```python
|
||||
# Click element
|
||||
click(uid="element-uid-from-snapshot")
|
||||
|
||||
# Double click
|
||||
click(uid="element-uid", dblClick=True)
|
||||
|
||||
# Fill input
|
||||
fill(uid="search-input", value="test query")
|
||||
|
||||
# Fill multiple inputs
|
||||
fill_form(elements=[
|
||||
{"uid": "input-1", "value": "value 1"},
|
||||
{"uid": "input-2", "value": "value 2"},
|
||||
])
|
||||
|
||||
# Hover
|
||||
hover(uid="lora-card-1")
|
||||
|
||||
# Upload file
|
||||
upload_file(uid="file-input", filePath="/path/to/file.safetensors")
|
||||
```
|
||||
|
||||
## Keyboard Input
|
||||
|
||||
```python
|
||||
# Press key
|
||||
press_key(key="Enter")
|
||||
press_key(key="Escape")
|
||||
press_key(key="Tab")
|
||||
|
||||
# Keyboard shortcuts
|
||||
press_key(key="Control+A") # Select all
|
||||
press_key(key="Control+F") # Find
|
||||
```
|
||||
|
||||
## JavaScript Evaluation
|
||||
|
||||
```python
|
||||
# Simple evaluation
|
||||
result = evaluate_script(function="() => document.title")
|
||||
|
||||
# Async evaluation
|
||||
result = evaluate_script(function="""
|
||||
async () => {
|
||||
const response = await fetch('/loras/api/list');
|
||||
return await response.json();
|
||||
}
|
||||
""")
|
||||
|
||||
# Check element existence
|
||||
exists = evaluate_script(function="""
|
||||
() => document.querySelector('.lora-card') !== null
|
||||
""")
|
||||
|
||||
# Get element count
|
||||
count = evaluate_script(function="""
|
||||
() => document.querySelectorAll('.lora-card').length
|
||||
""")
|
||||
```
|
||||
|
||||
## Network Monitoring
|
||||
|
||||
```python
|
||||
# List all network requests
|
||||
requests = list_network_requests()
|
||||
|
||||
# Filter by resource type
|
||||
xhr_requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
||||
|
||||
# Get specific request details
|
||||
details = get_network_request(reqid=123)
|
||||
|
||||
# Include preserved requests from previous navigations
|
||||
all_requests = list_network_requests(includePreservedRequests=True)
|
||||
```
|
||||
|
||||
## Console Monitoring
|
||||
|
||||
```python
|
||||
# List all console messages
|
||||
messages = list_console_messages()
|
||||
|
||||
# Filter by type
|
||||
errors = list_console_messages(types=["error", "warn"])
|
||||
|
||||
# Include preserved messages
|
||||
all_messages = list_console_messages(includePreservedMessages=True)
|
||||
|
||||
# Get specific message
|
||||
details = get_console_message(msgid=1)
|
||||
```
|
||||
|
||||
## Performance Testing
|
||||
|
||||
```python
|
||||
# Start trace with page reload
|
||||
performance_start_trace(reload=True, autoStop=False)
|
||||
|
||||
# Start trace without reload
|
||||
performance_start_trace(reload=False, autoStop=True, filePath="trace.json.gz")
|
||||
|
||||
# Stop trace
|
||||
results = performance_stop_trace()
|
||||
|
||||
# Stop and save
|
||||
performance_stop_trace(filePath="trace-results.json.gz")
|
||||
|
||||
# Analyze specific insight
|
||||
insight = performance_analyze_insight(
|
||||
insightSetId="results.insightSets[0].id",
|
||||
insightName="LCPBreakdown"
|
||||
)
|
||||
```
|
||||
|
||||
## Page Management
|
||||
|
||||
```python
|
||||
# List open pages
|
||||
pages = list_pages()
|
||||
|
||||
# Select a page
|
||||
select_page(pageId=0, bringToFront=True)
|
||||
|
||||
# Create new page
|
||||
new_page(url="http://127.0.0.1:{PORT}/loras")
|
||||
|
||||
# Close page (keep at least one open!)
|
||||
close_page(pageId=1)
|
||||
|
||||
# Resize page
|
||||
resize_page(width=1920, height=1080)
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||
```python
|
||||
# Full page screenshot
|
||||
take_screenshot(fullPage=True)
|
||||
|
||||
# Viewport screenshot
|
||||
take_screenshot()
|
||||
|
||||
# Element screenshot
|
||||
take_screenshot(uid="lora-card-1")
|
||||
|
||||
# Save to file
|
||||
take_screenshot(filePath="screenshots/page.png", format="png")
|
||||
|
||||
# JPEG with quality
|
||||
take_screenshot(filePath="screenshots/page.jpg", format="jpeg", quality=90)
|
||||
```
|
||||
|
||||
## Dialog Handling
|
||||
|
||||
```python
|
||||
# Accept dialog
|
||||
handle_dialog(action="accept")
|
||||
|
||||
# Accept with text input
|
||||
handle_dialog(action="accept", promptText="user input")
|
||||
|
||||
# Dismiss dialog
|
||||
handle_dialog(action="dismiss")
|
||||
```
|
||||
|
||||
## Device Emulation
|
||||
|
||||
```python
|
||||
# Mobile viewport
|
||||
emulate(viewport={"width": 375, "height": 667, "isMobile": True, "hasTouch": True})
|
||||
|
||||
# Tablet viewport
|
||||
emulate(viewport={"width": 768, "height": 1024, "isMobile": True, "hasTouch": True})
|
||||
|
||||
# Desktop viewport
|
||||
emulate(viewport={"width": 1920, "height": 1080})
|
||||
|
||||
# Network throttling
|
||||
emulate(networkConditions="Slow 3G")
|
||||
emulate(networkConditions="Fast 4G")
|
||||
|
||||
# CPU throttling
|
||||
emulate(cpuThrottlingRate=4) # 4x slowdown
|
||||
|
||||
# Geolocation
|
||||
emulate(geolocation={"latitude": 37.7749, "longitude": -122.4194})
|
||||
|
||||
# User agent
|
||||
emulate(userAgent="Mozilla/5.0 (Custom)")
|
||||
|
||||
# Reset emulation
|
||||
emulate(viewport=None, networkConditions="No emulation", userAgent=None)
|
||||
```
|
||||
|
||||
## Drag and Drop
|
||||
|
||||
```python
|
||||
# Drag element to another
|
||||
drag(from_uid="draggable-item", to_uid="drop-zone")
|
||||
```
|
||||
|
||||
## Common LoRa Manager Test Patterns
|
||||
|
||||
### Verify LoRA Cards Loaded
|
||||
|
||||
```python
|
||||
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||
wait_for(text="LoRAs", timeout=10000)
|
||||
|
||||
# Check if cards loaded
|
||||
result = evaluate_script(function="""
|
||||
() => {
|
||||
const cards = document.querySelectorAll('.lora-card');
|
||||
return {
|
||||
count: cards.length,
|
||||
hasData: cards.length > 0
|
||||
};
|
||||
}
|
||||
""")
|
||||
```
|
||||
|
||||
### Search and Verify Results
|
||||
|
||||
```python
|
||||
fill(uid="search-input", value="character")
|
||||
press_key(key="Enter")
|
||||
wait_for(timeout=2000) # Wait for debounce
|
||||
|
||||
# Check results
|
||||
result = evaluate_script(function="""
|
||||
() => {
|
||||
const cards = document.querySelectorAll('.lora-card');
|
||||
const names = Array.from(cards).map(c => c.dataset.name || c.textContent);
|
||||
return { count: cards.length, names };
|
||||
}
|
||||
""")
|
||||
```
|
||||
|
||||
### Check API Response
|
||||
|
||||
```python
|
||||
# Trigger API call
|
||||
evaluate_script(function="""
|
||||
() => window.loraApiCallPromise = fetch('/loras/api/list').then(r => r.json())
|
||||
""")
|
||||
|
||||
# Wait and get result
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
result = evaluate_script(function="""
|
||||
async () => await window.loraApiCallPromise
|
||||
""")
|
||||
```
|
||||
|
||||
### Monitor Console for Errors
|
||||
|
||||
```python
|
||||
# Before test: clear console (navigate reloads)
|
||||
navigate_page(type="reload")
|
||||
|
||||
# ... perform actions ...
|
||||
|
||||
# Check for errors
|
||||
errors = list_console_messages(types=["error"])
|
||||
assert len(errors) == 0, f"Console errors: {errors}"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Stale profile lock ("browser is already running" / `list_pages` fails)
|
||||
|
||||
A Chrome profile held by a stale Chrome from a prior MCP session makes `list_pages`
|
||||
fail with "browser is already running". Fix:
|
||||
|
||||
1. Find the stale Chrome that owns the profile dir (e.g. `~/.config/chrome-dev-profile`):
|
||||
```bash
|
||||
ps -ef | grep -i '[c]hrome.*user-data-dir'
|
||||
```
|
||||
2. Confirm it is a QA Chrome from a completed task (NOT the live ComfyUI server, NOT
|
||||
your current MCP instance).
|
||||
3. Kill ONLY that stale Chrome (`kill <stale-pid>`), then retry `list_pages`.
|
||||
|
||||
### Screenshot-write restrictions
|
||||
|
||||
The MCP may refuse to write into paths outside its configured workspace roots
|
||||
(e.g. `.omo/evidence/screenshots/` under a worktree that canonicalizes to an unmapped
|
||||
path). Save the screenshot to `/tmp` via the MCP, then copy it into the evidence dir:
|
||||
|
||||
```bash
|
||||
# MCP: take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
|
||||
# Shell:
|
||||
mkdir -p <repo-root>/.omo/evidence/screenshots
|
||||
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
|
||||
```
|
||||
|
||||
### Time budgets & abort rule
|
||||
|
||||
See SKILL.md "Time Budgets & Abort Guidance": if a phase exceeds ~2x its budget or a
|
||||
tool call retries 3+ times in a row, STOP and report BLOCKED with the last observed
|
||||
state (server PID + `ss -tlnp`, page snapshot, last API response). Do not loop.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,280 @@
|
||||
# LoRa Manager E2E Test Scenarios
|
||||
|
||||
This document provides detailed test scenarios for end-to-end validation of LoRa Manager features.
|
||||
|
||||
> **Run preconditions (from SKILL.md)**: every run uses the **sandboxed** standalone
|
||||
> server on a free port `{PORT}` (default candidate `8188`, only if actually free — pick
|
||||
> e.g. `8199` when `8188` is occupied by a live ComfyUI). Fixtures live in the sandboxed
|
||||
> `recipes_path` as `f"{id}.recipe.json"` files with matching in-JSON `id`; the real user
|
||||
> config and real library are never touched (record protection proof before/after).
|
||||
> Abort if a phase exceeds ~2x its budget or a tool call retries 3+ times (SKILL.md
|
||||
> "Time Budgets & Abort Guidance").
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [LoRA List Page](#lora-list-page)
|
||||
2. [Model Details](#model-details)
|
||||
3. [Recipes](#recipes)
|
||||
4. [Settings](#settings)
|
||||
5. [Import/Export](#importexport)
|
||||
|
||||
---
|
||||
|
||||
## LoRA List Page
|
||||
|
||||
### Scenario: Page Load and Display
|
||||
|
||||
**Objective**: Verify the LoRA list page loads correctly and displays models.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to `http://127.0.0.1:{PORT}/loras`
|
||||
2. Wait for page title "LoRAs" to appear
|
||||
3. Take snapshot to verify:
|
||||
- Header with "LoRAs" title is visible
|
||||
- Search/filter controls are present
|
||||
- Grid/list view toggle exists
|
||||
- LoRA cards are displayed (if models exist)
|
||||
- Pagination controls (if applicable)
|
||||
|
||||
**Expected Result**: Page loads without errors, UI elements are present.
|
||||
|
||||
### Scenario: Search Functionality
|
||||
|
||||
**Objective**: Verify search filters LoRA models correctly.
|
||||
|
||||
**Steps**:
|
||||
1. Ensure at least one LoRA exists with known name (e.g., "test-character")
|
||||
2. Navigate to LoRA list page
|
||||
3. Enter search term in search box: "test"
|
||||
4. Press Enter or click search button
|
||||
5. Wait for results to update
|
||||
|
||||
**Expected Result**: Only LoRAs matching search term are displayed.
|
||||
|
||||
**Verification Script**:
|
||||
```python
|
||||
# After search, verify filtered results
|
||||
evaluate_script(function="""
|
||||
() => {
|
||||
const cards = document.querySelectorAll('.lora-card');
|
||||
const names = Array.from(cards).map(c => c.dataset.name);
|
||||
return { count: cards.length, names };
|
||||
}
|
||||
""")
|
||||
```
|
||||
|
||||
### Scenario: Filter by Tags
|
||||
|
||||
**Objective**: Verify tag filtering works correctly.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to LoRA list page
|
||||
2. Click on a tag (e.g., "character", "style")
|
||||
3. Wait for filtered results
|
||||
|
||||
**Expected Result**: Only LoRAs with selected tag are displayed.
|
||||
|
||||
### Scenario: View Mode Toggle
|
||||
|
||||
**Objective**: Verify grid/list view toggle works.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to LoRA list page
|
||||
2. Click list view button
|
||||
3. Verify list layout
|
||||
4. Click grid view button
|
||||
5. Verify grid layout
|
||||
|
||||
**Expected Result**: View mode changes correctly, layout updates.
|
||||
|
||||
---
|
||||
|
||||
## Model Details
|
||||
|
||||
### Scenario: Open Model Details
|
||||
|
||||
**Objective**: Verify clicking a LoRA opens its details.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to LoRA list page
|
||||
2. Click on a LoRA card
|
||||
3. Wait for details panel/modal to open
|
||||
|
||||
**Expected Result**: Details panel shows:
|
||||
- Model name
|
||||
- Preview image
|
||||
- Metadata (trigger words, tags, etc.)
|
||||
- Action buttons (edit, delete, etc.)
|
||||
|
||||
### Scenario: Edit Model Metadata
|
||||
|
||||
**Objective**: Verify metadata editing works end-to-end.
|
||||
|
||||
**Steps**:
|
||||
1. Open a LoRA's details
|
||||
2. Click "Edit" button
|
||||
3. Modify trigger words field
|
||||
4. Add/remove tags
|
||||
5. Save changes
|
||||
6. Refresh page
|
||||
7. Reopen the same LoRA
|
||||
|
||||
**Expected Result**: Changes persist after refresh.
|
||||
|
||||
### Scenario: Delete Model
|
||||
|
||||
**Objective**: Verify model deletion works.
|
||||
|
||||
**Steps**:
|
||||
1. Open a LoRA's details
|
||||
2. Click "Delete" button
|
||||
3. Confirm deletion in dialog
|
||||
4. Wait for removal
|
||||
|
||||
**Expected Result**: Model removed from list, success message shown.
|
||||
|
||||
---
|
||||
|
||||
## Recipes
|
||||
|
||||
### Scenario: Recipe List Display
|
||||
|
||||
**Objective**: Verify recipes page loads and displays recipes.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to `http://127.0.0.1:{PORT}/recipes`
|
||||
2. Wait for "Recipes" title
|
||||
3. Take snapshot
|
||||
|
||||
**Expected Result**: Recipe list displayed with cards/items.
|
||||
|
||||
### Scenario: Create New Recipe
|
||||
|
||||
**Objective**: Verify recipe creation workflow.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to recipes page
|
||||
2. Click "New Recipe" button
|
||||
3. Fill recipe form:
|
||||
- Name: "Test Recipe"
|
||||
- Description: "E2E test recipe"
|
||||
- Add LoRA models
|
||||
4. Save recipe
|
||||
5. Verify recipe appears in list
|
||||
|
||||
**Expected Result**: New recipe created and displayed.
|
||||
|
||||
### Scenario: Apply Recipe
|
||||
|
||||
**Objective**: Verify applying a recipe to ComfyUI.
|
||||
|
||||
**Steps**:
|
||||
1. Open a recipe
|
||||
2. Click "Apply" or "Load in ComfyUI"
|
||||
3. Verify action completes
|
||||
|
||||
**Expected Result**: Recipe applied successfully.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
### Scenario: Settings Page Load
|
||||
|
||||
**Objective**: Verify settings page displays correctly.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to `http://127.0.0.1:{PORT}/settings`
|
||||
2. Wait for "Settings" title
|
||||
3. Take snapshot
|
||||
|
||||
**Expected Result**: Settings form with various options displayed.
|
||||
|
||||
### Scenario: Change Setting and Restart
|
||||
|
||||
**Objective**: Verify settings persist after restart.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to settings page
|
||||
2. Change a setting (e.g., default view mode)
|
||||
3. Save settings
|
||||
4. Restart server: `python scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach`
|
||||
5. Refresh browser page
|
||||
6. Navigate to settings
|
||||
|
||||
**Expected Result**: Changed setting value persists.
|
||||
|
||||
---
|
||||
|
||||
## Import/Export
|
||||
|
||||
### Scenario: Export Models List
|
||||
|
||||
**Objective**: Verify export functionality.
|
||||
|
||||
**Steps**:
|
||||
1. Navigate to LoRA list
|
||||
2. Click "Export" button
|
||||
3. Select format (JSON/CSV)
|
||||
4. Download file
|
||||
|
||||
**Expected Result**: File downloaded with correct data.
|
||||
|
||||
### Scenario: Import Models
|
||||
|
||||
**Objective**: Verify import functionality.
|
||||
|
||||
**Steps**:
|
||||
1. Prepare import file
|
||||
2. Navigate to import page
|
||||
3. Upload file
|
||||
4. Verify import results
|
||||
|
||||
**Expected Result**: Models imported successfully, confirmation shown.
|
||||
|
||||
---
|
||||
|
||||
## API Integration Tests
|
||||
|
||||
### Scenario: Verify API Endpoints
|
||||
|
||||
**Objective**: Verify backend API responds correctly.
|
||||
|
||||
**Test via browser console**:
|
||||
```javascript
|
||||
// List LoRAs
|
||||
fetch('/loras/api/list').then(r => r.json()).then(console.log)
|
||||
|
||||
// Get LoRA details
|
||||
fetch('/loras/api/detail/<id>').then(r => r.json()).then(console.log)
|
||||
|
||||
// Search LoRAs
|
||||
fetch('/loras/api/search?q=test').then(r => r.json()).then(console.log)
|
||||
```
|
||||
|
||||
**Expected Result**: APIs return valid JSON with expected structure.
|
||||
|
||||
---
|
||||
|
||||
## Console Error Monitoring
|
||||
|
||||
During all tests, monitor browser console for errors:
|
||||
|
||||
```python
|
||||
# Check for JavaScript errors
|
||||
messages = list_console_messages(types=["error"])
|
||||
assert len(messages) == 0, f"Console errors found: {messages}"
|
||||
```
|
||||
|
||||
## Network Request Verification
|
||||
|
||||
Verify key API calls are made:
|
||||
|
||||
```python
|
||||
# List XHR requests
|
||||
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
||||
|
||||
# Look for specific endpoints
|
||||
lora_list_requests = [r for r in requests if "/api/list" in r.get("url", "")]
|
||||
assert len(lora_list_requests) > 0, "LoRA list API not called"
|
||||
```
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example E2E test demonstrating LoRa Manager testing workflow.
|
||||
|
||||
This script shows how to:
|
||||
1. Start the standalone server
|
||||
2. Use Chrome DevTools MCP to interact with the UI
|
||||
3. Verify functionality end-to-end
|
||||
|
||||
Note: This is a template. Actual execution requires Chrome DevTools MCP.
|
||||
|
||||
Port: pick a FREE port for the run — 8188 is commonly occupied by a live
|
||||
ComfyUI (see the skill's Port Selection section). Set PORT below to e.g. 8199
|
||||
when 8188 is taken. Always run against a SANDBOXED standalone server.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
# Choose the E2E port. 8188 is only the default candidate; use 8199 (or any
|
||||
# free port checked with `ss -tlnp`) when 8188 is occupied by a live ComfyUI.
|
||||
PORT = "8188"
|
||||
|
||||
|
||||
def run_test():
|
||||
"""Run example E2E test flow."""
|
||||
|
||||
print("=" * 60)
|
||||
print("LoRa Manager E2E Test Example")
|
||||
print("=" * 60)
|
||||
|
||||
# Step 1: Start server (detached so it survives the shell)
|
||||
print("\n[1/5] Starting LoRa Manager standalone server...")
|
||||
result = subprocess.run(
|
||||
[sys.executable, "start_server.py", "--port", PORT, "--wait", "--timeout", "30", "--detach"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"Failed to start server: {result.stderr}")
|
||||
return 1
|
||||
print("Server ready!")
|
||||
|
||||
# Step 2: Open Chrome (manual step - show command)
|
||||
print("\n[2/5] Open Chrome with debug mode:")
|
||||
print(
|
||||
f"google-chrome --remote-debugging-port=9222 "
|
||||
f"--user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras"
|
||||
)
|
||||
print("(In actual test, this would be automated via MCP)")
|
||||
|
||||
# Step 3: Navigate and verify page load
|
||||
print("\n[3/5] Page Load Verification:")
|
||||
print(
|
||||
f"""
|
||||
MCP Commands to execute:
|
||||
1. navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||
2. wait_for(text="LoRAs", timeout=10000)
|
||||
3. snapshot = take_snapshot()
|
||||
"""
|
||||
)
|
||||
|
||||
# Step 4: Test search functionality
|
||||
print("\n[4/5] Search Functionality Test:")
|
||||
print(
|
||||
"""
|
||||
MCP Commands to execute:
|
||||
1. fill(uid="search-input", value="test")
|
||||
2. press_key(key="Enter")
|
||||
3. wait_for(text="Results", timeout=5000)
|
||||
4. result = evaluate_script(function=`
|
||||
() => {
|
||||
const cards = document.querySelectorAll('.lora-card');
|
||||
return { count: cards.length };
|
||||
}
|
||||
`)
|
||||
"""
|
||||
)
|
||||
|
||||
# Step 5: Verify API
|
||||
print("\n[5/5] API Verification:")
|
||||
print(
|
||||
"""
|
||||
MCP Commands to execute:
|
||||
1. api_result = evaluate_script(function=`
|
||||
async () => {
|
||||
const response = await fetch('/loras/api/list');
|
||||
const data = await response.json();
|
||||
return { count: data.length, status: response.status };
|
||||
}
|
||||
`)
|
||||
2. Verify api_result['status'] == 200
|
||||
"""
|
||||
)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Test flow completed!")
|
||||
print("=" * 60)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def example_restart_flow():
|
||||
"""Example: Testing configuration change that requires restart."""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example: Server Restart Flow")
|
||||
print("=" * 60)
|
||||
|
||||
print(
|
||||
f"""
|
||||
Scenario: Change setting and verify after restart
|
||||
|
||||
Steps:
|
||||
1. Navigate to settings page
|
||||
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
|
||||
|
||||
2. Change a setting (e.g., theme)
|
||||
- fill(uid="theme-select", value="dark")
|
||||
- click(uid="save-settings-button")
|
||||
|
||||
3. Restart server
|
||||
- subprocess.run([python, "start_server.py", "--port", "{PORT}", "--restart", "--wait", "--detach"])
|
||||
|
||||
4. Refresh browser
|
||||
- navigate_page(type="reload", ignoreCache=True)
|
||||
- wait_for(text="LoRAs", timeout=15000)
|
||||
|
||||
5. Verify setting persisted
|
||||
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/settings")
|
||||
- theme = evaluate_script(function="() => document.querySelector('#theme-select').value")
|
||||
- assert theme == "dark"
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def example_modal_interaction():
|
||||
"""Example: Testing modal dialog interaction."""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example: Modal Dialog Interaction")
|
||||
print("=" * 60)
|
||||
|
||||
print(
|
||||
"""
|
||||
Scenario: Add new LoRA via modal
|
||||
|
||||
Steps:
|
||||
1. Open modal
|
||||
- click(uid="add-lora-button")
|
||||
- wait_for(text="Add LoRA", timeout=3000)
|
||||
|
||||
2. Fill form
|
||||
- fill_form(elements=[
|
||||
{"uid": "lora-name", "value": "Test Character"},
|
||||
{"uid": "lora-path", "value": "/models/test.safetensors"},
|
||||
])
|
||||
|
||||
3. Submit
|
||||
- click(uid="modal-submit-button")
|
||||
|
||||
4. Verify success
|
||||
- wait_for(text="Successfully added", timeout=5000)
|
||||
- snapshot = take_snapshot()
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def example_network_monitoring():
|
||||
"""Example: Network request monitoring."""
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Example: Network Request Monitoring")
|
||||
print("=" * 60)
|
||||
|
||||
print(
|
||||
f"""
|
||||
Scenario: Verify API calls during user interaction
|
||||
|
||||
Steps:
|
||||
1. Clear network log (implicit on navigation)
|
||||
- navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
|
||||
|
||||
2. Perform action that triggers API call
|
||||
- fill(uid="search-input", value="character")
|
||||
- press_key(key="Enter")
|
||||
|
||||
3. List network requests
|
||||
- requests = list_network_requests(resourceTypes=["xhr", "fetch"])
|
||||
|
||||
4. Find search API call
|
||||
- search_requests = [r for r in requests if "/api/search" in r.get("url", "")]
|
||||
- assert len(search_requests) > 0, "Search API was not called"
|
||||
|
||||
5. Get request details
|
||||
- if search_requests:
|
||||
details = get_network_request(reqid=search_requests[0]["reqid"])
|
||||
- Verify request method, response status, etc.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("LoRa Manager E2E Test Examples\n")
|
||||
print("This script demonstrates E2E testing patterns.\n")
|
||||
print("Note: Actual execution requires Chrome DevTools MCP connection.\n")
|
||||
|
||||
run_test()
|
||||
example_restart_flow()
|
||||
example_modal_interaction()
|
||||
example_network_monitoring()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All examples shown!")
|
||||
print("=" * 60)
|
||||
@@ -166,14 +166,10 @@ The system runs in two modes:
|
||||
|
||||
### Model Types & Routes
|
||||
|
||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*`, `/other/*` patterns
|
||||
- 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`
|
||||
- Endpoints consumed by the companion browser extension (lm-civitai-extension)
|
||||
MUST also accept `GET` with query-string params: the extension is GET-only by
|
||||
convention (see its AGENTS.md), even for state-changing operations such as
|
||||
`GET /api/lm/recipe/{recipe_id}/reimport`
|
||||
|
||||
### Recipe System
|
||||
|
||||
@@ -190,8 +186,6 @@ The system runs in two modes:
|
||||
|
||||
- `py/config.py` manages folder paths for models and handles symlink mappings
|
||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
||||
- `settings.json.example` is intentionally minimal (see Important Notes); all
|
||||
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
|
||||
|
||||
### Frontend UI Architecture
|
||||
|
||||
@@ -221,26 +215,6 @@ The system runs in two modes:
|
||||
- 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`
|
||||
|
||||
### UI Verification (manual default)
|
||||
|
||||
UI/layout changes are verified by the user by eye — do NOT spin up a sandbox,
|
||||
standalone server, or browser automation to "prove" a visual fix. Ask the user to
|
||||
look instead. The full browser E2E ceremony (server + Chrome DevTools MCP +
|
||||
screenshots) is slow, token-heavy, and fragile; reserve it for genuine
|
||||
server+browser integration bugs, and only when the user explicitly agrees.
|
||||
|
||||
If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
`scripts/e2e/` (`start_server.py`, `wait_for_server.py`). Non-negotiable rules:
|
||||
|
||||
- Always launch with `--settings-path <sandbox>/settings` and sandboxed
|
||||
`folder_paths` under `/tmp` — the repo folder is the real plugin folder and a
|
||||
`settings.json` there is read by the live instance. Never touch real config or
|
||||
real model libraries.
|
||||
- Never kill a process you did not start; `start_server.py` tracks its own PIDs
|
||||
via pidfile and refuses to touch unrelated processes on the port.
|
||||
- Abort after ~30 minutes or 3 consecutive tool failures; report `BLOCKED` with
|
||||
observed state instead of retrying blindly. Clean up sandbox and server after.
|
||||
|
||||
## Key Integration Points
|
||||
|
||||
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
|
||||
@@ -252,12 +226,6 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
||||
## Important Notes
|
||||
|
||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||
(model-category folders, `default_*_root`, `auto_organize_exclusions`, etc.)
|
||||
to this file unless the user explicitly asks for it. Defaults belong in
|
||||
`DEFAULT_SETTINGS` in `py/services/settings_manager.py`.
|
||||
- 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
|
||||
|
||||
+395
-427
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||
`locales/`.
|
||||
|
||||
Source of truth: `locales/en.json` (10 locales, 1982 leaf keys; all locales share the exact
|
||||
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).
|
||||
@@ -13,26 +13,6 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||
> 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.
|
||||
>
|
||||
> **Status (2026-09, Other Models):** the `other` model type (VAE / Upscaler / Text Encoder /
|
||||
> CLIP Vision / ControlNet) and the Other Models opt-in toggles added 36 new keys; all of them
|
||||
> are now translated in all 9 locales (terminology in §2 "Other Models feature"). There are no
|
||||
> remaining `[TODO: Translate]` placeholders in any locale.
|
||||
>
|
||||
> **Status (2026-09, revision):** `other.disabled.description`, `banners.otherModels.content` and
|
||||
> `settings.folderSettings.enableOtherModelsHelp` were refreshed in `en.json` to name all five
|
||||
> sub_types (they had listed four, which read as "these are what enabling manages") and
|
||||
> re-translated in all 9 locales in the same pass. `clip_vision` and `controlnet` are now both
|
||||
> opt-in, so the first two describe **capability** and the third the **master switch**, not the
|
||||
> default set — keep all three enumerating the full five (`VAE / upscaler / text encoder /
|
||||
> CLIP vision / ControlNet` in `en`; locale slash-list casing follows each file's existing
|
||||
> `VAE / Upscaler / Text Encoder / …` style, de compounds as `CLIP-Vision- und ControlNet-Ordner`).
|
||||
>
|
||||
> **Status (2026-09, "no folders found" state):** the Other Models page gained an *enabled but
|
||||
> nothing to scan* empty state with 6 new keys (`other.noPaths.*`); translated in all 9 locales
|
||||
> in the same pass. The `folder_paths` JSON snippet shown in that state lives in
|
||||
> `templates/other.html`, **not** in the locale files, so it is never translated — only the
|
||||
> surrounding prose is. Terminology added in §2.
|
||||
|
||||
---
|
||||
|
||||
@@ -157,7 +137,7 @@ and must be normalized. `en` = keep the English word as-is.
|
||||
|
||||
| Term | Use | Fix |
|
||||
|---|---|---|
|
||||
| recipe | Rezept/Rezepte | leftover English "Recipe" keys → Rezept (e.g. `toast.recipes.recipeSaved`) |
|
||||
| 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" |
|
||||
@@ -213,7 +193,7 @@ and must be normalized. `en` = keep the English word as-is.
|
||||
| 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 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
||||
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
||||
|
||||
### ko
|
||||
|
||||
@@ -242,56 +222,6 @@ and must be normalized. `en` = keep the English word as-is.
|
||||
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
||||
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
|
||||
|
||||
### Other Models feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet)
|
||||
|
||||
The `other` model type exposes five sub_types. They are **model-type names**, so they follow
|
||||
R3 and stay in Latin in every locale. The `settings.folderSettings.subType*` values are
|
||||
therefore **intentionally byte-identical to `en.json`** (same precedent as
|
||||
`settings.priorityTags.modelTypes` / `checkpoints.modelTypes.checkpoint`) — a §6 sweep must
|
||||
not "fix" them.
|
||||
|
||||
| Term | Rendering | Note |
|
||||
|---|---|---|
|
||||
| VAE | `VAE` everywhere | acronym, always upper-case |
|
||||
| Upscaler | `Upscaler` everywhere | CivitAI `ModelType` name |
|
||||
| Text Encoder | `Text Encoder` everywhere | de compounds as `Text-Encoder-Stammordner` |
|
||||
| CLIP Vision | `CLIP Vision` everywhere | de compounds as `CLIP-Vision-Stammordner` |
|
||||
| ControlNet | `ControlNet` everywhere | brand casing, capital N |
|
||||
|
||||
In prose these names sit next to localized nouns the same way `Diffusion Model` does
|
||||
(zh `VAE 根目录`, ja `VAEルート`, ko `VAE 루트`, ru `Корневая папка VAE`).
|
||||
|
||||
**"Other Models" is the page/feature name, not a model type — translate it:**
|
||||
|
||||
| Locale | `other.title` | `header.navigation.other` |
|
||||
|---|---|---|
|
||||
| fr | Autres modèles | Autres |
|
||||
| zh-CN | 其他模型 | 其他 |
|
||||
| zh-TW | 其他模型 | 其他 |
|
||||
| ja | その他のモデル | その他 |
|
||||
| ko | 기타 모델 | 기타 |
|
||||
| de | Weitere Modelle | Andere |
|
||||
| es | Otros modelos | Otros |
|
||||
| ru | Другие модели | Другое |
|
||||
| he | מודלים אחרים | אחרים |
|
||||
|
||||
`settings.folderSettings.otherSubTypes` ("Managed Types") must name **model** types, matching
|
||||
each locale's `header.filter.modelTypes` rendering (zh `管理的模型类型`, ja `管理するモデルタイプ`,
|
||||
de `Verwaltete Modelltypen`, …).
|
||||
|
||||
The "no folders found" empty state (`other.noPaths.*`) uses two phrases that must stay
|
||||
consistent whenever that copy is edited. `folder key` means the `folder_paths` key name
|
||||
(`vae`, `upscale_models`, … — Latin per the table above); `on disk` means the folder must
|
||||
physically exist:
|
||||
|
||||
| Phrase | Rendering |
|
||||
|---|---|
|
||||
| folder key | zh-CN 文件夹键 · zh-TW 資料夾鍵 · ja フォルダーキー · ko 폴더 키 · fr clé de dossier · de Ordnerschlüssel · es clave de carpeta · ru ключ папки · he מפתח תיקייה |
|
||||
| on disk | zh-CN 在磁盘上 · zh-TW 在磁碟上 · ja ディスク上 · ko 디스크에 · fr sur le disque · de auf dem Datenträger · es en el disco · ru на диске · he בדיסק |
|
||||
|
||||
`settings.json` and `ComfyUI` stay verbatim in every locale; "reload this page" / "restart
|
||||
LoRA Manager" reuse each locale's existing restart wording (`settings.extraFolderPaths.*`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||
@@ -382,9 +312,8 @@ blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipe
|
||||
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`), model-type names (`settings.priorityTags.modelTypes.*`,
|
||||
`settings.folderSettings.subTypeVae` … `subTypeControlnet` — see §2), and the external playlist
|
||||
title (`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
||||
(`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
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
# Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc.
|
||||
|
||||
**Status:** v2 — **Phase 1 implemented** (2026-09-12, commits `27da7b3c` backend + `fa7ce725` frontend; verified live against a running ComfyUI instance: scan/hash/sub_type-derivation/fetch/previews all green). **Phase 2 implemented** (2026-09-12, per §9 design; full pytest + vitest green). **Phase 3 implemented** (§11: opt-in management toggles; default off). **i18n done** (2026-09-13): all 36 new keys translated in the 9 non-English locales — the `[TODO: Translate]` placeholders left by the sync script during development are gone (see `docs/i18n-translation-guidelines.md` §2, "Other Models feature"). **Default set revised (pre-release):** only `vae` / `upscaler` / `text_encoder` are managed by default — `clip_vision` and `controlnet` are both opt-in (§2, §11.1.1).
|
||||
**Scope (Phase 1):** scan + manage (list, search, filter, tags, folders, preview, rename, move, delete/exclude, CivitAI metadata fetch) for a new model type `other`, exposed as a new web page. **Phase 2 (§9):** one-click download from CivitAI for these types.
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Today the manager supports three model types:
|
||||
|
||||
| page | model_type | sub_types |
|
||||
|---|---|---|
|
||||
| `/loras` | `lora` | `lora`, `locon`, `dora` |
|
||||
| `/checkpoints` | `checkpoint` | `checkpoint`, `diffusion_model` |
|
||||
| `/embeddings` | `embedding` | `embedding` |
|
||||
|
||||
Add a fourth page that manages "everything else" — VAE, upscalers, text encoders / CLIP, CLIP vision, optionally ControlNet — with a folder→sub_type mapping table so new ComfyUI folder categories can be added later by configuration, not code.
|
||||
|
||||
## 2. Locked Decisions
|
||||
|
||||
1. **Architecture: one scanner + one service + one page, sub_type derived by location.**
|
||||
Replicates the checkpoint pattern (`CheckpointScanner` aggregates `checkpoints` + `unet` roots and derives `checkpoint` vs `diffusion_model` from the root containing the file, `py/services/checkpoint_scanner.py:384-415`). One `OtherScanner` aggregates all enabled folder roots; `resolve_sub_type_for_path()` maps each root to a sub_type. No per-category scanners.
|
||||
|
||||
2. **Naming: internal `model_type = "other"`, route prefix `/other`, page id `other`.**
|
||||
- `misc` is rejected: `py/routes/misc_routes.py` already owns that name for system/settings routes (`/api/lm/settings`, `/api/lm/doctor/*`).
|
||||
- `components` is rejected: `templates/components/` and `static/js/components/` directories would make `components.html` / `components.js` confusing neighbors.
|
||||
- `other` matches CivitAI's `Other` fallback type semantics. The **display name** is an i18n string (`other.title`, e.g. "Other Models") and can be renamed later without touching code.
|
||||
|
||||
3. **sub_type values:** snake_case, aligned with CivitAI `ModelType` semantics:
|
||||
|
||||
| sub_type | ComfyUI `folder_paths` key(s) | CivitAI ModelType | enabled by default |
|
||||
|---|---|---|---|
|
||||
| `vae` | `vae` | `VAE` | yes |
|
||||
| `upscaler` | `upscale_models` | `Upscaler` | yes |
|
||||
| `text_encoder` | `text_encoders`, `clip` (legacy) | `TextEncoder` (CLIP is retired upstream) | yes |
|
||||
| `clip_vision` | `clip_vision` | `CLIPVision` | no (mapping present, opt-in) |
|
||||
| `controlnet` | `controlnet` | `Controlnet` | no (mapping present, opt-in) |
|
||||
|
||||
New folder categories = one line in the mapping table (see §4.1).
|
||||
|
||||
**Why only three are on by default** (revised in Phase 3, before release):
|
||||
VAE, upscalers and text encoders are dependency-style assets every pipeline
|
||||
needs, and "which one am I actually using" is the recurring problem they
|
||||
solve. `clip_vision` and `controlnet` are workflow-driven instead
|
||||
(IPAdapter/SVD image conditioning; per-workflow ControlNet variants), and
|
||||
ControlNet libraries routinely run to dozens of files, so both are treated
|
||||
symmetrically as opt-in. Enumerating all five as "the default set" was not
|
||||
defensible on demand breadth alone.
|
||||
|
||||
4. **Phase 1 = scan/manage only.** Downloads from CivitAI (`download_manager.py` type mapping, default-root settings keys, download routing) are Phase 2 (§9). CivitAI **metadata fetch** for existing files IS in Phase 1 (hash-based lookup is type-agnostic; only the type-validation hook needs new values).
|
||||
|
||||
5. **Out of scope (default off, revisit later):** usage statistics buckets, recipe matching (`recipe_scanner.py` only merges lora+checkpoint scanners), statistics page, embeddings re-classification (stays its own page — merging would be a breaking change).
|
||||
|
||||
## 3. Why This Works With Minimal Churn
|
||||
|
||||
- `ModelScanner` (`py/services/model_scanner.py:93`) is specialized entirely via constructor params (`model_type`, `model_class`, `file_extensions`) + optional hooks (`adjust_metadata`, `adjust_cached_entry`, `resolve_sub_type_for_path`, `model_scanner.py:1429-1443`).
|
||||
- `BaseModelService` subclasses can be one method (`EmbeddingService` implements only `format_response`, `py/services/embedding_service.py:12`).
|
||||
- Routes: `ModelServiceFactory.register_model_type()` (`py/services/model_service_factory.py:120-136`) + `COMMON_ROUTE_DEFINITIONS` (`py/routes/model_route_registrar.py:23-149`) generate the full `/api/lm/{prefix}/*` surface (~50 endpoints) plus the `GET /{prefix}` page route.
|
||||
- `PersistentModelCache` (`py/services/persistent_model_cache.py:526-606`) is a single `models` table keyed `(model_type, file_path)` with `model_type` as free text — **zero schema change**.
|
||||
- Frontend `apiConfig.js` (`static/js/api/apiConfig.js:51`) generates all endpoints from the model-type string; `ModelCard.js:670-675` renders the sub_type badge from data; the checkpoints page already demonstrates the "one page, multiple sub_types" filter (`header.html:298`).
|
||||
|
||||
## 4. Backend Changes
|
||||
|
||||
### 4.1 New constants — `py/utils/constants.py`
|
||||
|
||||
```python
|
||||
# folder_paths key -> sub_type; single source of truth for extensibility
|
||||
OTHER_MODEL_FOLDER_SUBTYPES = {
|
||||
"vae": "vae",
|
||||
"upscale_models": "upscaler",
|
||||
"text_encoders": "text_encoder",
|
||||
"clip": "text_encoder", # legacy ComfyUI key
|
||||
"clip_vision": "clip_vision",
|
||||
"controlnet": "controlnet",
|
||||
}
|
||||
DEFAULT_OTHER_MODEL_FOLDERS = ("vae", "upscale_models", "text_encoders", "clip", "clip_vision")
|
||||
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
|
||||
# CivitAI model.type values accepted for this page (fetch-metadata validation)
|
||||
VALID_OTHER_CIVITAI_TYPES = {"vae", "upscaler", "textencoder", "clipvision", "controlnet", "other"}
|
||||
```
|
||||
|
||||
Also extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:90`) if user-model queries should include these types.
|
||||
|
||||
### 4.2 New files (mirror the embedding/checkpoint implementations)
|
||||
|
||||
1. **`py/utils/models.py`** — add `OtherModelMetadata(BaseModelMetadata)`: default `sub_type="vae"` placeholder overridden by scanner hook; `from_civitai_info` mapping CivitAI types → our sub_types (`TextEncoder`→`text_encoder`, `CLIPVision`→`clip_vision`, `Upscaler`→`upscaler`, `VAE`→`vae`, `Controlnet`→`controlnet`, else `other`-ish fallback to folder-derived sub_type).
|
||||
2. **`py/services/other_scanner.py`** — `OtherScanner(ModelScanner)`:
|
||||
- `model_type="other"`, extensions: reuse the checkpoint set (`safetensors/pt/pt2/bin/pth/pkl/sft/gguf`).
|
||||
- `get_model_roots()`: iterate `OTHER_MODEL_FOLDER_SUBTYPES` ∩ enabled keys, pull each from `config` (§4.3); dedupe; build `root → sub_type` map (normalized abspaths; multiple keys may share a sub_type).
|
||||
- Implement all three hooks like `CheckpointScanner` (`checkpoint_scanner.py:384-415`): `resolve_sub_type_for_path` by longest-prefix root match, `adjust_metadata`, `adjust_cached_entry` (sub_type is re-derived on cache load, never persisted).
|
||||
- **Lazy hashing, checkpoint-style**: text encoders (T5-XXL ≈ 10 GB) make eager sha256 painful. Copy the `hash_status="pending"` + singleflight `calculate_hash_for_model` pattern from `CheckpointScanner`.
|
||||
3. **`py/services/other_model_service.py`** — `OtherModelService(BaseModelService)`, `format_response` only (no usage_count, like `EmbeddingService`).
|
||||
4. **`py/routes/other_routes.py`** — `OtherRoutes(BaseModelRoutes)`, `template_name="other.html"`, hooks:
|
||||
- `_validate_civitai_model_type` → `VALID_OTHER_CIVITAI_TYPES`
|
||||
- `_get_expected_model_types`, `_parse_specific_params` (no type-specific download params in Phase 1)
|
||||
- `initialize_services()` on `app.on_startup` pulling `ServiceRegistry.get_other_scanner()`.
|
||||
|
||||
### 4.3 `py/config.py`
|
||||
|
||||
- New `other_roots` property: for each enabled key in `OTHER_MODEL_FOLDER_SUBTYPES`, `folder_paths.get_folder_paths(key)` (plugin mode) — standalone mode needs nothing new: `MockFolderPaths` (`standalone.py:66-105`) already serves arbitrary keys from `settings.json.folder_paths`.
|
||||
- Follow the existing per-type recipe: an `_prepare_other_paths()` (dedupe + symlink registration; also **cross-scanner overlap detection** — warn if an `other` root is already covered by checkpoints/unet/embedding roots, mirroring the checkpoint/unet overlap check).
|
||||
- Wire into: `_apply_library_paths`, `_symlink_roots()`, `_rebuild_preview_roots()` (hard requirement — preview images are served per registered root), `save_folder_paths_to_settings()`.
|
||||
|
||||
### 4.4 Existing-file edits (the "type string scatter" — each is a small branch/entry)
|
||||
|
||||
| file | change |
|
||||
|---|---|
|
||||
| `py/services/model_service_factory.py:120` | register `("other", OtherModelService, OtherRoutes)` in `register_default_model_types()` |
|
||||
| `py/services/service_registry.py` | add `get_other_scanner()` (mirror `:297` `get_embedding_scanner`) |
|
||||
| `py/services/model_scanner.py:67` | `PAGE_TYPE_MAP['other'] = 'other'` (WebSocket progress) |
|
||||
| `py/services/base_model_service.py:896-906` | `get_model_types()` branch → `VALID_OTHER_SUB_TYPES` |
|
||||
| `py/lora_manager.py` | `_initialize_services` scanner task list (`:219-242`), `_cleanup` cancel list (`:463`), `_cleanup_backup_files` roots (`:327-330`) |
|
||||
| `py/routes/handlers/misc_handlers.py` | `scanner_getters` (`:657-661`) + `scanner_factories` (`:757-759`) so Doctor / init-status / refresh-all see the new scanner |
|
||||
| `py/services/pending_delete_service.py` | `_PAGE_TYPE` map (`:57-61`) + scanner getter list (`:983-985`) |
|
||||
| `py/metadata_ops/__init__.py:36-38` | `SCANNER_TYPE_MAP['other']` |
|
||||
| `settings.json.example` | document optional `folder_paths` keys: `vae`, `upscale_models`, `text_encoders`, `clip_vision` |
|
||||
|
||||
**Explicitly NOT touched in Phase 1:** `py/services/download_manager.py`, `py/services/download_routing.py`, `py/services/settings_manager.py` default-root keys, `py/routes/stats_routes.py`, `py/utils/usage_stats.py`, `py/services/recipe_scanner.py`, `py/metadata_collector/`, `py/nodes/`.
|
||||
|
||||
**Zero-change confirmations (verified):** `PersistentModelCache`, `ModelUpdateService`, `DownloadedVersionHistoryService`, `MetadataSyncService` + provider chain (type-agnostic hash lookups), `ModelFileService` / `ModelMoveService` / `ModelLifecycleService` (scanner + model_type injected), `ModelCache` / `ModelHashIndex`, `AutoV3BackfillService`.
|
||||
|
||||
## 5. Frontend Changes
|
||||
|
||||
1. **`static/js/api/apiConfig.js`** — `MODEL_TYPES.OTHER = 'other'`; `MODEL_CONFIG.other` entry (displayName, singularName, `supportsMove`, `supportsBulkOperations`; no letter filter); endpoints come free from `getApiEndpoints()` (`:51`).
|
||||
2. **`static/js/api/otherApi.js`** — thin `OtherApiClient extends BaseModelApiClient` (mirror `embeddingApi.js`); register in `modelApiFactory.js`.
|
||||
3. **`static/js/other.js`** — page entry (mirror `embeddings.js`): `appCore.initialize()` + `createPageControls('other')` + `initializePageFeatures()` + `ModelDuplicatesManager` + `initActiveFiltersSync('other')`.
|
||||
4. **Controls & context menu** — `OtherControls extends PageControls` and `OtherContextMenu` (start from the embedding variants — the smallest); add branches in the two factories (`components/controls/index.js:15`, `components/ContextMenu/index.js:15`). Context-menu template block lives in `templates/other.html` (`{% block additional_components %}`, the checkpoints/embeddings pattern — do NOT touch the shared `context_menu.html`).
|
||||
5. **`templates/other.html`** — copy `embeddings.html`: same content blocks (controls + breadcrumb + duplicates banner + folder sidebar + `#modelGrid`), `data-page="other"`, main script `/loras_static/js/other.js`.
|
||||
6. **`templates/components/header.html`** — nav entry (`:23-43`, active when `request.path.startswith('/other')`); enable the `modelTypes` sub_type filter panel for `other` (`:298-305` pattern from checkpoints); check search-options panel conditions (`:199-224`).
|
||||
7. **`static/js/utils/constants.js`** — `MODEL_SUBTYPE_ABBREVIATIONS` (`:115`): `vae→VAE`, `upscaler→UPS`, `text_encoder→TE`, `clip_vision→CV`, `controlnet→CN`; matching `MODEL_SUBTYPE_DISPLAY_NAMES` (`:99`). (Unknown fallback already uppercases 4 chars, but explicit mappings read better.)
|
||||
8. **`static/js/core.js:110` `getPageType()`** — verify `data-page="other"` flows through `state.pages` generically; add only if the page list is enumerated anywhere.
|
||||
9. No change to `web/comfyui/top_menu_extension.js` (it opens `/loras`; page-to-page nav is the header bar).
|
||||
|
||||
## 6. i18n
|
||||
|
||||
- `locales/en.json`: add `other.title` (e.g. "Other Models") + minimal `other.contextMenu.*` / `other.modelTypes.*` keys; reuse `modelCard.*`, `loras.contextMenu.*`, `common.*` wherever possible (the established pattern — checkpoints/embeddings already reuse lora keys).
|
||||
- Run `python scripts/sync_translation_keys.py`; leave `[TODO: Translate]` placeholders in other locales (per `docs/i18n-translation-guidelines.md` §7 — do not translate proactively).
|
||||
|
||||
## 7. Testing
|
||||
|
||||
Follow existing conventions (`pytest.ini`, `tests/frontend/` vitest):
|
||||
|
||||
1. **Backend (pytest, async where needed):**
|
||||
- `OtherScanner` root aggregation + `resolve_sub_type_for_path` (file under `vae/` root → `vae`; `text_encoders` and legacy `clip` both → `text_encoder`; disabled `controlnet` root not scanned).
|
||||
- Cache round-trip: sub_type re-derived via `adjust_cached_entry` (not persisted).
|
||||
- Lazy hash: `hash_status="pending"` default; `calculate_hash_for_model` singleflight.
|
||||
- `OtherRoutes` registration smoke test: `/api/lm/other/...` endpoints exist; `_validate_civitai_model_type` accepts `vae`/`upscaler`/`textencoder`, rejects `lora`.
|
||||
- Config: `other_roots` in both modes (mock `folder_paths`, and standalone `settings.json.folder_paths`).
|
||||
2. **Frontend (vitest + jsdom, `tests/frontend/`):**
|
||||
- `apiConfig`: `getApiEndpoints('other')` URL shapes; `modelApiFactory` returns the Other client.
|
||||
- `ModelCard` badge rendering for new sub_types.
|
||||
- `createPageControls('other')` / `createPageContextMenu('other')` factories.
|
||||
3. **Manual UI verification by the user** (per AGENTS.md — no sandbox/browser automation): page loads, scans a real library, sub_type filter + badges, context menu actions.
|
||||
|
||||
## 8. Execution Order
|
||||
|
||||
1. `constants.py` + `OtherModelMetadata` + `config.py` roots
|
||||
2. `OtherScanner` (+ registry, factory, `PAGE_TYPE_MAP`) → scanner unit tests green
|
||||
3. `OtherModelService` + `OtherRoutes` + handler/registrar wiring + `lora_manager.py` lifecycle → route tests green
|
||||
4. Doctor/pending-delete/metadata-ops scatter entries
|
||||
5. Template + header nav + frontend API/controls/context-menu/card badges → vitest green
|
||||
6. i18n keys + sync script
|
||||
7. `pytest` + `npm test` full runs; hand to user for manual UI check
|
||||
|
||||
## 9. Phase 2 Detailed Design — CivitAI Downloads for `other`
|
||||
|
||||
Designed 2026-09-12 against the Phase-1 code on this branch; decisions marked **[locked]** follow the same recommendations the feature owner approved for Phase 1.
|
||||
|
||||
### 9.1 Download pipeline touch points
|
||||
|
||||
Flow: `POST /api/lm/download-model` (`py/routes/model_route_registrar.py:104`; GET variant `:105` for the browser extension) → `ModelDownloadHandler.download_model` (`model_handlers.py:1740`) → `DownloadModelUseCase.execute` → `DownloadCoordinator.schedule_download` → `DownloadManager.download_from_civitai` (`download_manager.py:386`) → `_execute_original_download` (`:1415`). Inside, seven scatter points need an `other` branch:
|
||||
|
||||
1. **Type map** (`:1496-1507`): accept `model.type.lower() in VALID_OTHER_CIVITAI_TYPES` → `model_type = "other"` (reuses the Phase-1 set, incl. `"other"` itself).
|
||||
2. **Early version-exists gate** (`:1436-1463`): add `other_scanner.check_model_version_exists`.
|
||||
3. **File-level exists gate** (`:1640-1655` → `_find_local_file_entry` `:320-346` → `_get_scanner_for_model_type` `:230-236`): add explicit `other` branch. **Trap**: the function currently falls through to the lora scanner for unknown types — `"other"` would silently dedupe against loras. Also narrow the fall-through to `"lora"` only / raise on unknown.
|
||||
4. **Version-level fallback gate** (`:1656-1688`): add `elif model_type == "other"`.
|
||||
5. **Default-root selection** (`:1690-1727`): for `other`, first resolve sub_type (§9.2), then read `default_other_roots[sub_type]` (§9.3); if sub_type is undecidable or no default root configured → error guiding the user to pick a folder explicitly.
|
||||
6. **Metadata class selection** (`:1909-1928`) + `_build_metadata_for_resume` (`:969-981`): add `OtherModelMetadata.from_civitai_info` branches.
|
||||
7. **Post-download cache write** (`_execute_download_pipeline` `:2622-2679`): add `other` scanner branch; `adjust_metadata` re-derives sub_type from the on-disk root automatically. `_get_supported_extensions_for_type` (`:2720-2744`): `other` reuses the checkpoint extension set.
|
||||
|
||||
Hooks: `_record_downloaded_version_history` (model_type is free text — zero change); `_sync_downloaded_version` (`:1984` → scanner dispatch `:2130-2135`) add `other`; `py/utils/example_images_download_manager.py` scanner dispatch at `:411-421`, `:591-601`, `:1089+` — add `other` at all three (silent no-scanner otherwise).
|
||||
|
||||
Path templates: `get_download_path_template("other")` is unset, so `other` resolves to a **flat** layout (empty template) — downloads land directly under the resolved sub_type root. This is deliberate: other-model roots are already split per sub_type (`default_other_roots`), and `priority_tags` has no `other` entry, so `{first_tag}` would fall back to an arbitrary CivitAI tag and scatter files into unstable folders. Users who want nesting can still set `download_path_templates["other"]` in `settings.json`. See `DEFAULT_DOWNLOAD_PATH_TEMPLATES` (`py/utils/constants.py`) and `DEFAULT_PATH_TEMPLATES` (`static/js/utils/constants.js`).
|
||||
|
||||
### 9.2 File-level routing (model.type / file.type → sub_type) **[locked]**
|
||||
|
||||
Table-driven, mirroring Phase 1. New in `py/utils/constants.py`:
|
||||
|
||||
```python
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
|
||||
"VAE": "vae", "Upscaler": "upscaler", "Text Encoder": "text_encoder",
|
||||
"Vision Encoder": "clip_vision", "CLIPVision": "clip_vision",
|
||||
"ControlNet": "controlnet",
|
||||
}
|
||||
```
|
||||
|
||||
`download_routing.py` gains `resolve_other_download_sub_type(civitai_model_type, file_types, selected_file_type=None)` with fixed priority:
|
||||
|
||||
1. **Explicit user file pick** (`file_params` from #1058's `_resolve_target_file`) — if the picked file's type maps, it wins even when model.type is `Checkpoint`.
|
||||
2. **model.type** via the existing `CIVITAI_TYPE_TO_OTHER_SUB_TYPE` (`constants.py:120-127`).
|
||||
3. **file.type fallback** — only when model.type maps to nothing (e.g. model.type `Other` or retired `CLIP`). MUST NOT override a mapped model.type: checkpoint models routinely bundle VAE/Text Encoder component files, and unconditional file-type routing would misroute them.
|
||||
4. Still undecidable → `None`; `use_default_paths` errors and the UI offers all other roots for manual selection.
|
||||
|
||||
HTTP: extend `DownloadRoutingHandler.get_download_routing` (`download_routing_handlers.py:23`) with an `other` branch returning `{root_kind: "other", sub_type: ...}`; add `GET /api/lm/other/roots_by_subtype` in `OtherRoutes.setup_specific_routes` (data from `config._prepare_other_paths`'s per-key roots, aggregating `text_encoders` + legacy `clip` under `text_encoder`).
|
||||
|
||||
### 9.3 Settings: single dict key `default_other_roots` **[locked]**
|
||||
|
||||
Rejected: four flat keys (`default_vae_root`…) — each flat key costs ~13 touch points in `settings_manager.py` (defaults `:82-85`, `_check_and_auto_set` `:890-895`, `set()` `:1621-1628`, `_update_active_library_entry` `:738-805`, upsert/create signatures `:1953-2132`, `_build_library_payload` `:552-612`, `_sync_active_library_to_root` `:519-547`, three library constructors, frontend `DEFAULT_SETTINGS_BASE`), repeated per future sub_type.
|
||||
|
||||
Chosen: one mapping key `default_other_roots: {sub_type: path}`, copying the `extra_folder_paths` precedent (generic Mapping handling at `:533-535`, `:573-578`, `:763-767`). `_check_and_auto_set` generalizes to per-sub_type candidates (union over that sub_type's folder keys — `text_encoder` → `text_encoders` + `clip`). `set()` validates keys against `VALID_OTHER_SUB_TYPES`.
|
||||
|
||||
Also fix the Phase-1 omission: add `"other_scanner"` to `_notify_library_change` (`:2150-2156`) and `_notify_model_name_display_change` (`:1795-1800`) — otherwise switching libraries leaves the other page stale.
|
||||
|
||||
### 9.4 Settings UI
|
||||
|
||||
- `templates/components/modals/settings/library.html:34-40`: sub_type selectors after the existing four `setting_select`s (Jinja loop; controlnet selector only when `enabled_other_folders` includes it). Dict-subkey save helper `saveOtherRootSetting(subType, value)` alongside the flat `saveSelectSetting`.
|
||||
- `static/js/managers/SettingsManager.js:1547-1697`: `loadOtherRoots()` mirroring `loadUnetRoots()`, fed by `/api/lm/other/roots_by_subtype`; current values from `state.global.settings.default_other_roots`. `state/index.js:24` `DEFAULT_SETTINGS_BASE` += `default_other_roots: {}`.
|
||||
- Optional: one `other` row in the download-path-template block (`library.html:153-211`).
|
||||
- i18n: `settings.folderSettings.*` keys into `locales/en.json` + sync script; other locales keep `[TODO: Translate]`.
|
||||
- Settings GET (`misc_handlers.py:1528-1536`) already returns all non-sensitive keys — new key reaches the frontend for free.
|
||||
|
||||
### 9.5 Frontend download entry
|
||||
|
||||
- `templates/components/controls.html:83`: drop the `page_id != 'other'` exclusion on the download button (keyboard shortcut D self-enables via `PageControls.js:196-198`).
|
||||
- `OtherControls.js:22-55`: add `showDownloadModal: () => downloadManager.showDownloadModal()` (mirror `EmbeddingsControls.js:43-45`).
|
||||
- `DownloadManager.js` `proceedToLocationContent` (`:955-1017`): add `_resolveOtherSubType()` (mirror `_resolveIsDiffusionModel` `:1026`): selected file type → `/api/lm/download/routing` → `otherApiClient.fetchModelRoots(subType)` (new); default-root preselect reads `default_other_roots[subType]` instead of `` `default_${singularType}_root` `` (`:974`). Undecidable → list all other roots (`/api/lm/other/roots`) for manual pick; an explicit save_dir skips backend default-root logic, so the two paths cannot disagree.
|
||||
- `ModelVersionsTab` download buttons are modelType-generic and already work via `getModelApiClient('other')`; context menu has no CivitAI download entry — no change.
|
||||
- Version-list type validation (`get_civitai_versions` → `_validate_civitai_model_type`) already accepts `VALID_OTHER_CIVITAI_TYPES` from Phase 1.
|
||||
|
||||
### 9.6 CivitAI type mapping decisions **[locked]**
|
||||
|
||||
- Download accepts exactly `VALID_OTHER_CIVITAI_TYPES` (`VAE, Upscaler, TextEncoder, CLIP, CLIPVision, Controlnet, Other`) — reuse the Phase-1 tables; do NOT create new ones.
|
||||
- Extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:133-137`) with the 7 aliases, and point them at the other scanner / `"other"` history bucket in `misc_handlers.py` (`type_scanner_map` `:2793-2797`, `downloaded_version_map` `:2821-2827`) — otherwise creator pages silently filter these models while downloads claim support.
|
||||
- Fix (small Phase-1 bug): `OtherModelMetadata.from_civitai_info` (`py/utils/models.py:343`) reads `version_info.get("type")`, but the type lives at `version["model"]["type"]` — the mapping never fires and always degrades to the placeholder. Read `version_info.get("model", {}).get("type")` instead. (`CheckpointMetadata:290` has the same shape; leave it alone here.)
|
||||
|
||||
### 9.7 Tests
|
||||
|
||||
Existing base: `tests/services/test_download_manager_basic.py` (incl. `test_download_rejects_unsupported_model_type` `:1336`), `test_download_manager_error.py`, `test_download_manager_concurrent.py`, `tests/integration/test_download_flow.py`, `tests/services/test_settings_manager.py`; frontend `tests/frontend/managers/downloadManager.routing.test.js`, `settingsManager.library.test.js`.
|
||||
|
||||
Add: (1) `resolve_other_download_sub_type` unit tests — every priority tier, bundled-component anti-misrouting, undecidable → None, civarchive-shaped payload; (2) download_manager — six model.types accepted → other scanner (mock), unknown still rejected, no lora-scanner fall-through, per-sub_type default roots + unconfigured error, resume metadata, extension set; (3) settings_manager — `default_other_roots` defaults/auto-set (incl. text_encoder dual-key union)/library sync/upsert passthrough/illegal sub_type rejection; (4) routes — `/api/lm/download/routing` other branch, `roots_by_subtype` shape; (5) example-images dispatch accepts `other` (3 sites); (6) vitest — `_resolveOtherSubType` + root select + default preselect, `loadOtherRoots`; (7) user-models existsLocally for VAE.
|
||||
|
||||
### 9.8 Phase 2 file list
|
||||
|
||||
Backend: `py/utils/constants.py`, `py/services/download_routing.py`, `py/routes/handlers/download_routing_handlers.py`, `py/services/download_manager.py`, `py/utils/example_images_download_manager.py`, `py/services/settings_manager.py`, `py/utils/models.py`, `py/routes/other_routes.py`, `py/routes/handlers/misc_handlers.py`, `settings.json.example`.
|
||||
Frontend/templates: `templates/components/controls.html`, `static/js/components/controls/OtherControls.js`, `static/js/managers/DownloadManager.js`, `static/js/api/otherApi.js`, `templates/components/modals/settings/library.html`, `static/js/managers/SettingsManager.js`, `static/js/state/index.js`, `locales/en.json` + sync.
|
||||
|
||||
## 10. Risks / Open Questions
|
||||
|
||||
- **Root overlap**: a user may point `text_encoders` at a directory already scanned as checkpoints/unet. Realpath dedup inside one scanner won't catch cross-scanner overlap → the `_prepare_other_paths` overlap warning (§4.3) is the mitigation; duplicate cards across pages are cosmetic, not corrupting (cache keyed by `(model_type, file_path)`).
|
||||
- **Huge text encoders + lazy hash**: CivitAI fetch for a pending-hash model must trigger on-demand hash like checkpoints do — verify that flow (`calculate_hash_for_model`) is reachable from the `other` routes' fetch-metadata handler.
|
||||
- **Retired CivitAI types**: `CLIP`/`CLIPVision` are retired upstream (grandfathered for existing models); metadata fetch must tolerate both retired and current types — `VALID_OTHER_CIVITAI_TYPES` includes them deliberately.
|
||||
- **Standalone users** must add the new `folder_paths` keys to `settings.json` themselves; document in `settings.json.example` and the feature doc.
|
||||
- **Page display name** is i18n-only; if "Other Models" tests poorly, rename `other.title` without code changes.
|
||||
|
||||
### Phase 2 risks
|
||||
|
||||
- **Bundled component files**: checkpoint models routinely ship VAE/Text Encoder component files — file.type routing must stay a fallback (or explicit user pick), never an override (§9.2 priority is load-bearing; test it).
|
||||
- **`_get_scanner_for_model_type` lora fall-through** (`download_manager.py:236`): without an explicit `other` branch, dedupe checks run against the lora scanner — the most insidious trap in Phase 2.
|
||||
- **text_encoder dual folder keys** (`text_encoders` + legacy `clip`): default-root candidates, `roots_by_subtype`, and auto-set must all merge both keys; miss one and the default-root dropdown comes up empty.
|
||||
- **Undecidable sub_type** (model.type `Other` + unknown file types): must error and ask, never silently default to the vae folder.
|
||||
- **Lazy hash after download**: downloads carry CivitAI SHA256 (no recompute needed) — ensure the post-download cache write doesn't leave `hash_status="pending"`, or the next metadata fetch re-hashes a 10 GB file.
|
||||
- **CivArchive source**: same `_execute_original_download` path, same payload shape — cover it once in tests.
|
||||
|
||||
## 11. Phase 3 — Opt-in Management Toggles (implemented)
|
||||
|
||||
Designed 2026-09-13 against the Phase-1/2 code. Other Models is **opt-in**: after
|
||||
Phase 3 the feature ships disabled, so no other-model folder is scanned and the
|
||||
page shows an "enable" empty state until the user turns it on.
|
||||
|
||||
### 11.1 Settings (global, not per-library)
|
||||
|
||||
| key | type | default | meaning |
|
||||
|---|---|---|---|
|
||||
| `enable_other_models` | bool | `false` | master switch |
|
||||
| `enabled_other_sub_types` | list[str] | `["vae","upscaler","text_encoder"]` | allow-list; `clip_vision` and `controlnet` are opt-in (see §2) |
|
||||
|
||||
`enabled_other_folders` (the unreleased, additive, no-UI backend key) was removed
|
||||
and replaced by the sub_type-level allow-list; there is no migration because the
|
||||
feature never shipped. `text_encoder` expands to `text_encoders` + legacy `clip`
|
||||
via `OTHER_SUB_TYPE_FOLDER_KEYS`.
|
||||
|
||||
The default allow-list lives on five surfaces that must stay in sync:
|
||||
`DEFAULT_ENABLED_OTHER_SUB_TYPES` (`py/utils/constants.py`), `DEFAULT_SETTINGS`
|
||||
(`py/services/settings_manager.py`), the two `DEFAULT_SETTINGS_BASE` /
|
||||
`createDefaultSettings` lists (`static/js/state/index.js`), the
|
||||
`updateOtherModelsControls()` fallback (`static/js/managers/SettingsManager.js`)
|
||||
and the server-rendered Jinja fallback
|
||||
(`templates/components/modals/settings/library.html`).
|
||||
|
||||
### 11.1.1 Legacy key handling in `Config._init_other_paths`
|
||||
|
||||
ComfyUI's `folder_paths` rewrites legacy names before every access (`clip` →
|
||||
`text_encoders`, `unet` → `diffusion_models`) and registers both legacy
|
||||
directories under the canonical key, so `get_folder_paths("clip")` returns
|
||||
exactly the same list as `get_folder_paths("text_encoders")`. Querying both keys
|
||||
made the overlap guard fire twice with `please fix your path configuration` for a
|
||||
configuration the user cannot fix. `Config._collapse_legacy_folder_keys()` now
|
||||
drops a key when the host exposes `map_legacy` and resolves it to another queried
|
||||
key, and `_prepare_other_paths()` downgrades a same-`sub_type` duplicate to
|
||||
`debug` (a cross-`sub_type` collision still warns). In standalone mode
|
||||
`MockFolderPaths` has no `map_legacy` and its keys are independent
|
||||
`settings.json` entries, so every key is still queried there.
|
||||
|
||||
`settings.json.example` intentionally stays minimal (only `use_portable_settings`,
|
||||
`civitai_api_key`, and the four core `folder_paths` keys: `loras`, `checkpoints`,
|
||||
`unet`, `embeddings`). Optional keys — including the other-model folder paths and
|
||||
`enable_other_models` — are NOT documented there; they live in `DEFAULT_SETTINGS`
|
||||
and reach the user's `settings.json` on demand. This supersedes the Phase-1/Phase-2
|
||||
notes that proposed adding the other-model folder keys to the example.
|
||||
|
||||
### 11.2 Behaviour matrix
|
||||
|
||||
| state | scan | nav / `/other` | other downloads | `default_other_roots` | Doctor / refresh-all |
|
||||
|---|---|---|---|---|---|
|
||||
| master off | nothing (`other_roots == []`) | nav entry hidden (`nav-item--hidden`); `/other` still renders the disabled empty state + Enable button; one-time dismissible announcement banner on first visit | rejected | preserved, never auto-set | scanner skipped |
|
||||
| sub_type off | that sub_type's folder keys excluded | page keeps working, type disappears from data | auto-routing refused (manual folder still allowed) | preserved, not preselected | normal |
|
||||
| all on (after enabling) | Phase-1/2 behaviour | normal | normal | normal | normal |
|
||||
|
||||
### 11.3 Backend touch points
|
||||
|
||||
- `py/utils/constants.py` — `DEFAULT_ENABLED_OTHER_SUB_TYPES`, `OTHER_SUB_TYPE_FOLDER_KEYS`, `normalize_other_sub_types`.
|
||||
- `py/config.py` — `_get_enabled_other_folder_keys()` is the single scan gate (master switch + allow-list); new `refresh_other_roots()` rebuilds roots + preview roots on toggle.
|
||||
- `py/services/settings_manager.py` — new defaults, `set()` normalization, `is_other_models_enabled()` / `get_enabled_other_sub_types()` / `is_other_sub_type_enabled()`, and `_apply_other_model_settings_change()` which reapplies config and calls `other_scanner.on_library_changed(reconcile=True)`.
|
||||
- `py/services/model_scanner.py` — `_should_keep_cached_entry()` hydration hook (default keep) plus `on_library_changed(reconcile=...)` / `initialize_in_background(reconcile=...)`; the hook filters `raw_data` and the hash/autov3 index rows.
|
||||
- `py/services/other_scanner.py` — drops persisted entries whose folder is no longer a managed root (sub_type is location-derived, so config is the source of truth).
|
||||
- `py/routes/other_routes.py` — `_validate_civitai_model_type` rejects everything while off / mapped-but-disabled sub_types; `_get_page_context_provider()` injects `other_disabled` into the template.
|
||||
- `py/routes/handlers/model_handlers.py` + `base_model_routes.py` — optional `page_context_provider` hook on `ModelPageView`.
|
||||
- `py/routes/handlers/download_routing_handlers.py` — returns `{sub_type: None, disabled: true, reason}` instead of guessing.
|
||||
- `py/services/download_manager.py` — rejects other-type downloads while off; disabled sub_type refuses default-path routing with a "pick a folder" error.
|
||||
- `py/routes/handlers/misc_handlers.py` — Doctor / init-status / refresh-all skip the other scanner while off (`_active_scanner_factories` / `_active_scanner_getters`).
|
||||
- `py/services/pending_delete_service.py` — deliberately untouched: the scanner stays registered so staged deletes still merge.
|
||||
|
||||
### 11.4 Frontend
|
||||
|
||||
Discoverability: the nav entry is hidden while the feature is off, and three
|
||||
lightweight surfaces replace it — a one-time announcement banner, the download
|
||||
toast, and the settings toggle itself.
|
||||
|
||||
- `templates/components/header.html` + `static/css/components/header.css` — `nav-item--hidden` class (server-rendered when off, client-toggled after enabling) and the `fa-shapes` icon.
|
||||
- `templates/other.html` — `other_disabled` branch in `content` + `main_script`; page-scoped CSS for the empty state.
|
||||
- `static/js/other_disabled.js` — boots `appCore` (shared header) and delegates to the shared enable helper.
|
||||
- `static/js/utils/otherModels.js` — shared `enableOtherModels()` (POST settings + reload) and `openOtherModelsSettings()` (settings modal on the Library section); used by the disabled page, the banner and the download modal.
|
||||
- `static/js/managers/BannerService.js` — `other-models-announcement` banner (only when off and not dismissed; `priority: 0`, dismissal persisted via `dismissed_banners`) with Enable / Open Settings actions; `removeOtherModelsAnnouncement()` drops it without persisting a dismissal.
|
||||
- `templates/components/modals/settings/library.html` + `SettingsManager.updateOtherModelsControls()` / `saveEnabledOtherSubTypes()` / `updateOtherModelsNavVisibility()` — master toggle + five sub_type checkboxes; unchecked/disabled sub_types have their default-root select disabled.
|
||||
- `static/js/managers/DownloadManager.js` — a disabled routing answer surfaces a `showActionToast` with an "Enable Other Models" action (opening settings) and falls back to manual selection.
|
||||
- i18n: `settings.folderSettings.*`, `other.disabled.*` and `banners.otherModels.*` keys in `locales/en.json` + `scripts/sync_translation_keys.py` (other locales keep `[TODO: Translate]`).
|
||||
|
||||
### 11.5 Cache consistency
|
||||
|
||||
- Disabling purges rows from the in-memory view at hydration time (the
|
||||
`_should_keep_cached_entry` hook) and from SQLite on the reconcile triggered by
|
||||
the toggle; the `.metadata.json` sidecars survive, so re-enabling rescans
|
||||
without recomputing hashes (critical for multi-GB text encoders).
|
||||
- Enabling triggers a reconcile so newly managed roots are scanned immediately.
|
||||
- Editing `settings.json` while the server is stopped is still covered by the
|
||||
hydration hook, so disabled types never appear after a restart.
|
||||
|
||||
### 11.6 Tests
|
||||
|
||||
Backend: opt-in fixtures added to the other-related suites; new coverage for
|
||||
"default off scans nothing", per-sub_type gating, routing/download rejection,
|
||||
`_should_keep_cached_entry`, settings normalization and `other_disabled` page
|
||||
context. Frontend: `updateOtherModelsControls` / `saveEnabledOtherSubTypes` and
|
||||
the disabled-page enable flow.
|
||||
@@ -1,58 +0,0 @@
|
||||
# CivitAI image imports can end up with 0 LoRAs
|
||||
|
||||
## Symptom
|
||||
|
||||
Importing a CivitAI image URL can produce a recipe with **zero LoRA
|
||||
entries**, even though the image page lists LoRAs in its resource panel.
|
||||
|
||||
Reported example: `https://civitai.red/images/140818889` was imported as a
|
||||
local recipe with 0 LoRAs, while the page shows 3 LoRAs. Some images (e.g.
|
||||
NSFW / higher browsing level) additionally require a login to view, so their
|
||||
data is not publicly reachable at all.
|
||||
|
||||
## Root cause
|
||||
|
||||
URL imports use only two data sources:
|
||||
|
||||
1. **CivitAI REST image API** — `GET /api/v1/images?imageId=<id>&nsfw=X&withMeta=true` → `meta`
|
||||
2. **Embedded image metadata** — EXIF/XMP read from the downloaded bytes
|
||||
|
||||
For the same image both sources can be empty, and the one source that does
|
||||
contain the data is never queried. Verified for image 140818889:
|
||||
|
||||
| Source | What it returned |
|
||||
|---|---|
|
||||
| REST image API | `meta` holds only a prompt; `modelVersionIds: []`; no `resources`/`hashes`; `baseModel: null` |
|
||||
| Downloaded image | PNG with **no EXIF/XMP** (the CDN URL ends in `.jpeg`, the body is PNG) |
|
||||
| Image page HTML | `__NEXT_DATA__` embeds the trpc `image.getGenerationData` result → full `resources` list: 3 LoRAs, each with `modelId`, `modelVersionId`, `modelName`, `modelType`, `versionName`, `baseModel` |
|
||||
|
||||
Key points:
|
||||
|
||||
- The page's resource panel is fed by an **internal, non-public trpc
|
||||
endpoint**, not by the public REST image API.
|
||||
- That internal endpoint is **login-gated** for some content — the
|
||||
"requires login" symptom.
|
||||
- Even with the version IDs in hand, `/model-versions/{id}` for these
|
||||
(Krea) versions returns **no `sha256`**, so an exact local-file hash match
|
||||
is impossible; only model/version identity is recoverable.
|
||||
|
||||
## Conclusion / status
|
||||
|
||||
0-LoRA imports are a data-source gap: public REST meta and image EXIF are
|
||||
both empty, while the only complete source (page generation data) is
|
||||
internal, sometimes login-gated, and not used by the importer.
|
||||
|
||||
Such imports **cannot be reliably auto-repaired/completed** by the backend
|
||||
alone. The old "Repair Metadata" feature only re-fetched the same incomplete
|
||||
REST meta and could not fix them; it was deprecated and has been removed.
|
||||
|
||||
**Fixed via the companion browser extension.** When the extension is
|
||||
installed with a valid license, it scrapes the image page's internal trpc
|
||||
generation data with the user's session and calls the payload-capable
|
||||
re-import endpoint (`POST /api/lm/recipe/{recipe_id}/reimport` with
|
||||
`image_url`/`name`/`resources`/`gen_params`/`base_model`/`tags` query
|
||||
params), which rebuilds the recipe from the caller-supplied metadata. The
|
||||
web UI delegates re-import of CivitAI-image-sourced recipes to the extension
|
||||
automatically (probe + `lm:reimport*` DOM events); without the extension,
|
||||
re-import silently falls back to the native path, which remains limited by
|
||||
the data-source gap documented above.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
|
||||
|
||||
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
|
||||
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
|
||||
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
|
||||
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
|
||||
~1400 ms 降到 ~120 ms。
|
||||
|
||||
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
|
||||
因此单独记录,留待 Windows 机器上确认。
|
||||
|
||||
---
|
||||
|
||||
## 待验证分支(现状)
|
||||
|
||||
`py/services/model_scanner.py` 中 `_reconcile_cache()` 的 walk 循环内:
|
||||
|
||||
```python
|
||||
# Try case-insensitive match on Windows
|
||||
if os.name == 'nt':
|
||||
lower_path = file_path.lower()
|
||||
matched = False
|
||||
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
|
||||
if cached_path.lower() == lower_path:
|
||||
found_paths.add(cached_path)
|
||||
matched = True
|
||||
break
|
||||
if matched:
|
||||
continue
|
||||
```
|
||||
|
||||
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
|
||||
|
||||
### 为什么可疑
|
||||
|
||||
1. **可能不可达**:Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
|
||||
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
|
||||
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
|
||||
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
|
||||
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
|
||||
3. **没有测试覆盖**:`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
|
||||
被 `os.name == 'nt'` 短路,无法覆盖)。
|
||||
|
||||
---
|
||||
|
||||
## 待办
|
||||
|
||||
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
|
||||
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
|
||||
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
|
||||
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
|
||||
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
|
||||
- [ ] **补一个 Windows-only 的回归测试**(`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
|
||||
- [ ] 把验证结论回填到本文件,并同步更新状态行。
|
||||
|
||||
---
|
||||
|
||||
## 验证方法(Windows)
|
||||
|
||||
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
|
||||
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
|
||||
2. **看后端日志判据**:
|
||||
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,
|
||||
且**没有** `Found N new files to process` / `Processing <path>`。
|
||||
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
|
||||
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
|
||||
(缓存路径被当成了新文件 + 旧条目被删)。
|
||||
3. **跑测试**:`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
|
||||
真实执行 `os.name == 'nt'` 分支)。
|
||||
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
|
||||
|
||||
---
|
||||
|
||||
## 已完成(本轮优化,供对照)
|
||||
|
||||
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
|
||||
|
||||
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
|
||||
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
|
||||
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
|
||||
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
|
||||
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
|
||||
|
||||
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
|
||||
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
|
||||
47 项、全量后端 2567 项全部通过。
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||
"modelNameCopied": "Modellname kopiert",
|
||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "Alle {typePlural} verfügen bereits über Lizenzmetadaten",
|
||||
"error": "Lizenzmetadaten für {typePlural} konnten nicht aktualisiert werden: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Rezept-Daten reparieren",
|
||||
"loading": "Rezept-Daten werden repariert...",
|
||||
"success": "{count} Rezepte erfolgreich repariert.",
|
||||
"cancelled": "Reparatur abgebrochen. {count} Rezepte wurden repariert.",
|
||||
"error": "Rezept-Reparatur fehlgeschlagen: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
||||
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
|
||||
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
||||
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
||||
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "Rezepte",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Andere",
|
||||
"statistics": "Statistiken"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Nach Modell gruppieren",
|
||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes CivitAI-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
||||
"stickyControls": "Aktionsleiste sichtbar halten",
|
||||
"stickyControlsHelp": "Wenn aktiviert, bleibt die Aktionsleiste (Aktualisieren, Herunterladen usw.) beim Scrollen zusammen mit der Breadcrumb-Navigation oben angeheftet.",
|
||||
"displayDensity": "Anzeige-Dichte",
|
||||
"displayDensityOptions": {
|
||||
"default": "Standard",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultVaeRoot": "VAE-Stammordner",
|
||||
"defaultVaeRootHelp": "Legen Sie den Standard-VAE-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultUpscalerRoot": "Upscaler-Stammordner",
|
||||
"defaultUpscalerRootHelp": "Legen Sie den Standard-Upscaler-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultTextEncoderRoot": "Text-Encoder-Stammordner",
|
||||
"defaultTextEncoderRootHelp": "Legen Sie den Standard-Text-Encoder-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultClipVisionRoot": "CLIP-Vision-Stammordner",
|
||||
"defaultClipVisionRootHelp": "Legen Sie den Standard-CLIP-Vision-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"defaultControlnetRoot": "ControlNet-Stammordner",
|
||||
"defaultControlnetRootHelp": "Legen Sie den Standard-ControlNet-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||
"enableOtherModels": "Verwaltung weiterer Modelle",
|
||||
"enableOtherModelsHelp": "Wenn deaktiviert, werden VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Ordner nicht gescannt, die Seite für weitere Modelle bleibt deaktiviert und diese Modelltypen können nicht heruntergeladen werden.",
|
||||
"otherSubTypes": "Verwaltete Modelltypen",
|
||||
"otherSubTypesHelp": "Wählen Sie, welche Kategorien weiterer Modelle gescannt und auf der Seite für weitere Modelle angezeigt werden.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Rezepte-Speicherpfad",
|
||||
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||
"copyAll": "Alle Syntax kopieren",
|
||||
"refreshAll": "Alle Metadaten aktualisieren",
|
||||
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "Vorschau ersetzen",
|
||||
"setContentRating": "Inhaltsbewertung festlegen",
|
||||
"moveToFolder": "In Ordner verschieben",
|
||||
"repairMetadata": "Metadaten reparieren",
|
||||
"rematchMetadata": "Mit lokalen Modellen abgleichen",
|
||||
"reimportMetadata": "Aus Quelle neu importieren",
|
||||
"excludeModel": "Modell ausschließen",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Rezept-Metadaten werden repariert...",
|
||||
"success": "Rezept-Metadaten erfolgreich repariert",
|
||||
"skipped": "Rezept bereits in der neuesten Version, keine Reparatur erforderlich",
|
||||
"failed": "Rezept-Reparatur fehlgeschlagen: {message}",
|
||||
"missingId": "Rezept kann nicht repariert werden: Fehlende Rezept-ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Rezept wird aus Quelle neu importiert...",
|
||||
"success": "Rezept erfolgreich neu importiert",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding-Modelle"
|
||||
},
|
||||
"other": {
|
||||
"title": "Weitere Modelle",
|
||||
"disabled": {
|
||||
"title": "Die Verwaltung weiterer Modelle ist deaktiviert",
|
||||
"description": "Aktivieren Sie die Option, um VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien zu scannen und zu verwalten und sie von CivitAI herunterzuladen.",
|
||||
"enableButton": "Weitere Modelle aktivieren",
|
||||
"hint": "Sie können die verwalteten Modelltypen später unter Einstellungen > Bibliothek ändern.",
|
||||
"enableFailed": "Aktivierung weiterer Modelle fehlgeschlagen",
|
||||
"downloadBlocked": "Die Verwaltung weiterer Modelle ist für diesen Modelltyp deaktiviert. Aktivieren Sie sie unter Einstellungen > Bibliothek, um diese Datei herunterzuladen.",
|
||||
"enableAction": "Weitere Modelle aktivieren"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Keine Ordner für weitere Modelle gefunden",
|
||||
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.",
|
||||
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
|
||||
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
|
||||
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Stammverzeichnis",
|
||||
"collapseAll": "Alle Ordner einklappen",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
||||
"downloadButton": "{count} LoRA(s) herunterladen"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Rezepte neu zuordnen",
|
||||
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
|
||||
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
|
||||
"confirmButton": "Neu zuordnen"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Rückgängig",
|
||||
"undone": "Rückgängig gemacht",
|
||||
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Zusammenfassung der Neuordnung",
|
||||
"successMessage": "{entries} Einträge zugeordnet",
|
||||
"failed": "Neuordnung fehlgeschlagen",
|
||||
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
|
||||
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
|
||||
"statMatched": "Zugeordnete Einträge",
|
||||
"statReview": "Zu überprüfen",
|
||||
"statUnresolved": "Nicht zugeordnet",
|
||||
"statErrors": "Fehler",
|
||||
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
|
||||
"columnRecipe": "Rezept",
|
||||
"columnEntry": "Eintrag",
|
||||
"columnFile": "Zugeordnete Datei",
|
||||
"columnUndo": "Rückgängig",
|
||||
"copyReport": "Bericht kopieren",
|
||||
"close": "Schließen",
|
||||
"scope_global": "Alle Rezepte",
|
||||
"scope_bulk": "Ausgewählte Rezepte",
|
||||
"scope_single": "Einzelnes Rezept"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Lokale Beispielbilder",
|
||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Embedding Manager wird initialisiert",
|
||||
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Manager für weitere Modelle wird initialisiert",
|
||||
"message": "Modell-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Rezept Manager wird initialisiert",
|
||||
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||
"created": "Rezept erfolgreich erstellt",
|
||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
|
||||
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
|
||||
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
||||
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
|
||||
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
|
||||
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
||||
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
||||
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
|
||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
||||
"otherRootsFailed": "Fehler beim Laden der Stammverzeichnisse weiterer Modelle: {message}",
|
||||
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
||||
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "Cache wird neu aufgebaut...",
|
||||
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
|
||||
"retry": "Wiederholen"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "Die Verwaltung weiterer Modelle ist verfügbar",
|
||||
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
|
||||
"enable": "Weitere Modelle aktivieren",
|
||||
"openSettings": "Einstellungen öffnen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Copy checkpoint name",
|
||||
"copyEmbeddingName": "Copy embedding name",
|
||||
"embeddingNameCopied": "Embedding syntax copied",
|
||||
"modelNameCopied": "Model name copied",
|
||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "All {typePlural} already have license metadata",
|
||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Repair recipes data",
|
||||
"loading": "Repairing recipe data...",
|
||||
"success": "Successfully repaired {count} recipes.",
|
||||
"cancelled": "Repair cancelled. {count} recipes were repaired.",
|
||||
"error": "Recipe repair failed: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "Rematch recipes to local models",
|
||||
"loading": "Rematching recipes to local models...",
|
||||
"success": "Matched {entries} entries across {recipes} recipes",
|
||||
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||
"allFailed": "Rematch failed for {failures} of {total} recipes",
|
||||
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
|
||||
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
||||
"error": "Recipe rematch failed: {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "Recipes",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Other",
|
||||
"statistics": "Stats"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"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.",
|
||||
"stickyControls": "Keep Action Bar Visible",
|
||||
"stickyControlsHelp": "When enabled, the action bar (Refresh, Download, etc.) stays pinned at the top while scrolling, together with the breadcrumb navigation.",
|
||||
"displayDensity": "Display Density",
|
||||
"displayDensityOptions": {
|
||||
"default": "Default",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||
"defaultEmbeddingRoot": "Embedding Root",
|
||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||
"defaultVaeRoot": "VAE Root",
|
||||
"defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves",
|
||||
"defaultUpscalerRoot": "Upscaler Root",
|
||||
"defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves",
|
||||
"defaultTextEncoderRoot": "Text Encoder Root",
|
||||
"defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves",
|
||||
"defaultClipVisionRoot": "CLIP Vision Root",
|
||||
"defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves",
|
||||
"defaultControlnetRoot": "ControlNet Root",
|
||||
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
|
||||
"enableOtherModels": "Other Models Management",
|
||||
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP vision / ControlNet folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
|
||||
"otherSubTypes": "Managed Types",
|
||||
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Recipes Storage Path",
|
||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "Set Content Rating for Selected",
|
||||
"copyAll": "Copy Selected Syntax",
|
||||
"refreshAll": "Refresh Selected Metadata",
|
||||
"repairMetadata": "Repair Metadata for Selected",
|
||||
"rematchMetadata": "Rematch Selected to Local Models",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"checkUpdates": "Check Updates for Selected",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "Replace Preview",
|
||||
"setContentRating": "Set Content Rating",
|
||||
"moveToFolder": "Move to Folder",
|
||||
"repairMetadata": "Repair metadata",
|
||||
"rematchMetadata": "Rematch to local models",
|
||||
"reimportMetadata": "Re-import from Source",
|
||||
"excludeModel": "Exclude Model",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "Failed to get information for missing LoRAs",
|
||||
"prepareError": "Error preparing LoRAs for download: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Repairing recipe metadata...",
|
||||
"success": "Recipe metadata repaired successfully",
|
||||
"skipped": "Recipe already at latest version, no repair needed",
|
||||
"failed": "Failed to repair recipe: {message}",
|
||||
"missingId": "Cannot repair recipe: Missing recipe ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Re-importing recipe from source...",
|
||||
"success": "Recipe re-imported successfully",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding Models"
|
||||
},
|
||||
"other": {
|
||||
"title": "Other Models",
|
||||
"disabled": {
|
||||
"title": "Other Models management is off",
|
||||
"description": "Enable it to scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files, and to download them from CivitAI.",
|
||||
"enableButton": "Enable Other Models",
|
||||
"hint": "You can change the managed model types later in Settings > Library.",
|
||||
"enableFailed": "Failed to enable Other Models",
|
||||
"downloadBlocked": "Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
|
||||
"enableAction": "Enable Other Models"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "No other-model folders found",
|
||||
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.",
|
||||
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
|
||||
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
|
||||
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
|
||||
"openSettings": "Open Settings"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Root",
|
||||
"collapseAll": "Collapse All Folders",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
|
||||
"downloadButton": "Download {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Rematch Recipes",
|
||||
"messageGlobal": "All recipes will be scanned against your local model library.",
|
||||
"messageSingle": "This recipe will be scanned against your local model library.",
|
||||
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
|
||||
"relaxedLabel": "Also reconnect missing models by file name",
|
||||
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
|
||||
"confirmButton": "Rematch"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Undo",
|
||||
"undone": "Undone",
|
||||
"undoFailed": "Failed to undo rematch: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Rematch Summary",
|
||||
"successMessage": "Matched {entries} entries",
|
||||
"failed": "Rematch failed",
|
||||
"completedWithWarnings": "Rematch completed — review recommended",
|
||||
"cancelledNote": "Run cancelled before completion — counts are partial.",
|
||||
"statMatched": "Matched entries",
|
||||
"statReview": "Needs review",
|
||||
"statUnresolved": "Unresolved",
|
||||
"statErrors": "Errors",
|
||||
"reviewSection": "Filename matches to review ({count})",
|
||||
"columnRecipe": "Recipe",
|
||||
"columnEntry": "Entry",
|
||||
"columnFile": "Matched file",
|
||||
"columnUndo": "Undo",
|
||||
"copyReport": "Copy Report",
|
||||
"close": "Close",
|
||||
"scope_global": "All recipes",
|
||||
"scope_bulk": "Selected recipes",
|
||||
"scope_single": "Single recipe"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Local Example Images",
|
||||
"message": "No local example images found for this model. View options:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Initializing Embedding Manager",
|
||||
"message": "Scanning and building embedding cache. This may take a few minutes..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Initializing Other Models Manager",
|
||||
"message": "Scanning and building model cache. This may take a few minutes..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Initializing Recipe Manager",
|
||||
"message": "Loading and processing recipes. This may take a few minutes..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "Missing required data to create recipe",
|
||||
"created": "Recipe created successfully",
|
||||
"noMissingLoras": "No missing LoRAs to download",
|
||||
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
|
||||
"noPreviousRecipe": "No previous recipe available",
|
||||
"noNextRecipe": "No next recipe available",
|
||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||
"noRecipesSelected": "No recipes selected",
|
||||
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
|
||||
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
|
||||
"repairBulkFailed": "Failed to repair selected recipes: {message}",
|
||||
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
|
||||
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
||||
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
|
||||
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||
"reimporting": "Re-importing recipe from source...",
|
||||
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
|
||||
"reimportSuccess": "Recipe re-imported successfully",
|
||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||
"reimportBulkFailed": "Failed to re-import some recipes",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
||||
"otherRootsFailed": "Failed to load other model roots: {message}",
|
||||
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||
"mappingsCleared": "Base model path mappings cleared",
|
||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "Rebuilding cache...",
|
||||
"rebuildFailed": "Failed to rebuild cache: {error}",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "Other Models Management is available",
|
||||
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
|
||||
"enable": "Enable Other Models",
|
||||
"openSettings": "Open Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||
"modelNameCopied": "Nombre del modelo copiado",
|
||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "Todos los {typePlural} ya tienen metadatos de licencia",
|
||||
"error": "No se pudieron actualizar los metadatos de licencia de los {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Reparar datos de recetas",
|
||||
"loading": "Reparando datos de recetas...",
|
||||
"success": "Se repararon con éxito {count} recetas.",
|
||||
"cancelled": "Reparación cancelada. {count} recetas fueron reparadas.",
|
||||
"error": "Error al reparar recetas: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "Reasociar recetas con modelos locales",
|
||||
"loading": "Reasociando recetas con modelos locales...",
|
||||
"success": "{entries} entradas asociadas en {recipes} recetas",
|
||||
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
|
||||
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
||||
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
||||
"error": "Falló la reasociación de recetas: {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "Recetas",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Otros",
|
||||
"statistics": "Estadísticas"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Agrupar por modelo",
|
||||
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de CivitAI como una tarjeta única. Las versiones anteriores están ocultas.",
|
||||
"stickyControls": "Mantener visible la barra de acciones",
|
||||
"stickyControlsHelp": "Cuando está activado, la barra de acciones (Actualizar, Descargar, etc.) permanece fijada en la parte superior al desplazarse, junto con la navegación por rutas.",
|
||||
"displayDensity": "Densidad de visualización",
|
||||
"displayDensityOptions": {
|
||||
"default": "Predeterminado",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||
"defaultVaeRoot": "Raíz de VAE",
|
||||
"defaultVaeRootHelp": "Establecer el directorio raíz predeterminado de VAE para descargas, importaciones y movimientos",
|
||||
"defaultUpscalerRoot": "Raíz de Upscaler",
|
||||
"defaultUpscalerRootHelp": "Establecer el directorio raíz predeterminado de Upscaler para descargas, importaciones y movimientos",
|
||||
"defaultTextEncoderRoot": "Raíz de Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "Establecer el directorio raíz predeterminado de Text Encoder para descargas, importaciones y movimientos",
|
||||
"defaultClipVisionRoot": "Raíz de CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "Establecer el directorio raíz predeterminado de CLIP Vision para descargas, importaciones y movimientos",
|
||||
"defaultControlnetRoot": "Raíz de ControlNet",
|
||||
"defaultControlnetRootHelp": "Establecer el directorio raíz predeterminado de ControlNet para descargas, importaciones y movimientos",
|
||||
"enableOtherModels": "Gestión de otros modelos",
|
||||
"enableOtherModelsHelp": "Cuando está desactivado, las carpetas VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet no se escanean, la página Otros modelos permanece desactivada y estos tipos de modelos no se pueden descargar.",
|
||||
"otherSubTypes": "Tipos de modelos gestionados",
|
||||
"otherSubTypesHelp": "Elige qué categorías de otros modelos se escanean y se muestran en la página Otros modelos.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Ruta de almacenamiento de recetas",
|
||||
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||
"copyAll": "Copiar toda la sintaxis",
|
||||
"refreshAll": "Actualizar todos los metadatos",
|
||||
"repairMetadata": "Reparar metadatos de la selección",
|
||||
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "Reemplazar vista previa",
|
||||
"setContentRating": "Establecer clasificación de contenido",
|
||||
"moveToFolder": "Mover a carpeta",
|
||||
"repairMetadata": "Reparar metadatos",
|
||||
"rematchMetadata": "Reasociar con modelos locales",
|
||||
"reimportMetadata": "Reimportar desde origen",
|
||||
"excludeModel": "Excluir modelo",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Reparando metadatos de la receta...",
|
||||
"success": "Metadatos de la receta reparados con éxito",
|
||||
"skipped": "La receta ya está en la última versión, no se necesita reparación",
|
||||
"failed": "Error al reparar la receta: {message}",
|
||||
"missingId": "No se puede reparar la receta: falta el ID de la receta"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Reimportando receta desde origen...",
|
||||
"success": "Receta reimportada exitosamente",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Modelos embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "Otros modelos",
|
||||
"disabled": {
|
||||
"title": "La gestión de otros modelos está desactivada",
|
||||
"description": "Actívala para escanear y gestionar archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y para descargarlos desde CivitAI.",
|
||||
"enableButton": "Activar otros modelos",
|
||||
"hint": "Puedes cambiar los tipos de modelos gestionados más adelante en Configuración > Biblioteca.",
|
||||
"enableFailed": "No se pudieron activar los otros modelos",
|
||||
"downloadBlocked": "La gestión de otros modelos está desactivada para este tipo de modelo. Actívala en Configuración > Biblioteca para descargar este archivo.",
|
||||
"enableAction": "Activar otros modelos"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "No se encontraron carpetas de otros modelos",
|
||||
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.",
|
||||
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
|
||||
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
|
||||
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
|
||||
"openSettings": "Abrir configuración"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Raíz",
|
||||
"collapseAll": "Colapsar todas las carpetas",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
|
||||
"downloadButton": "Descargar {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Reasociar recetas",
|
||||
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
|
||||
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
|
||||
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
|
||||
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
|
||||
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
|
||||
"confirmButton": "Reasociar"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Deshacer",
|
||||
"undone": "Deshecho",
|
||||
"undoFailed": "No se pudo deshacer la reasociación: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Resumen de la reasociación",
|
||||
"successMessage": "{entries} entradas asociadas",
|
||||
"failed": "Falló la reasociación",
|
||||
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
|
||||
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
|
||||
"statMatched": "Entradas asociadas",
|
||||
"statReview": "Por revisar",
|
||||
"statUnresolved": "Sin coincidencia",
|
||||
"statErrors": "Errores",
|
||||
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
|
||||
"columnRecipe": "Receta",
|
||||
"columnEntry": "Entrada",
|
||||
"columnFile": "Archivo coincidente",
|
||||
"columnUndo": "Deshacer",
|
||||
"copyReport": "Copiar informe",
|
||||
"close": "Cerrar",
|
||||
"scope_global": "Todas las recetas",
|
||||
"scope_bulk": "Recetas seleccionadas",
|
||||
"scope_single": "Receta individual"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Imágenes de ejemplo locales",
|
||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Inicializando gestor de embedding",
|
||||
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Inicializando el gestor de otros modelos",
|
||||
"message": "Escaneando y construyendo la caché de modelos. Esto puede tomar unos minutos..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Inicializando gestor de recetas",
|
||||
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||
"created": "Receta creada exitosamente",
|
||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
|
||||
"noPreviousRecipe": "No hay receta anterior disponible",
|
||||
"noNextRecipe": "No hay siguiente receta disponible",
|
||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
||||
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
||||
"noRecipesSelected": "No se han seleccionado recetas",
|
||||
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
|
||||
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
|
||||
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
|
||||
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
|
||||
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
||||
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
||||
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||
"reimporting": "Reimportando receta desde origen...",
|
||||
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
|
||||
"reimportSuccess": "Receta reimportada exitosamente",
|
||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
||||
"otherRootsFailed": "Error al cargar raíces de otros modelos: {message}",
|
||||
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "Reconstruyendo caché...",
|
||||
"rebuildFailed": "Error al reconstruir la caché: {error}",
|
||||
"retry": "Reintentar"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "La gestión de otros modelos ya está disponible",
|
||||
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
|
||||
"enable": "Activar otros modelos",
|
||||
"openSettings": "Abrir configuración"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||
"modelNameCopied": "Nom du modèle copié",
|
||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "Tous les {typePlural} possèdent déjà des métadonnées de licence",
|
||||
"error": "Échec de l'actualisation des métadonnées de licence pour les {typePlural} : {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Réparer les données de Recipes",
|
||||
"loading": "Réparation des données de Recipes...",
|
||||
"success": "{count} Recipes réparées avec succès.",
|
||||
"cancelled": "Réparation annulée. {count} Recipes ont été réparées.",
|
||||
"error": "Échec de la réparation des Recipes : {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "Réassocier les Recipes aux modèles locaux",
|
||||
"loading": "Réassociation des Recipes aux modèles locaux...",
|
||||
"success": "{entries} entrées associées dans {recipes} Recipes",
|
||||
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
||||
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
|
||||
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
||||
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
||||
"error": "Échec de la réassociation des Recipes : {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "Recipes",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Autres",
|
||||
"statistics": "Statistiques"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Grouper par modèle",
|
||||
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle CivitAI s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
|
||||
"stickyControls": "Garder la barre d'actions visible",
|
||||
"stickyControlsHelp": "Lorsque activé, la barre d'actions (Actualiser, Télécharger, etc.) reste épinglée en haut lors du défilement, avec la navigation par fil d'Ariane.",
|
||||
"displayDensity": "Densité d'affichage",
|
||||
"displayDensityOptions": {
|
||||
"default": "Par défaut",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultEmbeddingRoot": "Racine Embedding",
|
||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultVaeRoot": "Racine VAE",
|
||||
"defaultVaeRootHelp": "Définir le répertoire racine VAE par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultUpscalerRoot": "Racine Upscaler",
|
||||
"defaultUpscalerRootHelp": "Définir le répertoire racine Upscaler par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultTextEncoderRoot": "Racine Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "Définir le répertoire racine Text Encoder par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultClipVisionRoot": "Racine CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "Définir le répertoire racine CLIP Vision par défaut pour les téléchargements, imports et déplacements",
|
||||
"defaultControlnetRoot": "Racine ControlNet",
|
||||
"defaultControlnetRootHelp": "Définir le répertoire racine ControlNet par défaut pour les téléchargements, imports et déplacements",
|
||||
"enableOtherModels": "Gestion des autres modèles",
|
||||
"enableOtherModelsHelp": "Lorsque cette option est désactivée, les dossiers VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet ne sont pas analysés, la page Autres modèles reste désactivée et ces types de modèles ne peuvent pas être téléchargés.",
|
||||
"otherSubTypes": "Types de modèles gérés",
|
||||
"otherSubTypesHelp": "Choisissez les catégories d’autres modèles analysées et affichées sur la page Autres modèles.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Chemin de stockage des Recipes",
|
||||
"recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "Définir la classification du contenu pour tous",
|
||||
"copyAll": "Copier toute la syntaxe",
|
||||
"refreshAll": "Actualiser toutes les métadonnées",
|
||||
"repairMetadata": "Réparer les métadonnées de la sélection",
|
||||
"rematchMetadata": "Réassocier la sélection aux modèles locaux",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "Remplacer l'aperçu",
|
||||
"setContentRating": "Définir la classification du contenu",
|
||||
"moveToFolder": "Déplacer vers un dossier",
|
||||
"repairMetadata": "Réparer les métadonnées",
|
||||
"rematchMetadata": "Réassocier aux modèles locaux",
|
||||
"reimportMetadata": "Ré-importer depuis la source",
|
||||
"excludeModel": "Exclure le modèle",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
"prepareError": "Erreur lors de la préparation des LoRAs pour le téléchargement : {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Réparation des métadonnées de la Recipe...",
|
||||
"success": "Métadonnées de la Recipe réparées avec succès",
|
||||
"skipped": "Recette déjà à la version la plus récente, aucune réparation nécessaire",
|
||||
"failed": "Échec de la réparation de la Recipe : {message}",
|
||||
"missingId": "Impossible de réparer la Recipe : ID de Recipe manquant"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Ré-import de la Recipe depuis la source...",
|
||||
"success": "Recette ré-importée avec succès",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Modèles Embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "Autres modèles",
|
||||
"disabled": {
|
||||
"title": "La gestion des autres modèles est désactivée",
|
||||
"description": "Activez-la pour analyser et gérer les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et pour les télécharger depuis CivitAI.",
|
||||
"enableButton": "Activer les autres modèles",
|
||||
"hint": "Vous pourrez modifier les types de modèles gérés plus tard dans Paramètres > Bibliothèque.",
|
||||
"enableFailed": "Échec de l’activation des autres modèles",
|
||||
"downloadBlocked": "La gestion des autres modèles est désactivée pour ce type de modèle. Activez-la dans Paramètres > Bibliothèque pour télécharger ce fichier.",
|
||||
"enableAction": "Activer les autres modèles"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Aucun dossier d’autres modèles trouvé",
|
||||
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.",
|
||||
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
|
||||
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
|
||||
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Racine",
|
||||
"collapseAll": "Réduire tous les dossiers",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
|
||||
"downloadButton": "Télécharger {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Réassocier les Recipes",
|
||||
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
|
||||
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
|
||||
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
|
||||
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
|
||||
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
|
||||
"confirmButton": "Réassocier"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Annuler",
|
||||
"undone": "Annulé",
|
||||
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Résumé de la réassociation",
|
||||
"successMessage": "{entries} entrées associées",
|
||||
"failed": "Échec de la réassociation",
|
||||
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
|
||||
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
|
||||
"statMatched": "Entrées associées",
|
||||
"statReview": "À vérifier",
|
||||
"statUnresolved": "Sans correspondance",
|
||||
"statErrors": "Erreurs",
|
||||
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
|
||||
"columnRecipe": "Recipe",
|
||||
"columnEntry": "Entrée",
|
||||
"columnFile": "Fichier correspondant",
|
||||
"columnUndo": "Annuler",
|
||||
"copyReport": "Copier le rapport",
|
||||
"close": "Fermer",
|
||||
"scope_global": "Toutes les Recipes",
|
||||
"scope_bulk": "Recipes sélectionnées",
|
||||
"scope_single": "Une seule Recipe"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Images d'exemple locales",
|
||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Initialisation du gestionnaire Embedding",
|
||||
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Initialisation du gestionnaire Autres modèles",
|
||||
"message": "Analyse et construction du cache de modèles. Cela peut prendre quelques minutes..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Initialisation du gestionnaire de recipes",
|
||||
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||
"created": "Recipe créé avec succès",
|
||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
|
||||
"noPreviousRecipe": "Aucune Recipe précédente",
|
||||
"noNextRecipe": "Aucune Recipe suivante",
|
||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
||||
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
||||
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
||||
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
|
||||
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} Recipes sélectionnées",
|
||||
"repairBulkFailed": "Échec de la réparation des Recipes sélectionnées : {message}",
|
||||
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
|
||||
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
||||
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
|
||||
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
||||
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
||||
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
||||
"reimportingViaExtension": "Ré-import de la Recipe {current}/{total} via l’extension du navigateur...",
|
||||
"reimportSuccess": "Recette ré-importée avec succès",
|
||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
||||
"otherRootsFailed": "Échec du chargement des racines des autres modèles : {message}",
|
||||
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
||||
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "Reconstruction du cache...",
|
||||
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
|
||||
"retry": "Réessayer"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "La gestion des autres modèles est disponible",
|
||||
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
|
||||
"enable": "Activer les autres modèles",
|
||||
"openSettings": "Ouvrir les paramètres"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "העתק שם Checkpoint",
|
||||
"copyEmbeddingName": "העתק שם Embedding",
|
||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||
"modelNameCopied": "שם המודל הועתק",
|
||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "לכל ה-{typePlural} כבר יש מטא-נתוני רישיון",
|
||||
"error": "לא ניתן היה לרענן את מטא-נתוני הרישיון עבור {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "תיקון נתוני מתכונים",
|
||||
"loading": "מתקן נתוני מתכונים...",
|
||||
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||
"error": "תיקון המתכונים נכשל: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
||||
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "מתכונים",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "אחרים",
|
||||
"statistics": "סטטיסטיקה"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "קיבוץ לפי מודל",
|
||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||
"stickyControls": "השארת סרגל הפעולות גלוי",
|
||||
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
|
||||
"displayDensity": "צפיפות תצוגה",
|
||||
"displayDensityOptions": {
|
||||
"default": "ברירת מחדל",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||
"defaultVaeRoot": "תיקיית שורש VAE",
|
||||
"defaultVaeRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של VAE להורדות, ייבוא והעברות",
|
||||
"defaultUpscalerRoot": "תיקיית שורש Upscaler",
|
||||
"defaultUpscalerRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Upscaler להורדות, ייבוא והעברות",
|
||||
"defaultTextEncoderRoot": "תיקיית שורש Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Text Encoder להורדות, ייבוא והעברות",
|
||||
"defaultClipVisionRoot": "תיקיית שורש CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של CLIP Vision להורדות, ייבוא והעברות",
|
||||
"defaultControlnetRoot": "תיקיית שורש ControlNet",
|
||||
"defaultControlnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של ControlNet להורדות, ייבוא והעברות",
|
||||
"enableOtherModels": "ניהול מודלים אחרים",
|
||||
"enableOtherModelsHelp": "כשהאפשרות כבויה, תיקיות VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet אינן נסרקות, עמוד המודלים האחרים נשאר מושבת ולא ניתן להוריד סוגי מודלים אלה.",
|
||||
"otherSubTypes": "סוגי מודלים מנוהלים",
|
||||
"otherSubTypesHelp": "בחר אילו קטגוריות של מודלים אחרים ייסרקו ויוצגו בעמוד המודלים האחרים.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "נתיב אחסון מתכונים",
|
||||
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||
"copyAll": "העתק את כל התחבירים",
|
||||
"refreshAll": "רענן את כל המטא-נתונים",
|
||||
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
|
||||
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "החלף תצוגה מקדימה",
|
||||
"setContentRating": "הגדר דירוג תוכן",
|
||||
"moveToFolder": "העבר לתיקייה",
|
||||
"repairMetadata": "תיקון מטא-נתונים",
|
||||
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
||||
"reimportMetadata": "ייבא מחדש ממקור",
|
||||
"excludeModel": "החרג מודל",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "מתקן מטא-נתונים של מתכון...",
|
||||
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
|
||||
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
||||
"failed": "תיקון המתכון נכשל: {message}",
|
||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "מייבא מתכון מחדש מהמקור...",
|
||||
"success": "המתכון יובא מחדש בהצלחה",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "מודלי Embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "מודלים אחרים",
|
||||
"disabled": {
|
||||
"title": "ניהול המודלים האחרים כבוי",
|
||||
"description": "הפעל כדי לסרוק ולנהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, ולהוריד אותם מ-CivitAI.",
|
||||
"enableButton": "הפעל מודלים אחרים",
|
||||
"hint": "ניתן לשנות את סוגי המודלים המנוהלים מאוחר יותר בהגדרות > ספרייה.",
|
||||
"enableFailed": "הפעלת המודלים האחרים נכשלה",
|
||||
"downloadBlocked": "ניהול המודלים האחרים מושבת עבור סוג מודל זה. הפעל אותו בהגדרות > ספרייה כדי להוריד קובץ זה.",
|
||||
"enableAction": "הפעל מודלים אחרים"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "לא נמצאו תיקיות של מודלים אחרים",
|
||||
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.",
|
||||
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
|
||||
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
|
||||
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
|
||||
"openSettings": "פתח הגדרות"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "שורש",
|
||||
"collapseAll": "כווץ את כל התיקיות",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||
"downloadButton": "הורד {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "התאמה מחדש של מתכונים",
|
||||
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
|
||||
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
|
||||
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
|
||||
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
|
||||
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
|
||||
"confirmButton": "התאם מחדש"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "בטל",
|
||||
"undone": "בוטל",
|
||||
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "סיכום התאמה מחדש",
|
||||
"successMessage": "הותאמו {entries} פריטים",
|
||||
"failed": "ההתאמה מחדש נכשלה",
|
||||
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
|
||||
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
|
||||
"statMatched": "פריטים שהותאמו",
|
||||
"statReview": "טעוני סקירה",
|
||||
"statUnresolved": "ללא התאמה",
|
||||
"statErrors": "שגיאות",
|
||||
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
|
||||
"columnRecipe": "מתכון",
|
||||
"columnEntry": "פריט",
|
||||
"columnFile": "הקובץ שהותאם",
|
||||
"columnUndo": "בטל",
|
||||
"copyReport": "העתק דוח",
|
||||
"close": "סגור",
|
||||
"scope_global": "כל המתכונים",
|
||||
"scope_bulk": "מתכונים שנבחרו",
|
||||
"scope_single": "מתכון בודד"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "תמונות דוגמה מקומיות",
|
||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "מאתחל מנהל Embedding",
|
||||
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
||||
},
|
||||
"other": {
|
||||
"title": "מאתחל את מנהל המודלים האחרים",
|
||||
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "מאתחל מנהל מתכונים",
|
||||
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||
"created": "המתכון נוצר בהצלחה",
|
||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
|
||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
|
||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
|
||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "בונה מחדש את המטמון...",
|
||||
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
||||
"retry": "נסה שוב"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "ניהול המודלים האחרים זמין",
|
||||
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
||||
"enable": "הפעל מודלים אחרים",
|
||||
"openSettings": "פתח הגדרות"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Checkpoint名をコピー",
|
||||
"copyEmbeddingName": "embedding名をコピー",
|
||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||
"modelNameCopied": "モデル名をコピーしました",
|
||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
|
||||
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "レシピデータの修復",
|
||||
"loading": "レシピデータを修復中...",
|
||||
"success": "{count} 件のレシピを正常に修復しました。",
|
||||
"cancelled": "修復がキャンセルされました。{count}件のレシピが修復されました。",
|
||||
"error": "レシピの修復に失敗しました: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "レシピをローカルモデルに再マッチング",
|
||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "レシピ",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "その他",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "モデルでグループ化",
|
||||
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||
"stickyControls": "アクションバーを常に表示",
|
||||
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
|
||||
"displayDensity": "表示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "デフォルト",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||
"defaultEmbeddingRoot": "Embeddingルート",
|
||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||
"defaultVaeRoot": "VAEルート",
|
||||
"defaultVaeRootHelp": "ダウンロード、インポート、移動用のデフォルトVAEルートディレクトリを設定",
|
||||
"defaultUpscalerRoot": "Upscalerルート",
|
||||
"defaultUpscalerRootHelp": "ダウンロード、インポート、移動用のデフォルトUpscalerルートディレクトリを設定",
|
||||
"defaultTextEncoderRoot": "Text Encoderルート",
|
||||
"defaultTextEncoderRootHelp": "ダウンロード、インポート、移動用のデフォルトText Encoderルートディレクトリを設定",
|
||||
"defaultClipVisionRoot": "CLIP Visionルート",
|
||||
"defaultClipVisionRootHelp": "ダウンロード、インポート、移動用のデフォルトCLIP Visionルートディレクトリを設定",
|
||||
"defaultControlnetRoot": "ControlNetルート",
|
||||
"defaultControlnetRootHelp": "ダウンロード、インポート、移動用のデフォルトControlNetルートディレクトリを設定",
|
||||
"enableOtherModels": "その他のモデル管理",
|
||||
"enableOtherModelsHelp": "オフにすると、VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet フォルダーはスキャンされず、その他のモデルページは無効のままになり、これらのモデルタイプはダウンロードできません。",
|
||||
"otherSubTypes": "管理するモデルタイプ",
|
||||
"otherSubTypesHelp": "その他のモデルページでスキャンおよび表示するカテゴリを選択します。",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "レシピ保存先",
|
||||
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||
"copyAll": "すべての構文をコピー",
|
||||
"refreshAll": "すべてのメタデータを更新",
|
||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"checkUpdates": "選択項目の更新を確認",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "プレビューを置換",
|
||||
"setContentRating": "コンテンツレーティングを設定",
|
||||
"moveToFolder": "フォルダに移動",
|
||||
"repairMetadata": "メタデータを修復",
|
||||
"rematchMetadata": "ローカルモデルに再マッチング",
|
||||
"reimportMetadata": "ソースから再インポート",
|
||||
"excludeModel": "モデルを除外",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "レシピのメタデータを修復中...",
|
||||
"success": "レシピのメタデータが正常に修復されました",
|
||||
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
||||
"failed": "レシピの修復に失敗しました: {message}",
|
||||
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "ソースからレシピを再インポート中...",
|
||||
"success": "レシピの再インポートが完了しました",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Embeddingモデル"
|
||||
},
|
||||
"other": {
|
||||
"title": "その他のモデル",
|
||||
"disabled": {
|
||||
"title": "その他のモデル管理はオフです",
|
||||
"description": "有効にすると VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||
"enableButton": "その他のモデルを有効にする",
|
||||
"hint": "管理するモデルタイプは後で「設定 > ライブラリ」で変更できます。",
|
||||
"enableFailed": "その他のモデルの有効化に失敗しました",
|
||||
"downloadBlocked": "このモデルタイプではその他のモデル管理が無効です。このファイルをダウンロードするには「設定 > ライブラリ」で有効にしてください。",
|
||||
"enableAction": "その他のモデルを有効にする"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "その他のモデルのフォルダーが見つかりません",
|
||||
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。",
|
||||
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
|
||||
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
|
||||
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
|
||||
"openSettings": "設定を開く"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "ルート",
|
||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||
"downloadButton": "{count} 個の LoRA をダウンロード"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "レシピの再マッチング",
|
||||
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
|
||||
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
|
||||
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
|
||||
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
|
||||
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
|
||||
"confirmButton": "再マッチング"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "元に戻す",
|
||||
"undone": "元に戻しました",
|
||||
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "再マッチングの概要",
|
||||
"successMessage": "{entries} エントリをマッチングしました",
|
||||
"failed": "再マッチングに失敗しました",
|
||||
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
|
||||
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
|
||||
"statMatched": "マッチしたエントリ",
|
||||
"statReview": "要確認",
|
||||
"statUnresolved": "マッチなし",
|
||||
"statErrors": "エラー",
|
||||
"reviewSection": "確認が必要なファイル名マッチ({count})",
|
||||
"columnRecipe": "レシピ",
|
||||
"columnEntry": "エントリ",
|
||||
"columnFile": "マッチしたファイル",
|
||||
"columnUndo": "元に戻す",
|
||||
"copyReport": "レポートをコピー",
|
||||
"close": "閉じる",
|
||||
"scope_global": "すべてのレシピ",
|
||||
"scope_bulk": "選択したレシピ",
|
||||
"scope_single": "単一のレシピ"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "ローカル例画像",
|
||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Embedding Managerを初期化中",
|
||||
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||
},
|
||||
"other": {
|
||||
"title": "その他のモデルマネージャーを初期化中",
|
||||
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "レシピマネージャーを初期化中",
|
||||
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||
"created": "レシピを作成しました",
|
||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
|
||||
"noPreviousRecipe": "前のレシピがありません",
|
||||
"noNextRecipe": "次のレシピがありません",
|
||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||
"noRecipesSelected": "レシピが選択されていません",
|
||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||
"reimporting": "ソースからレシピを再インポート中...",
|
||||
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
|
||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
|
||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "キャッシュを再構築中...",
|
||||
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
||||
"retry": "再試行"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "その他のモデル管理が利用可能になりました",
|
||||
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||
"enable": "その他のモデルを有効にする",
|
||||
"openSettings": "設定を開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||
"copyEmbeddingName": "Embedding 이름 복사",
|
||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||
"modelNameCopied": "모델 이름 복사됨",
|
||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
|
||||
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "레시피 데이터 복구",
|
||||
"loading": "레시피 데이터 복구 중...",
|
||||
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||
"error": "레시피 복구 실패: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||
"error": "레시피 재매칭 실패: {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "레시피",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "기타",
|
||||
"statistics": "통계"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "모델별 그룹화",
|
||||
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||
"stickyControls": "작업 표시줄 항상 표시",
|
||||
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
|
||||
"displayDensity": "표시 밀도",
|
||||
"displayDensityOptions": {
|
||||
"default": "기본",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||
"defaultEmbeddingRoot": "Embedding 루트",
|
||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||
"defaultVaeRoot": "VAE 루트",
|
||||
"defaultVaeRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 VAE 루트 디렉토리를 설정합니다",
|
||||
"defaultUpscalerRoot": "Upscaler 루트",
|
||||
"defaultUpscalerRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Upscaler 루트 디렉토리를 설정합니다",
|
||||
"defaultTextEncoderRoot": "Text Encoder 루트",
|
||||
"defaultTextEncoderRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Text Encoder 루트 디렉토리를 설정합니다",
|
||||
"defaultClipVisionRoot": "CLIP Vision 루트",
|
||||
"defaultClipVisionRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 CLIP Vision 루트 디렉토리를 설정합니다",
|
||||
"defaultControlnetRoot": "ControlNet 루트",
|
||||
"defaultControlnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 ControlNet 루트 디렉토리를 설정합니다",
|
||||
"enableOtherModels": "기타 모델 관리",
|
||||
"enableOtherModelsHelp": "끄면 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 폴더를 스캔하지 않으며, 기타 모델 페이지가 비활성화된 상태로 유지되고 이러한 모델 유형은 다운로드할 수 없습니다.",
|
||||
"otherSubTypes": "관리할 모델 유형",
|
||||
"otherSubTypesHelp": "기타 모델 페이지에서 스캔하고 표시할 카테고리를 선택합니다.",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "레시피 저장 경로",
|
||||
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||
"copyAll": "모든 문법 복사",
|
||||
"refreshAll": "모든 메타데이터 새로고침",
|
||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"checkUpdates": "선택 항목 업데이트 확인",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "미리보기 교체",
|
||||
"setContentRating": "콘텐츠 등급 설정",
|
||||
"moveToFolder": "폴더로 이동",
|
||||
"repairMetadata": "메타데이터 복구",
|
||||
"rematchMetadata": "로컬 모델에 다시 매칭",
|
||||
"reimportMetadata": "소스에서 다시 가져오기",
|
||||
"excludeModel": "모델 제외",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "레시피 메타데이터 복구 중...",
|
||||
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
|
||||
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
||||
"failed": "레시피 복구 실패: {message}",
|
||||
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"success": "레시피를 다시 가져왔습니다",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding 모델"
|
||||
},
|
||||
"other": {
|
||||
"title": "기타 모델",
|
||||
"disabled": {
|
||||
"title": "기타 모델 관리가 꺼져 있습니다",
|
||||
"description": "활성화하면 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔하고 관리하며 CivitAI에서 다운로드할 수 있습니다.",
|
||||
"enableButton": "기타 모델 활성화",
|
||||
"hint": "관리할 모델 유형은 나중에 설정 > 라이브러리에서 변경할 수 있습니다.",
|
||||
"enableFailed": "기타 모델 활성화 실패",
|
||||
"downloadBlocked": "이 모델 유형에 대해서는 기타 모델 관리가 비활성화되어 있습니다. 이 파일을 다운로드하려면 설정 > 라이브러리에서 활성화하세요.",
|
||||
"enableAction": "기타 모델 활성화"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "기타 모델 폴더를 찾을 수 없습니다",
|
||||
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.",
|
||||
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
|
||||
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
|
||||
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
|
||||
"openSettings": "설정 열기"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "루트",
|
||||
"collapseAll": "모든 폴더 접기",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||
"downloadButton": "{count}개 LoRA 다운로드"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "레시피 재매칭",
|
||||
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
|
||||
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
|
||||
"confirmButton": "재매칭"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "실행 취소",
|
||||
"undone": "실행 취소됨",
|
||||
"undoFailed": "재매칭 실행 취소 실패: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "재매칭 요약",
|
||||
"successMessage": "{entries}개 항목이 매칭되었습니다",
|
||||
"failed": "재매칭 실패",
|
||||
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
|
||||
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
|
||||
"statMatched": "매칭된 항목",
|
||||
"statReview": "검토 필요",
|
||||
"statUnresolved": "매칭 없음",
|
||||
"statErrors": "오류",
|
||||
"reviewSection": "검토할 파일 이름 매칭 ({count})",
|
||||
"columnRecipe": "레시피",
|
||||
"columnEntry": "항목",
|
||||
"columnFile": "매칭된 파일",
|
||||
"columnUndo": "실행 취소",
|
||||
"copyReport": "보고서 복사",
|
||||
"close": "닫기",
|
||||
"scope_global": "모든 레시피",
|
||||
"scope_bulk": "선택한 레시피",
|
||||
"scope_single": "단일 레시피"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "로컬 예시 이미지",
|
||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Embedding Manager 초기화 중",
|
||||
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"other": {
|
||||
"title": "기타 모델 관리자 초기화 중",
|
||||
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "레시피 매니저 초기화 중",
|
||||
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||
"created": "레시피가 생성되었습니다",
|
||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
|
||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||
"noNextRecipe": "다음 레시피가 없습니다",
|
||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
|
||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
|
||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "캐시 재구축 중...",
|
||||
"rebuildFailed": "캐시 재구축 실패: {error}",
|
||||
"retry": "다시 시도"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "기타 모델 관리를 사용할 수 있습니다",
|
||||
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
||||
"enable": "기타 모델 활성화",
|
||||
"openSettings": "설정 열기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "Копировать имя checkpoint",
|
||||
"copyEmbeddingName": "Копировать имя embedding",
|
||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||
"modelNameCopied": "Имя модели скопировано",
|
||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "У всех {typePlural} уже есть метаданные лицензии",
|
||||
"error": "Не удалось обновить метаданные лицензии для {typePlural}: {message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "Восстановить данные рецептов",
|
||||
"loading": "Восстановление данных рецептов...",
|
||||
"success": "Успешно восстановлено {count} рецептов.",
|
||||
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||
"error": "Ошибка восстановления рецептов: {message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
||||
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "Рецепты",
|
||||
"checkpoints": "Checkpoints",
|
||||
"embeddings": "Embeddings",
|
||||
"other": "Другое",
|
||||
"statistics": "Статистика"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "Группировать по модели",
|
||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
||||
"stickyControls": "Держать панель действий видимой",
|
||||
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
|
||||
"displayDensity": "Плотность отображения",
|
||||
"displayDensityOptions": {
|
||||
"default": "По умолчанию",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultVaeRoot": "Корневая папка VAE",
|
||||
"defaultVaeRootHelp": "Установить корневую папку VAE по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultUpscalerRoot": "Корневая папка Upscaler",
|
||||
"defaultUpscalerRootHelp": "Установить корневую папку Upscaler по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultTextEncoderRoot": "Корневая папка Text Encoder",
|
||||
"defaultTextEncoderRootHelp": "Установить корневую папку Text Encoder по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultClipVisionRoot": "Корневая папка CLIP Vision",
|
||||
"defaultClipVisionRootHelp": "Установить корневую папку CLIP Vision по умолчанию для загрузок, импорта и перемещений",
|
||||
"defaultControlnetRoot": "Корневая папка ControlNet",
|
||||
"defaultControlnetRootHelp": "Установить корневую папку ControlNet по умолчанию для загрузок, импорта и перемещений",
|
||||
"enableOtherModels": "Управление другими моделями",
|
||||
"enableOtherModelsHelp": "Если выключено, папки VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet не сканируются, страница «Другие модели» остаётся отключённой, а эти типы моделей нельзя загрузить.",
|
||||
"otherSubTypes": "Управляемые типы моделей",
|
||||
"otherSubTypesHelp": "Выберите, какие категории других моделей сканируются и отображаются на странице «Другие модели».",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "Путь хранения рецептов",
|
||||
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "Установить рейтинг контента для всех",
|
||||
"copyAll": "Копировать весь синтаксис",
|
||||
"refreshAll": "Обновить все метаданные",
|
||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"checkUpdates": "Проверить обновления для выбранных",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "Заменить превью",
|
||||
"setContentRating": "Установить рейтинг контента",
|
||||
"moveToFolder": "Переместить в папку",
|
||||
"repairMetadata": "Восстановить метаданные",
|
||||
"rematchMetadata": "Сопоставить с локальными моделями",
|
||||
"reimportMetadata": "Переимпортировать из источника",
|
||||
"excludeModel": "Исключить модель",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "Восстановление метаданных рецепта...",
|
||||
"success": "Метаданные рецепта успешно восстановлены",
|
||||
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
||||
"failed": "Не удалось восстановить рецепт: {message}",
|
||||
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "Переимпорт рецепта из источника...",
|
||||
"success": "Рецепт успешно переимпортирован",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Модели Embedding"
|
||||
},
|
||||
"other": {
|
||||
"title": "Другие модели",
|
||||
"disabled": {
|
||||
"title": "Управление другими моделями отключено",
|
||||
"description": "Включите, чтобы сканировать и управлять файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загружать их с CivitAI.",
|
||||
"enableButton": "Включить другие модели",
|
||||
"hint": "Вы сможете изменить управляемые типы моделей позже в разделе «Настройки > Библиотека».",
|
||||
"enableFailed": "Не удалось включить другие модели",
|
||||
"downloadBlocked": "Управление другими моделями отключено для этого типа моделей. Включите его в разделе «Настройки > Библиотека», чтобы загрузить этот файл.",
|
||||
"enableAction": "Включить другие модели"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "Папки других моделей не найдены",
|
||||
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.",
|
||||
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
|
||||
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
|
||||
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
|
||||
"openSettings": "Открыть настройки"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "Корень",
|
||||
"collapseAll": "Свернуть все папки",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||
"downloadButton": "Скачать {count} LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "Повторное сопоставление рецептов",
|
||||
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
|
||||
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
|
||||
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
|
||||
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
|
||||
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
|
||||
"confirmButton": "Сопоставить"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "Отменить",
|
||||
"undone": "Отменено",
|
||||
"undoFailed": "Не удалось отменить сопоставление: {message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "Сводка повторного сопоставления",
|
||||
"successMessage": "Сопоставлено записей: {entries}",
|
||||
"failed": "Не удалось выполнить сопоставление",
|
||||
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
|
||||
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
|
||||
"statMatched": "Сопоставленные записи",
|
||||
"statReview": "Требуют проверки",
|
||||
"statUnresolved": "Не сопоставлено",
|
||||
"statErrors": "Ошибки",
|
||||
"reviewSection": "Совпадения по имени файла для проверки ({count})",
|
||||
"columnRecipe": "Рецепт",
|
||||
"columnEntry": "Запись",
|
||||
"columnFile": "Совпавший файл",
|
||||
"columnUndo": "Отменить",
|
||||
"copyReport": "Скопировать отчёт",
|
||||
"close": "Закрыть",
|
||||
"scope_global": "Все рецепты",
|
||||
"scope_bulk": "Выбранные рецепты",
|
||||
"scope_single": "Один рецепт"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "Локальные примеры изображений",
|
||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "Инициализация Embedding Manager",
|
||||
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
||||
},
|
||||
"other": {
|
||||
"title": "Инициализация менеджера других моделей",
|
||||
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "Инициализация менеджера рецептов",
|
||||
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||
"created": "Рецепт успешно создан",
|
||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
|
||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||
"noRecipesSelected": "Рецепты не выбраны",
|
||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||
"reimporting": "Переимпорт рецепта из источника...",
|
||||
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
|
||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
|
||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "Перестроение кэша...",
|
||||
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
||||
"retry": "Повторить"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "Управление другими моделями доступно",
|
||||
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
||||
"enable": "Включить другие модели",
|
||||
"openSettings": "Открыть настройки"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||
"copyEmbeddingName": "复制 Embedding 名称",
|
||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||
"modelNameCopied": "模型名称已复制",
|
||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "所有 {typePlural} 都已具备许可证元数据",
|
||||
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修复配方数据",
|
||||
"loading": "正在修复配方数据...",
|
||||
"success": "成功修复了 {count} 个配方。",
|
||||
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||
"error": "配方修复失败:{message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "将配方重新匹配到本地模型",
|
||||
"loading": "正在将配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
||||
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||
"error": "配方重新匹配失败:{message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "配方",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "其他",
|
||||
"statistics": "统计"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分组",
|
||||
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||
"stickyControls": "保持操作栏可见",
|
||||
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
|
||||
"displayDensity": "显示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "默认",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||
"defaultVaeRoot": "VAE 根目录",
|
||||
"defaultVaeRootHelp": "设置下载、导入和移动时的默认 VAE 根目录",
|
||||
"defaultUpscalerRoot": "Upscaler 根目录",
|
||||
"defaultUpscalerRootHelp": "设置下载、导入和移动时的默认 Upscaler 根目录",
|
||||
"defaultTextEncoderRoot": "Text Encoder 根目录",
|
||||
"defaultTextEncoderRootHelp": "设置下载、导入和移动时的默认 Text Encoder 根目录",
|
||||
"defaultClipVisionRoot": "CLIP Vision 根目录",
|
||||
"defaultClipVisionRootHelp": "设置下载、导入和移动时的默认 CLIP Vision 根目录",
|
||||
"defaultControlnetRoot": "ControlNet 根目录",
|
||||
"defaultControlnetRootHelp": "设置下载、导入和移动时的默认 ControlNet 根目录",
|
||||
"enableOtherModels": "其他模型管理",
|
||||
"enableOtherModelsHelp": "关闭后,不会扫描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 文件夹,其他模型页面保持禁用,且无法下载这些模型类型。",
|
||||
"otherSubTypes": "管理的模型类型",
|
||||
"otherSubTypesHelp": "选择要在其他模型页面中扫描和显示的其他模型类别。",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "配方存储路径",
|
||||
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "为所选中设置内容评级",
|
||||
"copyAll": "复制所选中语法",
|
||||
"refreshAll": "刷新所选中元数据",
|
||||
"repairMetadata": "修复所选中元数据",
|
||||
"rematchMetadata": "将所选中重新匹配到本地模型",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"checkUpdates": "检查所选更新",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "替换预览",
|
||||
"setContentRating": "设置内容评级",
|
||||
"moveToFolder": "移动到文件夹",
|
||||
"repairMetadata": "修复元数据",
|
||||
"rematchMetadata": "重新匹配到本地模型",
|
||||
"reimportMetadata": "从源重新导入",
|
||||
"excludeModel": "排除模型",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "正在修复配方元数据...",
|
||||
"success": "配方元数据修复成功",
|
||||
"skipped": "配方已是最新版本,无需修复",
|
||||
"failed": "修复配方失败:{message}",
|
||||
"missingId": "无法修复配方:缺少配方 ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "正在从源重新导入配方...",
|
||||
"success": "配方已从源重新导入成功",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"other": {
|
||||
"title": "其他模型",
|
||||
"disabled": {
|
||||
"title": "其他模型管理已关闭",
|
||||
"description": "启用后可扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||
"enableButton": "启用其他模型",
|
||||
"hint": "你可以稍后在“设置 > 库”中更改管理的模型类型。",
|
||||
"enableFailed": "启用其他模型失败",
|
||||
"downloadBlocked": "其他模型管理已对此模型类型禁用。请在“设置 > 库”中启用以下载此文件。",
|
||||
"enableAction": "启用其他模型"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "未找到其他模型文件夹",
|
||||
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。",
|
||||
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
|
||||
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
|
||||
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
|
||||
"openSettings": "打开设置"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目录",
|
||||
"collapseAll": "折叠所有文件夹",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||
"downloadButton": "下载 {count} 个 LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "重新匹配配方",
|
||||
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
|
||||
"messageSingle": "将对照你的本地模型库扫描此配方。",
|
||||
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
|
||||
"relaxedLabel": "同时按文件名重新关联缺失的模型",
|
||||
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
|
||||
"confirmButton": "重新匹配"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "撤销",
|
||||
"undone": "已撤销",
|
||||
"undoFailed": "撤销重新匹配失败:{message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "重新匹配摘要",
|
||||
"successMessage": "已匹配 {entries} 个条目",
|
||||
"failed": "重新匹配失败",
|
||||
"completedWithWarnings": "重新匹配已完成——建议检查",
|
||||
"cancelledNote": "运行在完成前已取消——统计不完整。",
|
||||
"statMatched": "已匹配条目",
|
||||
"statReview": "需要检查",
|
||||
"statUnresolved": "未匹配",
|
||||
"statErrors": "错误",
|
||||
"reviewSection": "需要检查的文件名匹配({count})",
|
||||
"columnRecipe": "配方",
|
||||
"columnEntry": "条目",
|
||||
"columnFile": "匹配到的文件",
|
||||
"columnUndo": "撤销",
|
||||
"copyReport": "复制报告",
|
||||
"close": "关闭",
|
||||
"scope_global": "所有配方",
|
||||
"scope_bulk": "所选配方",
|
||||
"scope_single": "单个配方"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本地示例图片",
|
||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "初始化 Embedding 管理器",
|
||||
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
||||
},
|
||||
"other": {
|
||||
"title": "正在初始化其他模型管理器",
|
||||
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "初始化配方管理器",
|
||||
"message": "正在加载和处理配方。这可能需要几分钟..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "缺少创建配方所需的数据",
|
||||
"created": "配方创建成功",
|
||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
|
||||
"noPreviousRecipe": "没有上一个配方",
|
||||
"noNextRecipe": "没有下一个配方",
|
||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"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}",
|
||||
"reimporting": "正在从源重新导入配方...",
|
||||
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
|
||||
"reimportSuccess": "配方已从源重新导入成功",
|
||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||
"reimportBulkFailed": "重新导入某些配方失败",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||
"otherRootsFailed": "加载其他模型根目录失败:{message}",
|
||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
||||
"mappingsCleared": "基础模型路径映射已清除",
|
||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "正在重建缓存...",
|
||||
"rebuildFailed": "重建缓存失败:{error}",
|
||||
"retry": "重试"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "其他模型管理现已可用",
|
||||
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||
"enable": "启用其他模型",
|
||||
"openSettings": "打开设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+26
-91
@@ -149,7 +149,6 @@
|
||||
"copyCheckpointName": "複製 Checkpoint 名稱",
|
||||
"copyEmbeddingName": "複製嵌入名稱",
|
||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||
"modelNameCopied": "模型名稱已複製",
|
||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||
},
|
||||
@@ -213,10 +212,20 @@
|
||||
"none": "所有 {typePlural} 已具備授權中繼資料",
|
||||
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
||||
},
|
||||
"repairRecipes": {
|
||||
"label": "修復配方資料",
|
||||
"loading": "正在修復配方資料...",
|
||||
"success": "成功修復 {count} 個配方。",
|
||||
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||
"error": "配方修復失敗:{message}"
|
||||
},
|
||||
"rematchRecipes": {
|
||||
"label": "將配方重新匹配到本地模型",
|
||||
"loading": "正在將配方重新匹配到本地模型...",
|
||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
||||
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||
"error": "配方重新匹配失敗:{message}"
|
||||
},
|
||||
@@ -234,7 +243,6 @@
|
||||
"recipes": "配方",
|
||||
"checkpoints": "Checkpoint",
|
||||
"embeddings": "Embedding",
|
||||
"other": "其他",
|
||||
"statistics": "統計"
|
||||
},
|
||||
"search": {
|
||||
@@ -476,8 +484,6 @@
|
||||
"layoutSettings": {
|
||||
"groupByModel": "按模型分組",
|
||||
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||
"stickyControls": "保持操作列可見",
|
||||
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
|
||||
"displayDensity": "顯示密度",
|
||||
"displayDensityOptions": {
|
||||
"default": "預設",
|
||||
@@ -535,25 +541,6 @@
|
||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||
"defaultVaeRoot": "VAE 根目錄",
|
||||
"defaultVaeRootHelp": "設定下載、匯入和移動時的預設 VAE 根目錄",
|
||||
"defaultUpscalerRoot": "Upscaler 根目錄",
|
||||
"defaultUpscalerRootHelp": "設定下載、匯入和移動時的預設 Upscaler 根目錄",
|
||||
"defaultTextEncoderRoot": "Text Encoder 根目錄",
|
||||
"defaultTextEncoderRootHelp": "設定下載、匯入和移動時的預設 Text Encoder 根目錄",
|
||||
"defaultClipVisionRoot": "CLIP Vision 根目錄",
|
||||
"defaultClipVisionRootHelp": "設定下載、匯入和移動時的預設 CLIP Vision 根目錄",
|
||||
"defaultControlnetRoot": "ControlNet 根目錄",
|
||||
"defaultControlnetRootHelp": "設定下載、匯入和移動時的預設 ControlNet 根目錄",
|
||||
"enableOtherModels": "其他模型管理",
|
||||
"enableOtherModelsHelp": "關閉後,不會掃描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 資料夾,其他模型頁面會保持停用,且無法下載這些模型類型。",
|
||||
"otherSubTypes": "管理的模型類型",
|
||||
"otherSubTypesHelp": "選擇要在其他模型頁面中掃描和顯示的其他模型類別。",
|
||||
"subTypeVae": "VAE",
|
||||
"subTypeUpscaler": "Upscaler",
|
||||
"subTypeTextEncoder": "Text Encoder",
|
||||
"subTypeClipVision": "CLIP Vision",
|
||||
"subTypeControlnet": "ControlNet",
|
||||
"recipesPath": "配方儲存路徑",
|
||||
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||
"recipesPathPlaceholder": "/path/to/recipes",
|
||||
@@ -832,6 +819,7 @@
|
||||
"setContentRating": "為全部設定內容分級",
|
||||
"copyAll": "複製全部語法",
|
||||
"refreshAll": "刷新全部 metadata",
|
||||
"repairMetadata": "修復所選中元數據",
|
||||
"rematchMetadata": "將所選中重新匹配到本地模型",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"checkUpdates": "檢查所選更新",
|
||||
@@ -887,6 +875,7 @@
|
||||
"replacePreview": "更換預覽圖",
|
||||
"setContentRating": "設定內容分級",
|
||||
"moveToFolder": "移動到資料夾",
|
||||
"repairMetadata": "修復元數據",
|
||||
"rematchMetadata": "重新匹配到本地模型",
|
||||
"reimportMetadata": "從來源重新匯入",
|
||||
"excludeModel": "排除模型",
|
||||
@@ -1139,6 +1128,13 @@
|
||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||
},
|
||||
"repair": {
|
||||
"starting": "正在修復配方元數據...",
|
||||
"success": "配方元數據修復成功",
|
||||
"skipped": "配方已是最新版本,無需修復",
|
||||
"failed": "修復配方失敗:{message}",
|
||||
"missingId": "無法修復配方:缺少配方 ID"
|
||||
},
|
||||
"reimport": {
|
||||
"starting": "正在從來源重新匯入配方...",
|
||||
"success": "配方已從來源重新匯入成功",
|
||||
@@ -1222,26 +1218,6 @@
|
||||
"embeddings": {
|
||||
"title": "Embedding 模型"
|
||||
},
|
||||
"other": {
|
||||
"title": "其他模型",
|
||||
"disabled": {
|
||||
"title": "其他模型管理已關閉",
|
||||
"description": "啟用後可掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||
"enableButton": "啟用其他模型",
|
||||
"hint": "您稍後可以在「設定 > 模型庫」中變更管理的模型類型。",
|
||||
"enableFailed": "啟用其他模型失敗",
|
||||
"downloadBlocked": "其他模型管理已對此模型類型停用。請在「設定 > 模型庫」中啟用以下載此檔案。",
|
||||
"enableAction": "啟用其他模型"
|
||||
},
|
||||
"noPaths": {
|
||||
"title": "找不到其他模型資料夾",
|
||||
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。",
|
||||
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
|
||||
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
|
||||
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
|
||||
"openSettings": "開啟設定"
|
||||
}
|
||||
},
|
||||
"sidebar": {
|
||||
"modelRoot": "根目錄",
|
||||
"collapseAll": "全部摺疊資料夾",
|
||||
@@ -1539,41 +1515,6 @@
|
||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||
"downloadButton": "下載 {count} 個 LoRA(s)"
|
||||
},
|
||||
"rematchOptions": {
|
||||
"title": "重新匹配配方",
|
||||
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
|
||||
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
|
||||
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
|
||||
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
|
||||
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
|
||||
"confirmButton": "重新匹配"
|
||||
},
|
||||
"rematchResults": {
|
||||
"undo": "撤銷",
|
||||
"undone": "已撤銷",
|
||||
"undoFailed": "撤銷重新匹配失敗:{message}"
|
||||
},
|
||||
"rematchSummary": {
|
||||
"title": "重新匹配摘要",
|
||||
"successMessage": "已匹配 {entries} 個條目",
|
||||
"failed": "重新匹配失敗",
|
||||
"completedWithWarnings": "重新匹配已完成——建議檢查",
|
||||
"cancelledNote": "執行在完成前已取消——統計不完整。",
|
||||
"statMatched": "已匹配條目",
|
||||
"statReview": "需要檢查",
|
||||
"statUnresolved": "未匹配",
|
||||
"statErrors": "錯誤",
|
||||
"reviewSection": "需要檢查的檔案名稱匹配({count})",
|
||||
"columnRecipe": "配方",
|
||||
"columnEntry": "條目",
|
||||
"columnFile": "匹配到的檔案",
|
||||
"columnUndo": "撤銷",
|
||||
"copyReport": "複製報告",
|
||||
"close": "關閉",
|
||||
"scope_global": "所有配方",
|
||||
"scope_bulk": "所選配方",
|
||||
"scope_single": "單個配方"
|
||||
},
|
||||
"exampleAccess": {
|
||||
"title": "本機範例圖片",
|
||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||
@@ -1919,10 +1860,6 @@
|
||||
"title": "初始化 Embedding 管理器",
|
||||
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
||||
},
|
||||
"other": {
|
||||
"title": "正在初始化其他模型管理器",
|
||||
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
|
||||
},
|
||||
"recipes": {
|
||||
"title": "初始化配方管理器",
|
||||
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
||||
@@ -2245,7 +2182,6 @@
|
||||
"createMissingData": "缺少建立配方所需的資料",
|
||||
"created": "配方建立成功",
|
||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
|
||||
"noPreviousRecipe": "沒有上一個配方",
|
||||
"noNextRecipe": "沒有下一個配方",
|
||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||
@@ -2300,10 +2236,16 @@
|
||||
"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}",
|
||||
"reimporting": "正在從來源重新匯入配方...",
|
||||
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
|
||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||
@@ -2378,7 +2320,6 @@
|
||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
|
||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||
@@ -2641,12 +2582,6 @@
|
||||
"rebuilding": "重建快取中...",
|
||||
"rebuildFailed": "重建快取失敗:{error}",
|
||||
"retry": "重試"
|
||||
},
|
||||
"otherModels": {
|
||||
"title": "其他模型管理現已可用",
|
||||
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||
"enable": "啟用其他模型",
|
||||
"openSettings": "開啟設定"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-276
@@ -17,9 +17,6 @@ import types as _types
|
||||
import time
|
||||
|
||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||
from .utils.constants import (
|
||||
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||
)
|
||||
from .utils.settings_paths import (
|
||||
ensure_settings_file,
|
||||
get_settings_dir,
|
||||
@@ -175,13 +172,6 @@ class Config:
|
||||
self.embeddings_roots = None
|
||||
self.base_models_roots = self._init_checkpoint_paths()
|
||||
self.embeddings_roots = self._init_embedding_paths()
|
||||
# Other-model roots (VAE, upscalers, text encoders, ...): flat deduped
|
||||
# list plus a normalized root -> sub_type map and per-folder_paths-key
|
||||
# roots for settings persistence.
|
||||
self.other_roots: Optional[List[str]] = None
|
||||
self.other_root_subtypes: Dict[str, str] = {}
|
||||
self.other_folder_roots: Dict[str, List[str]] = {}
|
||||
self.other_roots = self._init_other_paths()
|
||||
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||
self.extra_loras_roots: List[str] = []
|
||||
self.extra_checkpoints_roots: List[str] = []
|
||||
@@ -346,10 +336,6 @@ class Config:
|
||||
"unet": list(self.unet_roots or []),
|
||||
"embeddings": list(self.embeddings_roots or []),
|
||||
}
|
||||
# Persist the other-model roots under their original folder_paths
|
||||
# keys so library switching round-trips them.
|
||||
for key, roots in (self.other_folder_roots or {}).items():
|
||||
target_folder_paths[key] = list(roots)
|
||||
|
||||
normalized_target_paths = _normalize_folder_paths_for_comparison(
|
||||
target_folder_paths
|
||||
@@ -536,7 +522,6 @@ class Config:
|
||||
roots.extend(self.loras_roots or [])
|
||||
roots.extend(self.base_models_roots or [])
|
||||
roots.extend(self.embeddings_roots or [])
|
||||
roots.extend(self.other_roots or [])
|
||||
# Include extra paths for scanning symlinks
|
||||
roots.extend(self.extra_loras_roots or [])
|
||||
roots.extend(self.extra_checkpoints_roots or [])
|
||||
@@ -877,8 +862,6 @@ class Config:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.embeddings_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
for root in self.other_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
# Include extra paths for preview access
|
||||
for root in self.extra_loras_roots or []:
|
||||
preview_roots.update(self._expand_preview_root(root))
|
||||
@@ -899,7 +882,7 @@ class Config:
|
||||
path for path in preview_roots if path.is_absolute()
|
||||
}
|
||||
logger.debug(
|
||||
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d other roots, %d symlink mappings",
|
||||
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings",
|
||||
len(self._preview_root_paths),
|
||||
len(self.loras_roots or []),
|
||||
len(self.extra_loras_roots or []),
|
||||
@@ -907,7 +890,6 @@ class Config:
|
||||
len(self.extra_checkpoints_roots or []),
|
||||
len(self.embeddings_roots or []),
|
||||
len(self.extra_embeddings_roots or []),
|
||||
len(self.other_roots or []),
|
||||
len(self._path_mappings),
|
||||
)
|
||||
|
||||
@@ -1146,155 +1128,6 @@ class Config:
|
||||
|
||||
return unique_paths
|
||||
|
||||
def _get_enabled_other_folder_keys(self) -> List[str]:
|
||||
"""Return the OTHER_MODEL_FOLDER_SUBTYPES keys that are enabled.
|
||||
|
||||
Other Models management is opt-in: while ``enable_other_models`` is
|
||||
off (the default) no other-model folder is scanned at all. When it is
|
||||
on, only the folder keys of the enabled sub_types are scanned
|
||||
(text_encoder merges ``text_encoders`` with the legacy ``clip`` key).
|
||||
"""
|
||||
try:
|
||||
from .services.settings_manager import get_settings_manager
|
||||
|
||||
enabled_sub_types = get_settings_manager().get_enabled_other_sub_types()
|
||||
except Exception:
|
||||
enabled_sub_types = []
|
||||
if not enabled_sub_types:
|
||||
return []
|
||||
allowed = set(enabled_sub_types)
|
||||
return [
|
||||
key
|
||||
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
|
||||
if sub_type in allowed
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _collapse_legacy_folder_keys(keys: List[str]) -> List[str]:
|
||||
"""Drop folder keys the host already normalizes onto another queried key.
|
||||
|
||||
ComfyUI's ``folder_paths`` rewrites legacy names before every access
|
||||
(``clip`` -> ``text_encoders``, ``unet`` -> ``diffusion_models``), and
|
||||
registers both legacy directories under the canonical key, so
|
||||
``get_folder_paths("clip")`` returns exactly the same list as
|
||||
``get_folder_paths("text_encoders")``. Querying both therefore reports
|
||||
every text-encoder folder twice and trips the overlap guard with a
|
||||
conflict the user cannot fix.
|
||||
|
||||
When the host exposes ``map_legacy`` the alias is provably redundant and
|
||||
is skipped (an empty canonical list implies an empty alias list).
|
||||
Without it - the standalone mock, whose keys are independent
|
||||
``settings.json`` entries - every key is kept, because a ``clip``-only
|
||||
configuration is then genuinely distinct.
|
||||
"""
|
||||
map_legacy = getattr(folder_paths, "map_legacy", None)
|
||||
if not callable(map_legacy):
|
||||
return list(keys)
|
||||
|
||||
queried = set(keys)
|
||||
collapsed: List[str] = []
|
||||
for key in keys:
|
||||
try:
|
||||
canonical = map_legacy(key)
|
||||
except Exception:
|
||||
canonical = key
|
||||
if canonical != key and canonical in queried:
|
||||
logger.debug(
|
||||
"Skipping legacy folder key '%s'; the host resolves it to "
|
||||
"'%s', which is queried as well.",
|
||||
key,
|
||||
canonical,
|
||||
)
|
||||
continue
|
||||
collapsed.append(key)
|
||||
return collapsed
|
||||
|
||||
def _prepare_other_paths(
|
||||
self, folder_path_map: Mapping[str, Iterable[str]]
|
||||
) -> Tuple[List[str], Dict[str, str], Dict[str, List[str]]]:
|
||||
"""Prepare other-model paths from a folder_paths-key -> raw paths map.
|
||||
|
||||
Returns:
|
||||
Tuple of (all_unique_roots, business_root -> sub_type map,
|
||||
folder_paths key -> business roots). This method does NOT modify
|
||||
instance variables - callers must set them.
|
||||
"""
|
||||
unique_paths: List[str] = []
|
||||
sub_type_map: Dict[str, str] = {}
|
||||
per_key_roots: Dict[str, List[str]] = {}
|
||||
# real path -> (business path, sub_type) of the category that claimed it
|
||||
seen_real_paths: Dict[str, Tuple[str, str]] = {}
|
||||
|
||||
# Cross-scanner overlap detection: warn when an "other" root is
|
||||
# already covered by the checkpoints/unet or embeddings scanners.
|
||||
# Kept (not dropped) on purpose - duplicate cards across pages are
|
||||
# cosmetic, while dropping would silently unmanage the files.
|
||||
covered_real_paths = {
|
||||
os.path.normpath(os.path.realpath(path)).replace(os.sep, "/"): path
|
||||
for path in [
|
||||
*(self.base_models_roots or []),
|
||||
*(self.embeddings_roots or []),
|
||||
]
|
||||
if isinstance(path, str) and path.strip() and os.path.exists(path)
|
||||
}
|
||||
|
||||
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||
raw_paths = folder_path_map.get(key)
|
||||
if not raw_paths:
|
||||
continue
|
||||
path_map = self._dedupe_existing_paths(raw_paths)
|
||||
key_roots: List[str] = []
|
||||
for real_path, business_path in sorted(
|
||||
path_map.items(), key=lambda item: item[1].lower()
|
||||
):
|
||||
seen = seen_real_paths.get(real_path)
|
||||
if seen is not None:
|
||||
seen_business_path, seen_sub_type = seen
|
||||
if seen_sub_type == sub_type:
|
||||
# Same category reached through a second folder_paths
|
||||
# key (legacy alias, or a sub_type spanning two keys).
|
||||
# Expected, so never a "fix your configuration" warning.
|
||||
logger.debug(
|
||||
"Ignoring duplicate folder '%s' for category '%s' "
|
||||
"(already covered by '%s').",
|
||||
business_path,
|
||||
sub_type,
|
||||
seen_business_path,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Detected the same folder '%s' under multiple other-model "
|
||||
"categories ('%s' is already mapped as '%s'). Keeping the "
|
||||
"first category; please fix your path configuration.",
|
||||
business_path,
|
||||
seen_business_path,
|
||||
seen_sub_type,
|
||||
)
|
||||
continue
|
||||
seen_real_paths[real_path] = (business_path, sub_type)
|
||||
unique_paths.append(business_path)
|
||||
key_roots.append(business_path)
|
||||
sub_type_map[business_path] = sub_type
|
||||
|
||||
if real_path != business_path:
|
||||
self.add_path_mapping(business_path, real_path)
|
||||
|
||||
covered_by = covered_real_paths.get(real_path)
|
||||
if covered_by:
|
||||
logger.warning(
|
||||
"Detected an other-model root ('%s', category '%s') that "
|
||||
"overlaps an existing checkpoints/embeddings root ('%s'). "
|
||||
"The same files will appear on both pages; please review "
|
||||
"your path configuration.",
|
||||
business_path,
|
||||
key,
|
||||
covered_by,
|
||||
)
|
||||
if key_roots:
|
||||
per_key_roots[key] = key_roots
|
||||
|
||||
return unique_paths, sub_type_map, per_key_roots
|
||||
|
||||
def _apply_library_paths(
|
||||
self,
|
||||
folder_paths: Mapping[str, Any],
|
||||
@@ -1318,16 +1151,6 @@ class Config:
|
||||
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||
|
||||
other_path_map = {
|
||||
key: folder_paths.get(key, []) or []
|
||||
for key in self._get_enabled_other_folder_keys()
|
||||
}
|
||||
(
|
||||
self.other_roots,
|
||||
self.other_root_subtypes,
|
||||
self.other_folder_roots,
|
||||
) = self._prepare_other_paths(other_path_map)
|
||||
|
||||
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||
extra_paths = extra_folder_paths or {}
|
||||
extra_lora_paths = extra_paths.get("loras", []) or []
|
||||
@@ -1444,104 +1267,6 @@ class Config:
|
||||
logger.warning(f"Error initializing embedding paths: {e}")
|
||||
return []
|
||||
|
||||
def _init_other_paths(self) -> List[str]:
|
||||
"""Initialize and validate other-model paths from ComfyUI settings.
|
||||
|
||||
Iterates the enabled OTHER_MODEL_FOLDER_SUBTYPES keys and pulls each
|
||||
from ``folder_paths.get_folder_paths(key)`` (in standalone mode the
|
||||
mock serves arbitrary keys from ``settings.json.folder_paths``).
|
||||
Legacy aliases the host normalizes onto a canonical key (``clip`` ->
|
||||
``text_encoders``) are collapsed first so the same folders are not
|
||||
reported twice.
|
||||
"""
|
||||
try:
|
||||
folder_path_map: Dict[str, List[str]] = {}
|
||||
for key in self._collapse_legacy_folder_keys(
|
||||
self._get_enabled_other_folder_keys()
|
||||
):
|
||||
try:
|
||||
folder_path_map[key] = folder_paths.get_folder_paths(key)
|
||||
except Exception as exc:
|
||||
logger.debug("Error reading folder paths for '%s': %s", key, exc)
|
||||
|
||||
(
|
||||
unique_paths,
|
||||
self.other_root_subtypes,
|
||||
self.other_folder_roots,
|
||||
) = self._prepare_other_paths(folder_path_map)
|
||||
|
||||
logger.info(
|
||||
"Found other model roots:"
|
||||
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
|
||||
)
|
||||
|
||||
if not unique_paths:
|
||||
logger.info("No valid other-model folders found in configuration")
|
||||
return []
|
||||
|
||||
return unique_paths
|
||||
except Exception as e:
|
||||
logger.warning(f"Error initializing other model paths: {e}")
|
||||
return []
|
||||
|
||||
def refresh_other_roots(self) -> None:
|
||||
"""Rebuild other-model roots after the management toggles changed.
|
||||
|
||||
Called when ``enable_other_models`` / ``enabled_other_sub_types`` are
|
||||
updated so the scanner immediately reflects the new folder set without
|
||||
a full application restart.
|
||||
"""
|
||||
self.other_roots = self._init_other_paths()
|
||||
self._rebuild_preview_roots()
|
||||
|
||||
def get_other_models_availability(self) -> Dict[str, Any]:
|
||||
"""Report the other-model folders the host can actually expose.
|
||||
|
||||
Independent of the opt-in ``enable_other_models`` toggle: this answers
|
||||
"could Other Models management work here at all?". ComfyUI mode almost
|
||||
always has these folder keys registered, while standalone mode only
|
||||
knows the keys present in ``settings.json.folder_paths`` - so the UI
|
||||
uses this to decide whether announcing the feature would be actionable.
|
||||
|
||||
Returns:
|
||||
``{"available": bool, "sub_types": {sub_type: [existing roots]}}``.
|
||||
A folder only counts when it exists on disk; an empty folder still
|
||||
counts because CivitAI downloads can target it.
|
||||
"""
|
||||
sub_types: Dict[str, List[str]] = {}
|
||||
try:
|
||||
keys = self._collapse_legacy_folder_keys(
|
||||
list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
keys = list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
|
||||
|
||||
for key in keys:
|
||||
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
|
||||
if not sub_type:
|
||||
continue
|
||||
try:
|
||||
raw_paths = folder_paths.get_folder_paths(key)
|
||||
except Exception as exc:
|
||||
logger.debug("Error probing folder paths for '%s': %s", key, exc)
|
||||
continue
|
||||
|
||||
bucket = sub_types.setdefault(sub_type, [])
|
||||
for root in sorted(
|
||||
self._dedupe_existing_paths(raw_paths or []).values(),
|
||||
key=lambda path: path.lower(),
|
||||
):
|
||||
if root not in bucket:
|
||||
bucket.append(root)
|
||||
|
||||
available_sub_types = {
|
||||
sub_type: roots for sub_type, roots in sub_types.items() if roots
|
||||
}
|
||||
return {
|
||||
"available": bool(available_sub_types),
|
||||
"sub_types": available_sub_types,
|
||||
}
|
||||
|
||||
def get_preview_static_url(self, preview_path: str) -> str:
|
||||
if not preview_path:
|
||||
return ""
|
||||
|
||||
+1
-7
@@ -219,7 +219,6 @@ class LoraManager:
|
||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
|
||||
# Initialize recipe scanner if needed
|
||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||
@@ -237,10 +236,6 @@ class LoraManager:
|
||||
embedding_scanner.initialize_in_background(),
|
||||
name="embedding_cache_init",
|
||||
),
|
||||
asyncio.create_task(
|
||||
other_scanner.initialize_in_background(),
|
||||
name="other_cache_init",
|
||||
),
|
||||
asyncio.create_task(
|
||||
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
|
||||
),
|
||||
@@ -333,7 +328,6 @@ class LoraManager:
|
||||
all_roots.update(config.loras_roots)
|
||||
all_roots.update(config.base_models_roots or [])
|
||||
all_roots.update(config.embeddings_roots or [])
|
||||
all_roots.update(config.other_roots or [])
|
||||
|
||||
total_deleted = 0
|
||||
total_size_freed = 0
|
||||
@@ -466,7 +460,7 @@ class LoraManager:
|
||||
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner"):
|
||||
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
||||
scanner = ServiceRegistry.get_service_sync(name)
|
||||
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||
scanner.cancel_task()
|
||||
|
||||
@@ -36,7 +36,6 @@ SCANNER_TYPE_MAP: dict[str, str] = {
|
||||
"get_lora_scanner": "lora",
|
||||
"get_checkpoint_scanner": "checkpoint",
|
||||
"get_embedding_scanner": "embedding",
|
||||
"get_other_scanner": "other",
|
||||
}
|
||||
|
||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
||||
@@ -81,8 +80,8 @@ async def _find_scanner_for_model(
|
||||
|
||||
|
||||
async def identify_model_type(model_path: str) -> str:
|
||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
|
||||
``\"embedding\"``, or ``\"other\"``) for *model_path*.
|
||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
||||
``\"embedding\"``) for *model_path*.
|
||||
|
||||
Falls back to ``\"lora\"`` when unknown.
|
||||
"""
|
||||
|
||||
@@ -149,7 +149,6 @@ class BaseModelRoutes(ABC):
|
||||
settings_service=self._settings,
|
||||
server_i18n=self._server_i18n,
|
||||
logger=logger,
|
||||
page_context_provider=self._get_page_context_provider(),
|
||||
)
|
||||
listing = ModelListingHandler(
|
||||
service=service,
|
||||
@@ -251,10 +250,6 @@ class BaseModelRoutes(ABC):
|
||||
"""Get expected model types string for error messages - to be overridden by subclasses."""
|
||||
return "any model type"
|
||||
|
||||
def _get_page_context_provider(self):
|
||||
"""Optional hook returning extra template context for the page view."""
|
||||
return None
|
||||
|
||||
def _find_model_file(self, files):
|
||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
"""HTTP handler for download target routing decisions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from ...services.download_routing import (
|
||||
is_diffusion_model_download,
|
||||
resolve_other_download_sub_type,
|
||||
)
|
||||
from ...utils.constants import VALID_OTHER_CIVITAI_TYPES
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DownloadRoutingHandler:
|
||||
"""Expose the download-time checkpoint/diffusion-model routing decision.
|
||||
|
||||
The web UI calls this when the user reaches the download location step
|
||||
so the root dropdown offers the same root set (checkpoint vs unet) that
|
||||
the download manager would pick for ``use_default_paths``.
|
||||
"""
|
||||
|
||||
async def get_download_routing(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Invalid JSON payload"}, status=400
|
||||
)
|
||||
|
||||
model_type = payload.get("model_type", "")
|
||||
base_model = payload.get("base_model") or ""
|
||||
file_types = payload.get("file_types") or []
|
||||
selected_file_type = payload.get("selected_file_type")
|
||||
|
||||
if not isinstance(model_type, str) or not model_type:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "model_type is required"}, status=400
|
||||
)
|
||||
if not isinstance(base_model, str) or not isinstance(file_types, list):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"error": "base_model must be a string and file_types a list",
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
if selected_file_type is not None and not isinstance(selected_file_type, str):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "selected_file_type must be a string"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
|
||||
from ...services.settings_manager import get_settings_manager
|
||||
|
||||
settings = get_settings_manager()
|
||||
if not settings.is_other_models_enabled():
|
||||
# Opt-in feature is off: never auto-route, the UI falls back to
|
||||
# manual folder selection and the download manager rejects it.
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"root_kind": "other",
|
||||
"sub_type": None,
|
||||
"disabled": True,
|
||||
"reason": "other_models_disabled",
|
||||
}
|
||||
)
|
||||
|
||||
sub_type = resolve_other_download_sub_type(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
selected_file_type=selected_file_type,
|
||||
)
|
||||
if sub_type and not settings.is_other_sub_type_enabled(sub_type):
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"root_kind": "other",
|
||||
"sub_type": None,
|
||||
"disabled": True,
|
||||
"reason": "other_sub_type_disabled",
|
||||
"requested_sub_type": sub_type,
|
||||
}
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"root_kind": "other",
|
||||
"sub_type": sub_type,
|
||||
}
|
||||
)
|
||||
|
||||
is_diffusion = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(str(t) for t in file_types),
|
||||
base_model=base_model,
|
||||
)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"is_diffusion_model": is_diffusion,
|
||||
"root_kind": "unet" if is_diffusion else model_type,
|
||||
}
|
||||
)
|
||||
@@ -122,8 +122,12 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
||||
metadata._unknown_fields["hf_url"] = hf_url
|
||||
metadata.from_civitai = False # HF models are not from CivitAI
|
||||
|
||||
metadata_dict = metadata.to_dict()
|
||||
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
|
||||
del metadata_dict["trainedWords"]
|
||||
|
||||
# 3. Save metadata atomically
|
||||
await MetadataManager.save_metadata(dest_path, metadata)
|
||||
await MetadataManager.save_metadata(dest_path, metadata_dict)
|
||||
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||
|
||||
# 4. Determine relative folder path for cache
|
||||
@@ -240,10 +244,7 @@ class HfHandler:
|
||||
})
|
||||
|
||||
existing["hf_url"] = hf_url
|
||||
# NOTE: deliberately do NOT touch `from_civitai` here. It records
|
||||
# where the metadata came from, and the UI must show the CivitAI
|
||||
# link whenever CivitAI data is present — linking HuggingFace must
|
||||
# not hide it (#1094). HF provenance is tracked via `hf_url`.
|
||||
existing["from_civitai"] = False
|
||||
await MetadataManager.save_metadata(file_path, existing)
|
||||
|
||||
await _add_to_scanner_cache(file_path, existing)
|
||||
|
||||
@@ -53,11 +53,9 @@ from ...utils.constants import (
|
||||
PREVIEW_EXTENSIONS,
|
||||
SUPPORTED_MEDIA_EXTENSIONS,
|
||||
VALID_LORA_TYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
)
|
||||
from .hf_handlers import HfHandler
|
||||
from .agent_handlers import AgentHandler
|
||||
from .download_routing_handlers import DownloadRoutingHandler
|
||||
from .model_handlers import ModelCivitaiHandler
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.example_images_paths import (
|
||||
@@ -659,21 +657,9 @@ class HealthCheckHandler:
|
||||
"lora": ServiceRegistry.get_lora_scanner,
|
||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||
"other": ServiceRegistry.get_other_scanner,
|
||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||
}
|
||||
|
||||
def _active_scanner_getters(
|
||||
self,
|
||||
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
getters = self._scanner_getters
|
||||
if "other" not in getters:
|
||||
return getters
|
||||
if get_settings_manager().is_other_models_enabled():
|
||||
return getters
|
||||
return {name: getter for name, getter in getters.items() if name != "other"}
|
||||
|
||||
async def health_check(self, request: web.Request) -> web.Response:
|
||||
return web.json_response({"status": "ok"})
|
||||
|
||||
@@ -685,7 +671,7 @@ class HealthCheckHandler:
|
||||
page accepts the update and only reloads once all scanners are done.
|
||||
"""
|
||||
pending: list[str] = []
|
||||
for name, getter in self._active_scanner_getters().items():
|
||||
for name, getter in self._scanner_getters.items():
|
||||
try:
|
||||
scanner = await getter()
|
||||
except Exception:
|
||||
@@ -770,19 +756,10 @@ class DoctorHandler:
|
||||
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
||||
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
||||
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
||||
("other", "Other Models", ServiceRegistry.get_other_scanner),
|
||||
)
|
||||
)
|
||||
self._app_version_getter = app_version_getter
|
||||
|
||||
def _active_scanner_factories(
|
||||
self,
|
||||
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
|
||||
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||
if self._settings.is_other_models_enabled():
|
||||
return self._scanner_factories
|
||||
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
|
||||
|
||||
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
client_version = (request.query.get("clientVersion") or "").strip()
|
||||
@@ -830,7 +807,7 @@ class DoctorHandler:
|
||||
repaired: list[dict[str, Any]] = []
|
||||
failures: list[dict[str, str]] = []
|
||||
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
try:
|
||||
scanner = await factory()
|
||||
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
||||
@@ -862,7 +839,7 @@ class DoctorHandler:
|
||||
renamed: list[dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
try:
|
||||
scanner = await factory()
|
||||
hash_index = getattr(scanner, "_hash_index", None)
|
||||
@@ -1094,7 +1071,7 @@ class DoctorHandler:
|
||||
overall_status = "ok"
|
||||
summary = "All model caches look healthy."
|
||||
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
try:
|
||||
scanner = await factory()
|
||||
persisted = None
|
||||
@@ -1179,7 +1156,7 @@ class DoctorHandler:
|
||||
total_conflict_groups = 0
|
||||
total_conflict_files = 0
|
||||
|
||||
for model_type, label, factory in self._active_scanner_factories():
|
||||
for model_type, label, factory in self._scanner_factories:
|
||||
# Duplicate filename detection targets LoRAs which use basename-only
|
||||
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||
# models via relative paths with extensions, so conflicts there would
|
||||
@@ -1559,22 +1536,6 @@ class SettingsHandler:
|
||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||
raw_llm_key = self._settings.get("llm_api_key")
|
||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||
# Derived capability flag (not persisted): whether the host exposes
|
||||
# any other-model folder at all. Standalone installs only know the
|
||||
# folder_paths keys present in settings.json, so the announcement
|
||||
# banner uses this to avoid promising a page that cannot list
|
||||
# anything.
|
||||
try:
|
||||
availability = config.get_other_models_availability()
|
||||
response_data["other_models_paths_available"] = bool(
|
||||
availability.get("available")
|
||||
)
|
||||
except Exception as availability_error: # pragma: no cover - defensive
|
||||
logger.debug(
|
||||
"Could not resolve Other Models availability: %s",
|
||||
availability_error,
|
||||
)
|
||||
response_data["other_models_paths_available"] = None
|
||||
settings_file = getattr(self._settings, "settings_file", None)
|
||||
if settings_file:
|
||||
response_data["settings_file"] = settings_file
|
||||
@@ -2104,7 +2065,6 @@ class ServiceRegistryAdapter:
|
||||
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
||||
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
||||
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
||||
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
|
||||
|
||||
|
||||
class ModelLibraryHandler:
|
||||
@@ -2129,8 +2089,6 @@ class ModelLibraryHandler:
|
||||
return "checkpoint"
|
||||
if normalized in {"embedding", "textualinversion"}:
|
||||
return "embedding"
|
||||
if normalized in VALID_OTHER_CIVITAI_TYPES:
|
||||
return "other"
|
||||
return None
|
||||
|
||||
async def _get_scanner_for_type(self, model_type: str | None):
|
||||
@@ -2141,13 +2099,6 @@ class ModelLibraryHandler:
|
||||
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
||||
if normalized_type == "embedding":
|
||||
return normalized_type, await self._service_registry.get_embedding_scanner()
|
||||
if normalized_type == "other":
|
||||
# Opt-in feature: the other scanner only resolves while the master
|
||||
# switch is on, so callers keep returning the legacy "required"
|
||||
# error (400) when it is off.
|
||||
if not get_settings_manager().is_other_models_enabled():
|
||||
return None, None
|
||||
return normalized_type, await self._service_registry.get_other_scanner()
|
||||
return None, None
|
||||
|
||||
async def _get_download_history_service(self):
|
||||
@@ -2239,11 +2190,6 @@ class ModelLibraryHandler:
|
||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
# Opt-in: probe the other scanner only while Other Models is enabled,
|
||||
# so the disabled behaviour stays byte-identical to the legacy one.
|
||||
other_scanner = None
|
||||
if get_settings_manager().is_other_models_enabled():
|
||||
other_scanner = await self._service_registry.get_other_scanner()
|
||||
|
||||
if model_version_id_str:
|
||||
try:
|
||||
@@ -2282,13 +2228,6 @@ class ModelLibraryHandler:
|
||||
exists = True
|
||||
model_type = "embedding"
|
||||
matched_scanner = embedding_scanner
|
||||
elif (
|
||||
other_scanner
|
||||
and await other_scanner.check_model_version_exists(model_version_id)
|
||||
):
|
||||
exists = True
|
||||
model_type = "other"
|
||||
matched_scanner = other_scanner
|
||||
|
||||
if exists:
|
||||
return web.json_response(
|
||||
@@ -2306,7 +2245,7 @@ class ModelLibraryHandler:
|
||||
history_service = await self._get_download_history_service()
|
||||
has_been_downloaded = False
|
||||
history_type = None
|
||||
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
if await history_service.has_been_downloaded(
|
||||
candidate_type,
|
||||
model_version_id,
|
||||
@@ -2328,7 +2267,6 @@ class ModelLibraryHandler:
|
||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||
checkpoint_versions = []
|
||||
embedding_versions = []
|
||||
other_versions = []
|
||||
if not lora_versions and checkpoint_scanner:
|
||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
||||
model_id
|
||||
@@ -2337,13 +2275,6 @@ class ModelLibraryHandler:
|
||||
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
||||
model_id
|
||||
)
|
||||
if (
|
||||
not lora_versions
|
||||
and not checkpoint_versions
|
||||
and not embedding_versions
|
||||
and other_scanner
|
||||
):
|
||||
other_versions = await other_scanner.get_model_versions_by_id(model_id)
|
||||
|
||||
model_type = None
|
||||
versions = []
|
||||
@@ -2375,18 +2306,9 @@ class ModelLibraryHandler:
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
if other_versions:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"modelType": "other",
|
||||
"versions": self._with_downloaded_flag(other_versions),
|
||||
"downloadedVersionIds": [],
|
||||
}
|
||||
)
|
||||
|
||||
history_service = await self._get_download_history_service()
|
||||
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
|
||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
||||
candidate_downloaded_version_ids = (
|
||||
await history_service.get_downloaded_version_ids(
|
||||
candidate_type,
|
||||
@@ -2441,11 +2363,6 @@ class ModelLibraryHandler:
|
||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||
# Opt-in: keep the other probe last so model cards for lora /
|
||||
# checkpoint / embedding ids are unaffected by the extra scanner.
|
||||
other_scanner = None
|
||||
if get_settings_manager().is_other_models_enabled():
|
||||
other_scanner = await self._service_registry.get_other_scanner()
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
for model_id in model_ids:
|
||||
@@ -2481,17 +2398,6 @@ class ModelLibraryHandler:
|
||||
})
|
||||
continue
|
||||
|
||||
if other_scanner:
|
||||
other_versions = await other_scanner.get_model_versions_by_id(model_id)
|
||||
if other_versions:
|
||||
results.append({
|
||||
"modelId": model_id,
|
||||
"modelType": "other",
|
||||
"versions": self._with_downloaded_flag(other_versions),
|
||||
"downloadedVersionIds": [],
|
||||
})
|
||||
continue
|
||||
|
||||
results.append({
|
||||
"modelId": model_id,
|
||||
"modelType": None,
|
||||
@@ -2880,32 +2786,12 @@ class ModelLibraryHandler:
|
||||
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
||||
}
|
||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
||||
other_type_aliases = {
|
||||
model_type.lower() for model_type in VALID_OTHER_CIVITAI_TYPES
|
||||
}
|
||||
|
||||
# Acquire the other scanner lazily so adapters without it only
|
||||
# fail when the payload actually contains other-type models.
|
||||
# While the opt-in feature is off the scanner still exists (its
|
||||
# cache is empty), so other types simply report inLibrary=False.
|
||||
needs_other_scanner = any(
|
||||
isinstance(model, dict)
|
||||
and str(model.get("type", "")).lower() in other_type_aliases
|
||||
for model in models
|
||||
)
|
||||
other_scanner = None
|
||||
if needs_other_scanner:
|
||||
other_scanner = await self._service_registry.get_other_scanner()
|
||||
|
||||
type_scanner_map: Dict[str, Any] = {
|
||||
**{alias: lora_scanner for alias in lora_type_aliases},
|
||||
"checkpoint": checkpoint_scanner,
|
||||
"textualinversion": embedding_scanner,
|
||||
}
|
||||
if other_scanner is not None:
|
||||
type_scanner_map.update(
|
||||
{alias: other_scanner for alias in other_type_aliases}
|
||||
)
|
||||
|
||||
versions: list[dict[str, Any]] = []
|
||||
history_service = await self._get_download_history_service()
|
||||
@@ -2929,17 +2815,12 @@ class ModelLibraryHandler:
|
||||
"embedding",
|
||||
model_ids,
|
||||
)
|
||||
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||
"other",
|
||||
model_ids,
|
||||
)
|
||||
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||
"lora": lora_downloaded,
|
||||
"locon": lora_downloaded,
|
||||
"dora": lora_downloaded,
|
||||
"checkpoint": checkpoint_downloaded,
|
||||
"textualinversion": embedding_downloaded,
|
||||
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
|
||||
}
|
||||
for model in models:
|
||||
if not isinstance(model, dict):
|
||||
@@ -4003,7 +3884,6 @@ class MiscHandlerSet:
|
||||
base_model: BaseModelHandlerSet,
|
||||
hf_handler: Any = None,
|
||||
agent_handler: Any = None,
|
||||
download_routing: Any = None,
|
||||
) -> None:
|
||||
self.health = health
|
||||
self.settings = settings
|
||||
@@ -4024,7 +3904,6 @@ class MiscHandlerSet:
|
||||
self.base_model = base_model
|
||||
self.hf_handler = hf_handler
|
||||
self.agent_handler = agent_handler
|
||||
self.download_routing = download_routing
|
||||
|
||||
def to_route_mapping(
|
||||
self,
|
||||
@@ -4083,8 +3962,6 @@ class MiscHandlerSet:
|
||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||
# Download routing handler
|
||||
"get_download_routing": self.download_routing.get_download_routing,
|
||||
# Base model handlers
|
||||
"get_base_models": self.base_model.get_base_models,
|
||||
"refresh_base_models": self.base_model.refresh_base_models,
|
||||
@@ -4098,7 +3975,6 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
||||
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
||||
get_other_scanner=ServiceRegistry.get_other_scanner,
|
||||
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||
get_backup_service=ServiceRegistry.get_backup_service,
|
||||
)
|
||||
|
||||
@@ -90,7 +90,6 @@ class ModelPageView:
|
||||
settings_service: SettingsManager,
|
||||
server_i18n,
|
||||
logger: logging.Logger,
|
||||
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self._template_env = template_env
|
||||
self._template_name = template_name
|
||||
@@ -98,7 +97,6 @@ class ModelPageView:
|
||||
self._settings = settings_service
|
||||
self._server_i18n = server_i18n
|
||||
self._logger = logger
|
||||
self._page_context_provider = page_context_provider
|
||||
|
||||
def _load_supporters(self) -> dict[str, Any]:
|
||||
"""Load supporters data from JSON file."""
|
||||
@@ -212,16 +210,6 @@ class ModelPageView:
|
||||
self._logger.error("Error loading cache data: %s", cache_error)
|
||||
template_context["is_initializing"] = True
|
||||
|
||||
if self._page_context_provider is not None:
|
||||
try:
|
||||
extra_context = self._page_context_provider(request)
|
||||
if isinstance(extra_context, dict):
|
||||
template_context.update(extra_context)
|
||||
except Exception as context_error: # pragma: no cover - logging path
|
||||
self._logger.error(
|
||||
"Error building page context: %s", context_error
|
||||
)
|
||||
|
||||
rendered = self._template_env.get_template(self._template_name).render(
|
||||
**template_context
|
||||
)
|
||||
|
||||
@@ -35,7 +35,6 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
||||
"loras": "get_lora_scanner",
|
||||
"checkpoints": "get_checkpoint_scanner",
|
||||
"embeddings": "get_embedding_scanner",
|
||||
"other": "get_other_scanner",
|
||||
}
|
||||
|
||||
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||
|
||||
@@ -74,26 +74,6 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
|
||||
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
||||
|
||||
|
||||
async def _parse_relaxed_flag(request: web.Request) -> bool:
|
||||
"""Read the relaxed-rematch flag from the JSON body or query string.
|
||||
|
||||
The flag defaults to False (strict candidacy). A JSON body value wins;
|
||||
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
|
||||
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
|
||||
"""
|
||||
relaxed = False
|
||||
if request.can_read_body:
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception: # noqa: BLE001 - any parse failure means no flag
|
||||
data = None
|
||||
if isinstance(data, dict):
|
||||
relaxed = bool(data.get("relaxed"))
|
||||
if not relaxed:
|
||||
relaxed = request.query.get("relaxed", "").lower() == "true"
|
||||
return relaxed
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecipeHandlerSet:
|
||||
"""Group of handlers providing recipe route implementations."""
|
||||
@@ -149,6 +129,11 @@ class RecipeHandlerSet:
|
||||
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
|
||||
"scan_recipes": self.query.scan_recipes,
|
||||
"move_recipe": self.management.move_recipe,
|
||||
"repair_recipes": self.management.repair_recipes,
|
||||
"cancel_repair": self.management.cancel_repair,
|
||||
"repair_recipe": self.management.repair_recipe,
|
||||
"repair_recipes_bulk": self.management.repair_recipes_bulk,
|
||||
"get_repair_progress": self.management.get_repair_progress,
|
||||
"rematch_recipes": self.management.rematch_recipes,
|
||||
"cancel_rematch": self.management.cancel_rematch,
|
||||
"rematch_recipe": self.management.rematch_recipe,
|
||||
@@ -811,6 +796,157 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def repair_recipes(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
# Check if already running
|
||||
if self._ws_manager.is_recipe_repair_running():
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe repair already in progress"},
|
||||
status=409,
|
||||
)
|
||||
|
||||
recipe_scanner.reset_cancellation()
|
||||
|
||||
async def progress_callback(data):
|
||||
await self._ws_manager.broadcast_recipe_repair_progress(data)
|
||||
|
||||
# Run in background to avoid timeout
|
||||
async def run_repair():
|
||||
try:
|
||||
await recipe_scanner.repair_all_recipes(
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(
|
||||
f"Error in recipe repair task: {e}", exc_info=True
|
||||
)
|
||||
await self._ws_manager.broadcast_recipe_repair_progress(
|
||||
{"status": "error", "error": str(e)}
|
||||
)
|
||||
finally:
|
||||
# Keep the final status for a while so the UI can see it
|
||||
await asyncio.sleep(5)
|
||||
# Don't cleanup if it was cancelled, let the UI see the cancelled state for a bit?
|
||||
# Actually cleanup_recipe_repair_progress is fine as long as we waited enough.
|
||||
self._ws_manager.cleanup_recipe_repair_progress()
|
||||
|
||||
asyncio.create_task(run_repair())
|
||||
|
||||
return web.json_response(
|
||||
{"success": True, "message": "Recipe repair started"}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error starting recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def cancel_repair(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
recipe_scanner.cancel_task()
|
||||
return web.json_response(
|
||||
{"success": True, "message": "Cancellation requested"}
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error cancelling recipe repair: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def repair_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||
"""Bulk repair metadata for multiple recipes by their IDs.
|
||||
|
||||
Accepts a JSON body with a "recipe_ids" array and iterates
|
||||
repair_recipe_by_id over each entry, collecting statistics.
|
||||
"""
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
recipe_ids = data.get("recipe_ids", [])
|
||||
if not recipe_ids:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "recipe_ids are required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
total = len(recipe_ids)
|
||||
repaired = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
recipes = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
try:
|
||||
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||
if result.get("success"):
|
||||
repaired += result.get("repaired", 0)
|
||||
skipped += result.get("skipped", 0)
|
||||
if result.get("recipe"):
|
||||
recipes.append(result["recipe"])
|
||||
else:
|
||||
errors += 1
|
||||
except RecipeNotFoundError:
|
||||
skipped += 1
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error repairing recipe %s: %s", recipe_id, exc
|
||||
)
|
||||
errors += 1
|
||||
|
||||
return web.json_response({
|
||||
"success": True,
|
||||
"total": total,
|
||||
"repaired": repaired,
|
||||
"skipped": skipped,
|
||||
"errors": errors,
|
||||
"recipes": recipes,
|
||||
})
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
"Error performing bulk repair: %s", exc, exc_info=True
|
||||
)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(exc)}, status=500
|
||||
)
|
||||
|
||||
async def repair_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe scanner unavailable"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
result = await recipe_scanner.repair_recipe_by_id(recipe_id)
|
||||
return web.json_response(result)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error repairing single recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def rematch_recipes(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -822,9 +958,12 @@ class RecipeManagementHandler:
|
||||
)
|
||||
|
||||
# Mutual exclusion: a global rematch cannot start while a rematch
|
||||
# is already running — both mutate recipes under the same
|
||||
# mutation lock.
|
||||
if self._ws_manager.is_recipe_rematch_running():
|
||||
# OR a repair is already running — both mutate recipes under the
|
||||
# same mutation lock.
|
||||
if (
|
||||
self._ws_manager.is_recipe_rematch_running()
|
||||
or self._ws_manager.is_recipe_repair_running()
|
||||
):
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Recipe rematch already in progress"},
|
||||
status=409,
|
||||
@@ -832,8 +971,6 @@ class RecipeManagementHandler:
|
||||
|
||||
recipe_scanner.reset_cancellation()
|
||||
|
||||
relaxed = await _parse_relaxed_flag(request)
|
||||
|
||||
async def progress_callback(data):
|
||||
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
||||
|
||||
@@ -841,8 +978,7 @@ class RecipeManagementHandler:
|
||||
async def run_rematch():
|
||||
try:
|
||||
await recipe_scanner.rematch_all_recipes(
|
||||
progress_callback=progress_callback,
|
||||
relaxed=relaxed,
|
||||
progress_callback=progress_callback
|
||||
)
|
||||
except Exception as e:
|
||||
self._logger.error(
|
||||
@@ -915,13 +1051,7 @@ class RecipeManagementHandler:
|
||||
status=400,
|
||||
)
|
||||
|
||||
relaxed = bool(data.get("relaxed")) or (
|
||||
request.query.get("relaxed", "").lower() == "true"
|
||||
)
|
||||
|
||||
result = await recipe_scanner.rematch_recipes_bulk(
|
||||
recipe_ids, relaxed=relaxed
|
||||
)
|
||||
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
||||
return web.json_response(result)
|
||||
except Exception as exc:
|
||||
self._logger.error(
|
||||
@@ -950,10 +1080,7 @@ class RecipeManagementHandler:
|
||||
)
|
||||
|
||||
recipe_id = request.match_info["recipe_id"]
|
||||
relaxed = await _parse_relaxed_flag(request)
|
||||
result = await recipe_scanner.rematch_recipe_by_id(
|
||||
recipe_id, relaxed=relaxed
|
||||
)
|
||||
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
||||
return web.json_response(result)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
@@ -1052,55 +1179,12 @@ class RecipeManagementHandler:
|
||||
persisted_source_path=persisted_source_path,
|
||||
)
|
||||
|
||||
# Optional caller-supplied metadata payload (companion browser
|
||||
# extension re-import). Only honored for CivitAI image page
|
||||
# sources; everything else uses the native URL import below.
|
||||
params = request.rel_url.query
|
||||
payload_image_url = params.get("image_url")
|
||||
payload_name = params.get("name")
|
||||
payload_resources = params.get("resources")
|
||||
has_import_payload = bool(
|
||||
payload_image_url and payload_name and payload_resources
|
||||
)
|
||||
|
||||
import_response: web.Response | None = None
|
||||
if has_import_payload and image_id:
|
||||
try:
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._import_remote_recipe_impl(
|
||||
image_url=payload_image_url,
|
||||
name=payload_name,
|
||||
resources_raw=payload_resources,
|
||||
gen_params_raw=params.get("gen_params"),
|
||||
tags_raw=params.get("tags"),
|
||||
base_model=params.get("base_model", "") or "",
|
||||
source_path=source_path,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
except RecipeValidationError as exc:
|
||||
# Malformed resources/gen_params JSON: treat as "no
|
||||
# payload" and use the legacy URL re-import.
|
||||
self._logger.warning(
|
||||
"Ignoring malformed re-import payload for recipe %s "
|
||||
"(%s); falling back to source URL re-import",
|
||||
recipe_id,
|
||||
exc,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.warning(
|
||||
"Payload-based re-import failed for recipe %s: %s; "
|
||||
"falling back to source URL re-import",
|
||||
recipe_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
if import_response is None:
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._do_import_from_url(
|
||||
source_path,
|
||||
recipe_scanner,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
async with self._import_semaphore:
|
||||
import_response = await self._do_import_from_url(
|
||||
source_path,
|
||||
recipe_scanner,
|
||||
target_dir=old_folder,
|
||||
)
|
||||
|
||||
await self._persistence_service.delete_recipe(
|
||||
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||
@@ -1127,19 +1211,14 @@ class RecipeManagementHandler:
|
||||
exc,
|
||||
)
|
||||
|
||||
response_body: Dict[str, Any] = {
|
||||
"success": True,
|
||||
"old_recipe_id": recipe_id,
|
||||
"recipe_id": new_recipe_id,
|
||||
"source_path": source_path,
|
||||
}
|
||||
loras_count = await self._count_recipe_loras(
|
||||
recipe_scanner, new_recipe_id
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"old_recipe_id": recipe_id,
|
||||
"recipe_id": new_recipe_id,
|
||||
"source_path": source_path,
|
||||
}
|
||||
)
|
||||
if loras_count is not None:
|
||||
response_body["loras_count"] = loras_count
|
||||
|
||||
return web.json_response(response_body)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except RecipeValidationError as exc:
|
||||
@@ -1152,6 +1231,18 @@ class RecipeManagementHandler:
|
||||
)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_repair_progress(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
progress = self._ws_manager.get_recipe_repair_progress()
|
||||
if progress:
|
||||
return web.json_response({"success": True, "progress": progress})
|
||||
return web.json_response(
|
||||
{"success": False, "message": "No repair in progress"}, status=404
|
||||
)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -1172,14 +1263,31 @@ class RecipeManagementHandler:
|
||||
if not resources_raw:
|
||||
raise RecipeValidationError("Missing required field: resources")
|
||||
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(
|
||||
resources_raw
|
||||
)
|
||||
gen_params_request = self._parse_gen_params(params.get("gen_params"))
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, lora_count=%d",
|
||||
image_url,
|
||||
len(lora_entries),
|
||||
)
|
||||
self._logger.debug(
|
||||
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||
async with self._import_semaphore:
|
||||
return await self._import_remote_recipe_impl(
|
||||
return await self._do_import_remote_recipe(
|
||||
image_url=image_url,
|
||||
name=name,
|
||||
resources_raw=resources_raw,
|
||||
gen_params_raw=params.get("gen_params"),
|
||||
tags_raw=params.get("tags"),
|
||||
lora_entries=lora_entries,
|
||||
checkpoint_entry=checkpoint_entry,
|
||||
gen_params_request=gen_params_request,
|
||||
tags=self._parse_tags(params.get("tags")),
|
||||
base_model=params.get("base_model", "") or "",
|
||||
source_path=params.get("source_path") or image_url,
|
||||
)
|
||||
@@ -1193,52 +1301,6 @@ class RecipeManagementHandler:
|
||||
)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def _import_remote_recipe_impl(
|
||||
self,
|
||||
*,
|
||||
image_url: str,
|
||||
name: str,
|
||||
resources_raw: str,
|
||||
gen_params_raw: Optional[str],
|
||||
tags_raw: Optional[str],
|
||||
base_model: str,
|
||||
source_path: str,
|
||||
target_dir: str | None = None,
|
||||
) -> web.Response:
|
||||
"""Payload-based remote import engine shared by import-remote and the
|
||||
extension-driven re-import path.
|
||||
|
||||
Parses the caller-supplied payloads and delegates to
|
||||
:meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on
|
||||
malformed payloads so callers can decide how to handle them (the
|
||||
re-import path falls back to the legacy URL import).
|
||||
"""
|
||||
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
|
||||
gen_params_request = self._parse_gen_params(gen_params_raw)
|
||||
|
||||
self._logger.info(
|
||||
"Remote recipe import received: url=%s, lora_count=%d",
|
||||
image_url,
|
||||
len(lora_entries),
|
||||
)
|
||||
self._logger.debug(
|
||||
" gen_params_keys=%s, checkpoint_keys=%s",
|
||||
sorted(gen_params_request.keys()) if gen_params_request else [],
|
||||
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
|
||||
)
|
||||
|
||||
return await self._do_import_remote_recipe(
|
||||
image_url=image_url,
|
||||
name=name,
|
||||
lora_entries=lora_entries,
|
||||
checkpoint_entry=checkpoint_entry,
|
||||
gen_params_request=gen_params_request,
|
||||
tags=self._parse_tags(tags_raw),
|
||||
base_model=base_model,
|
||||
source_path=source_path,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
|
||||
async def _do_import_remote_recipe(
|
||||
self,
|
||||
*,
|
||||
@@ -1250,7 +1312,6 @@ class RecipeManagementHandler:
|
||||
tags: list[Any],
|
||||
base_model: str,
|
||||
source_path: str,
|
||||
target_dir: str | None = None,
|
||||
) -> web.Response:
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
@@ -1414,7 +1475,6 @@ class RecipeManagementHandler:
|
||||
tags=tags,
|
||||
metadata=metadata,
|
||||
extension=extension,
|
||||
target_dir=target_dir,
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
|
||||
@@ -1879,25 +1939,6 @@ class RecipeManagementHandler:
|
||||
return []
|
||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
||||
|
||||
async def _count_recipe_loras(
|
||||
self, recipe_scanner: Any, recipe_id: Optional[str]
|
||||
) -> Optional[int]:
|
||||
"""Best-effort LoRA count for a freshly saved recipe (for the
|
||||
re-import response). Returns None when the recipe cannot be read."""
|
||||
if not recipe_id:
|
||||
return None
|
||||
try:
|
||||
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||
except Exception as exc:
|
||||
self._logger.debug(
|
||||
"Could not read new recipe %s for loras_count: %s",
|
||||
recipe_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
loras = (recipe or {}).get("loras")
|
||||
return len(loras) if isinstance(loras, list) else None
|
||||
|
||||
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if payload is None:
|
||||
return None
|
||||
@@ -3124,12 +3165,6 @@ class RecipeWorkflowHandler:
|
||||
class BatchImportHandler:
|
||||
"""Handle batch import operations for recipes."""
|
||||
|
||||
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||
# path handling and fails the existence check.
|
||||
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -3303,27 +3338,31 @@ class BatchImportHandler:
|
||||
data = await request.json()
|
||||
directory_path = data.get("path", "")
|
||||
|
||||
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
||||
return self._windows_drives_response()
|
||||
|
||||
# Default to the user's home directory. The frontend previously
|
||||
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||
# resolves to the current drive root and then fails the access
|
||||
# check below.
|
||||
if not directory_path:
|
||||
path = Path.home()
|
||||
else:
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
return web.json_response(
|
||||
{"success": False, "error": "Directory path is required"},
|
||||
status=400,
|
||||
)
|
||||
|
||||
# Access check: browsing intentionally covers the whole server
|
||||
# filesystem (the server operator browses their own machine). On
|
||||
# POSIX every absolute path is under "/", but Path("/") has no
|
||||
# drive letter on Windows and can never anchor a drive-qualified
|
||||
# path in relative_to(), so test for a drive there instead.
|
||||
if os.name == "nt":
|
||||
is_allowed = bool(path.drive)
|
||||
else:
|
||||
is_allowed = path.is_absolute()
|
||||
# Normalize the path
|
||||
path = Path(directory_path).expanduser().resolve()
|
||||
|
||||
# Security check: ensure path is within allowed directories
|
||||
# Allow common image/model directories
|
||||
allowed_roots = [
|
||||
Path.home(),
|
||||
Path("/"), # Allow browsing from root for flexibility
|
||||
]
|
||||
|
||||
# Check if path is within any allowed root
|
||||
is_allowed = False
|
||||
for root in allowed_roots:
|
||||
try:
|
||||
path.relative_to(root)
|
||||
is_allowed = True
|
||||
break
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not is_allowed:
|
||||
return web.json_response(
|
||||
@@ -3390,24 +3429,15 @@ class BatchImportHandler:
|
||||
directories.sort(key=lambda x: x["name"].lower())
|
||||
image_files.sort(key=lambda x: x["name"].lower())
|
||||
|
||||
# Parent directory. A filesystem root is its own parent
|
||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||
# drive root (C:\) links up to the virtual drive list so users
|
||||
# can switch drives. The previous str(path) != str(path.root)
|
||||
# check misfired on Windows, where a drive root's parent is
|
||||
# itself, producing an infinite self-loop.
|
||||
if path.parent == path:
|
||||
parent_path = (
|
||||
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||
)
|
||||
else:
|
||||
parent_path = str(path.parent)
|
||||
# Add parent directory if not at root
|
||||
parent_path = path.parent
|
||||
show_parent = str(path) != str(path.root)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
"current_path": str(path),
|
||||
"parent_path": parent_path,
|
||||
"parent_path": str(parent_path) if show_parent else None,
|
||||
"directories": directories,
|
||||
"image_files": image_files,
|
||||
"image_count": len(image_files),
|
||||
@@ -3434,30 +3464,3 @@ class BatchImportHandler:
|
||||
except Exception as exc:
|
||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
def _windows_drives_response(self) -> web.Response:
|
||||
"""List available drive letters as a virtual directory (Windows only)."""
|
||||
try:
|
||||
drives = os.listdrives()
|
||||
except AttributeError: # Python < 3.12
|
||||
drives = [
|
||||
f"{letter}:\\"
|
||||
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
if os.path.exists(f"{letter}:\\")
|
||||
]
|
||||
directories = [
|
||||
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
||||
]
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
# Empty current_path marks the virtual level; the frontend
|
||||
# disables folder selection there.
|
||||
"current_path": "",
|
||||
"parent_path": None,
|
||||
"directories": directories,
|
||||
"image_files": [],
|
||||
"image_count": 0,
|
||||
"directory_count": len(directories),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -103,10 +103,6 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||
),
|
||||
# Download target routing decision (checkpoint vs diffusion model roots)
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||
),
|
||||
|
||||
@@ -41,7 +41,6 @@ from .handlers.misc_handlers import (
|
||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||
from .handlers.hf_handlers import HfHandler
|
||||
from .handlers.agent_handlers import AgentHandler
|
||||
from .handlers.download_routing_handlers import DownloadRoutingHandler
|
||||
from .misc_route_registrar import MiscRouteRegistrar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -141,7 +140,6 @@ class MiscRoutes:
|
||||
base_model = BaseModelHandlerSet()
|
||||
hf_handler = HfHandler()
|
||||
agent_handler = AgentHandler()
|
||||
download_routing = DownloadRoutingHandler()
|
||||
|
||||
return self._handler_set_factory(
|
||||
health=health,
|
||||
@@ -163,7 +161,6 @@ class MiscRoutes:
|
||||
base_model=base_model,
|
||||
hf_handler=hf_handler,
|
||||
agent_handler=agent_handler,
|
||||
download_routing=download_routing,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from aiohttp import web
|
||||
|
||||
from .base_model_routes import BaseModelRoutes
|
||||
from .model_route_registrar import ModelRouteRegistrar
|
||||
from ..config import config
|
||||
from ..services.other_model_service import OtherModelService
|
||||
from ..services.service_registry import ServiceRegistry
|
||||
from ..utils.constants import (
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtherRoutes(BaseModelRoutes):
|
||||
"""Other-model-specific route controller (VAE, upscaler, text encoder, ...)"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Other-model routes with OtherModel service"""
|
||||
super().__init__()
|
||||
self.template_name = "other.html"
|
||||
|
||||
async def initialize_services(self):
|
||||
"""Initialize services from ServiceRegistry"""
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
update_service = await ServiceRegistry.get_model_update_service()
|
||||
self.service = OtherModelService(other_scanner, update_service=update_service)
|
||||
self.set_model_update_service(update_service)
|
||||
|
||||
# Attach service dependencies
|
||||
self.attach_service(self.service)
|
||||
|
||||
def setup_routes(self, app: web.Application, prefix: str = "other"):
|
||||
"""Setup Other-model routes"""
|
||||
# Schedule service initialization on app startup
|
||||
app.on_startup.append(lambda _: self.initialize_services())
|
||||
|
||||
# Setup common routes with 'other' prefix (includes page route)
|
||||
super().setup_routes(app, prefix)
|
||||
|
||||
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||
"""Setup Other-model-specific routes"""
|
||||
# Other-model info by name
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_other_model_info)
|
||||
# Other-model roots grouped by sub_type (text_encoders + legacy clip
|
||||
# are aggregated under text_encoder)
|
||||
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/roots_by_subtype', prefix, self.get_roots_by_subtype)
|
||||
|
||||
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||
"""Validate CivitAI model type for other models.
|
||||
|
||||
Accepts retired CivitAI types (CLIP, CLIPVision) as well — grandfathered
|
||||
models on CivitAI still carry them. Types whose sub_type is currently
|
||||
disabled (or every type while the opt-in feature is off) are rejected.
|
||||
"""
|
||||
normalized = (model_type or "").strip().lower()
|
||||
if normalized not in VALID_OTHER_CIVITAI_TYPES:
|
||||
return False
|
||||
if not self._settings.is_other_models_enabled():
|
||||
return False
|
||||
|
||||
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized)
|
||||
if sub_type is None:
|
||||
# CivitAI "Other" has no sub_type of its own; it is only usable
|
||||
# while at least one sub_type is enabled.
|
||||
return bool(self._settings.get_enabled_other_sub_types())
|
||||
return self._settings.is_other_sub_type_enabled(sub_type)
|
||||
|
||||
def _get_page_context_provider(self):
|
||||
"""Expose the opt-in feature state to the Other Models page template."""
|
||||
return self._page_context_for_other
|
||||
|
||||
def _page_context_for_other(self, request: web.Request) -> Dict[str, Any]:
|
||||
if not self._settings.is_other_models_enabled():
|
||||
return {"other_disabled": True, "other_no_paths": False}
|
||||
|
||||
# Enabled but nothing to scan: folder paths for the managed sub_types
|
||||
# resolved to no existing folder. Render an actionable empty state
|
||||
# instead of an apparently broken empty grid.
|
||||
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||
return {
|
||||
"other_disabled": False,
|
||||
"other_no_paths": not bool(config.other_roots),
|
||||
"standalone_mode": standalone_mode,
|
||||
}
|
||||
|
||||
def _get_expected_model_types(self) -> str:
|
||||
"""Get expected model types string for error messages"""
|
||||
return "VAE, Upscaler, TextEncoder, CLIPVision, Controlnet, or Other"
|
||||
|
||||
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
|
||||
"""Parse other-model-specific parameters (none in Phase 1)."""
|
||||
return {}
|
||||
|
||||
async def get_roots_by_subtype(self, request: web.Request) -> web.Response:
|
||||
"""Return other-model roots grouped by sub_type.
|
||||
|
||||
Aggregates the per-folder_paths-key roots from config
|
||||
(``text_encoders`` and the legacy ``clip`` key both land under
|
||||
``text_encoder``).
|
||||
"""
|
||||
try:
|
||||
roots_by_subtype: Dict[str, List[str]] = {}
|
||||
for key, roots in (config.other_folder_roots or {}).items():
|
||||
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
|
||||
if not sub_type:
|
||||
continue
|
||||
bucket = roots_by_subtype.setdefault(sub_type, [])
|
||||
for root in roots:
|
||||
if root and root not in bucket:
|
||||
bucket.append(root)
|
||||
return web.json_response(
|
||||
{"success": True, "roots_by_subtype": roots_by_subtype}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting other roots by sub_type: {e}", exc_info=True)
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(e)}, status=500
|
||||
)
|
||||
|
||||
async def get_other_model_info(self, request: web.Request) -> web.Response:
|
||||
"""Get detailed information for a specific other model by name"""
|
||||
try:
|
||||
name = request.match_info.get('name', '')
|
||||
model_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
if model_info:
|
||||
return web.json_response(model_info)
|
||||
else:
|
||||
return web.json_response({"error": "Model not found"}, status=404)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in get_other_model_info: {e}", exc_info=True)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
@@ -84,6 +84,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
|
||||
),
|
||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/rematch", "rematch_recipes"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
|
||||
@@ -110,11 +115,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
# The companion browser extension only ever issues GET requests, so the
|
||||
# payload-based re-import variant must also be reachable via GET.
|
||||
RouteDefinition(
|
||||
"GET", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
||||
),
|
||||
RouteDefinition(
|
||||
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
|
||||
),
|
||||
|
||||
@@ -407,6 +407,7 @@ class AgentService:
|
||||
"base_model": metadata.get("base_model", ""),
|
||||
"tags": metadata.get("tags", []),
|
||||
"modelDescription": metadata.get("modelDescription", ""),
|
||||
"trainedWords": metadata.get("trainedWords", []),
|
||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||
"size": metadata.get("size", 0),
|
||||
}
|
||||
|
||||
@@ -92,10 +92,7 @@ class PostProcessor:
|
||||
preview_downloaded = False
|
||||
|
||||
# -- Determine whether this is an HF-sourced model -----------------
|
||||
# Key off `hf_url` directly: `from_civitai` records provenance and can
|
||||
# be true for a model that is also linked to HuggingFace (both sources
|
||||
# coexist, see #1094), so it must not gate HF enrichment.
|
||||
is_hf_model = bool(metadata.get("hf_url", ""))
|
||||
is_hf_model = not metadata.get("from_civitai", True)
|
||||
|
||||
# -- Collect updates -----------------------------------------------
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
@@ -161,11 +161,6 @@ class Aria2Downloader:
|
||||
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
||||
and the partial download continues. Recovery is bounded by
|
||||
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||
|
||||
Cancellation never leaks daemon transfers: the gid is tracked in
|
||||
``_transfers`` before any post-``addUri`` await, and a gid accepted
|
||||
by the daemon while the caller is being cancelled is removed again
|
||||
before the ``CancelledError`` propagates.
|
||||
"""
|
||||
|
||||
await self._ensure_process()
|
||||
@@ -256,11 +251,7 @@ class Aria2Downloader:
|
||||
await asyncio.sleep(self._poll_interval)
|
||||
finally:
|
||||
current = self._transfers.get(download_id)
|
||||
if (
|
||||
transfer is not None
|
||||
and current is not None
|
||||
and current.gid == transfer.gid
|
||||
):
|
||||
if current is not None and current.gid == transfer.gid:
|
||||
self._transfers.pop(download_id, None)
|
||||
|
||||
async def _get_status_with_retry(
|
||||
@@ -348,43 +339,21 @@ class Aria2Downloader:
|
||||
resolved_url != url,
|
||||
)
|
||||
|
||||
# Shield the addUri RPC from cancellation: the daemon may accept the
|
||||
# download even when the caller is cancelled while the request is in
|
||||
# flight. On cancellation, wait for the RPC result so the freshly
|
||||
# created gid can be removed instead of leaking an untracked
|
||||
# download that keeps running in the daemon.
|
||||
add_task = asyncio.ensure_future(
|
||||
self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
)
|
||||
try:
|
||||
gid = await asyncio.shield(add_task)
|
||||
except asyncio.CancelledError:
|
||||
leaked_gid: Any = None
|
||||
try:
|
||||
leaked_gid = await add_task
|
||||
except Exception:
|
||||
leaked_gid = None
|
||||
if isinstance(leaked_gid, str) and leaked_gid:
|
||||
logger.info(
|
||||
"Removing aria2 gid %s accepted while download %s was "
|
||||
"being cancelled",
|
||||
leaked_gid,
|
||||
download_id,
|
||||
)
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [leaked_gid])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove leaked aria2 gid %s for download %s: %s",
|
||||
leaked_gid,
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
raise
|
||||
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||
except Exception as exc:
|
||||
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
||||
|
||||
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
|
||||
await self._state_store.upsert(
|
||||
download_id,
|
||||
{
|
||||
"gid": gid,
|
||||
"save_path": save_path,
|
||||
"status": "downloading",
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
return gid
|
||||
|
||||
async def _register_transfer(
|
||||
@@ -403,46 +372,7 @@ class Aria2Downloader:
|
||||
headers=headers,
|
||||
)
|
||||
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
||||
# Register the transfer before any further await: once the daemon
|
||||
# holds the gid, cancel_download() must be able to find it. An await
|
||||
# in between would open a window where a concurrent cancel reports
|
||||
# "Download task not found" and the daemon keeps downloading
|
||||
# untracked.
|
||||
self._transfers[download_id] = transfer
|
||||
try:
|
||||
await self._state_store.upsert(
|
||||
download_id,
|
||||
{
|
||||
"gid": gid,
|
||||
"save_path": transfer.save_path,
|
||||
"status": "downloading",
|
||||
"url": url,
|
||||
},
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# The task was cancelled while persisting state and the
|
||||
# coordinator's cancel ran before the transfer was registered
|
||||
# above. Remove the daemon transfer unless it was deliberately
|
||||
# paused (skip_download preserves paused transfers for resume).
|
||||
status = None
|
||||
try:
|
||||
status = await self.get_status(download_id)
|
||||
except Exception:
|
||||
status = None
|
||||
if status is not None and status.get("status") != "paused":
|
||||
try:
|
||||
await self._rpc_call("aria2.forceRemove", [gid])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to remove aria2 gid %s for cancelled download %s: %s",
|
||||
gid,
|
||||
download_id,
|
||||
exc,
|
||||
)
|
||||
current = self._transfers.get(download_id)
|
||||
if current is not None and current.gid == gid:
|
||||
self._transfers.pop(download_id, None)
|
||||
raise
|
||||
return transfer
|
||||
|
||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES, VALID_OTHER_SUB_TYPES
|
||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
||||
from ..utils.models import BaseModelMetadata
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..utils.usage_stats import UsageStats
|
||||
@@ -904,11 +904,6 @@ class BaseModelService(ABC):
|
||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
if (
|
||||
self.model_type == "other"
|
||||
and normalized_type not in VALID_OTHER_SUB_TYPES
|
||||
):
|
||||
continue
|
||||
|
||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||
|
||||
|
||||
@@ -410,10 +410,6 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve sub_type from the configured root that contains the file."""
|
||||
return self._resolve_sub_type(self._find_root_for_file(file_path))
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path)
|
||||
@@ -423,7 +419,9 @@ class CheckpointScanner(ModelScanner):
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
|
||||
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||
sub_type = self._resolve_sub_type(
|
||||
self._find_root_for_file(entry.get("file_path"))
|
||||
)
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
@@ -505,50 +505,6 @@ class CivitaiClient:
|
||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||
return None
|
||||
|
||||
async def get_version_file_mini(
|
||||
self, version_id: int, file_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch raw stored file info via the model-versions/mini endpoint.
|
||||
|
||||
The public REST API rewrites ``files[].name`` to
|
||||
``"{model}_{version}"`` for non-LoRA model types, so every
|
||||
precision variant of a multi-file version shares one name (#1100).
|
||||
The mini endpoint returns the raw ``ModelFile.name`` in
|
||||
``fileName``. ``file_id`` is mandatory: without it mini picks a
|
||||
file via its own primary-file logic, which can disagree with the
|
||||
REST ``primary`` flag.
|
||||
|
||||
Returns the mini payload dict on success, None on any failure.
|
||||
"""
|
||||
try:
|
||||
success, data = await self._make_request(
|
||||
"GET",
|
||||
f"{self.base_url}/model-versions/mini/{version_id}",
|
||||
params={"modelFileId": file_id},
|
||||
use_auth=True,
|
||||
)
|
||||
if success and isinstance(data, dict):
|
||||
return data
|
||||
if is_expected_offline_error(data):
|
||||
return None
|
||||
logger.debug(
|
||||
"Mini endpoint lookup failed for version %s file %s: %s",
|
||||
version_id,
|
||||
file_id,
|
||||
data,
|
||||
)
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Error fetching mini info for version %s file %s: %s",
|
||||
version_id,
|
||||
file_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
if not model_hash:
|
||||
return None
|
||||
|
||||
+26
-177
@@ -17,18 +17,13 @@ from dataclasses import dataclass, field
|
||||
import uuid
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||
from urllib.parse import urlparse
|
||||
from ..utils.models import (
|
||||
LoraMetadata,
|
||||
CheckpointMetadata,
|
||||
EmbeddingMetadata,
|
||||
OtherModelMetadata,
|
||||
)
|
||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||
from ..utils.constants import (
|
||||
CARD_PREVIEW_WIDTH,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
MODEL_WEIGHT_FILE_TYPES,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_LORA_TYPES,
|
||||
VALID_OTHER_CIVITAI_TYPES,
|
||||
)
|
||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||
@@ -37,11 +32,9 @@ from ..utils.utils import sanitize_folder_name
|
||||
from ..utils.exif_utils import ExifUtils
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from .service_registry import ServiceRegistry
|
||||
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
|
||||
from .settings_manager import get_settings_manager
|
||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||
from .errors import RateLimitError
|
||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||
from .aria2_transfer_state import Aria2TransferStateStore
|
||||
from .download_queue_service import DownloadQueueService
|
||||
@@ -234,21 +227,12 @@ class DownloadManager:
|
||||
return False
|
||||
|
||||
async def _get_scanner_for_model_type(self, model_type: str):
|
||||
"""Return the scanner responsible for the given model type.
|
||||
|
||||
Every supported type resolves explicitly — an unknown type must never
|
||||
fall through to the lora scanner (an "other" download would silently
|
||||
dedupe against the lora library).
|
||||
"""
|
||||
"""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()
|
||||
if model_type == "other":
|
||||
return await ServiceRegistry.get_other_scanner()
|
||||
if model_type == "lora":
|
||||
return await self._get_lora_scanner()
|
||||
raise ValueError(f'Unknown model type "{model_type}"')
|
||||
return await self._get_lora_scanner()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_target_file(
|
||||
@@ -945,42 +929,6 @@ class DownloadManager:
|
||||
|
||||
return download_urls
|
||||
|
||||
async def _fetch_raw_file_name(
|
||||
self,
|
||||
metadata_provider,
|
||||
version_id: Optional[int],
|
||||
file_id: Any,
|
||||
) -> Optional[str]:
|
||||
"""Best-effort lookup of the raw stored filename via the CivitAI
|
||||
model-versions/mini endpoint (#1100). Returns None on any failure so
|
||||
the caller can fall back to the (possibly rewritten) REST name."""
|
||||
if version_id is None or file_id is None:
|
||||
return None
|
||||
fetch = getattr(metadata_provider, "get_version_file_mini", None)
|
||||
if fetch is None:
|
||||
return None
|
||||
try:
|
||||
mini_info = await fetch(int(version_id), int(file_id))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
except RateLimitError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Mini endpoint lookup failed for version %s file %s: %s",
|
||||
version_id,
|
||||
file_id,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
if not isinstance(mini_info, dict):
|
||||
return None
|
||||
raw_name = mini_info.get("fileName")
|
||||
if not isinstance(raw_name, str) or not raw_name.strip():
|
||||
return None
|
||||
# Defensive: never let a path component slip into the filename.
|
||||
return os.path.basename(raw_name.strip()) or None
|
||||
|
||||
def _build_metadata_for_resume(
|
||||
self,
|
||||
*,
|
||||
@@ -993,8 +941,6 @@ class DownloadManager:
|
||||
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
if model_type == "embedding":
|
||||
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
if model_type == "other":
|
||||
return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||
|
||||
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
|
||||
@@ -1455,7 +1401,6 @@ class DownloadManager:
|
||||
lora_scanner = await self._get_lora_scanner()
|
||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
|
||||
# Check lora scanner first
|
||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||
@@ -1480,13 +1425,6 @@ class DownloadManager:
|
||||
"error": "Model version already exists in embedding library",
|
||||
}
|
||||
|
||||
# Check other scanner
|
||||
if await other_scanner.check_model_version_exists(model_version_id):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Model version already exists in other library",
|
||||
}
|
||||
|
||||
# Use CivArchive provider directly when source is 'civarchive'
|
||||
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
||||
if source == "civarchive":
|
||||
@@ -1525,20 +1463,6 @@ class DownloadManager:
|
||||
model_type = "lora"
|
||||
elif model_type_from_info == "textualinversion":
|
||||
model_type = "embedding"
|
||||
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
|
||||
if not get_settings_manager().is_other_models_enabled():
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"Other Models management is disabled. Enable it in "
|
||||
"Settings > Library before downloading VAE, upscaler, "
|
||||
"text encoder or CLIP files."
|
||||
),
|
||||
# Machine-readable failure code consumed by the companion
|
||||
# browser extension (docs/other-models-support.md C4).
|
||||
"reason": "other_models_disabled",
|
||||
}
|
||||
model_type = "other"
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -1660,13 +1584,27 @@ class DownloadManager:
|
||||
}
|
||||
|
||||
# Check if this checkpoint should be treated as a diffusion model
|
||||
# (shared with the download routing endpoint so the UI location
|
||||
# step and the actual download agree on the target roots).
|
||||
is_diffusion_model = is_diffusion_model_download(
|
||||
model_type,
|
||||
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||
base_model=base_model_value,
|
||||
)
|
||||
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||
is_diffusion_model = False
|
||||
if model_type == "checkpoint":
|
||||
# Check file types first (more direct signal from CivitAI)
|
||||
version_files = version_info.get("files", [])
|
||||
for f in version_files:
|
||||
f_type = f.get("type", "")
|
||||
if f_type in ("UNet", "Diffusion Model"):
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
||||
)
|
||||
break
|
||||
|
||||
# Fallback to baseModel name check
|
||||
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
||||
is_diffusion_model = True
|
||||
logger.info(
|
||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
||||
)
|
||||
|
||||
# Existence check after the metadata fetch (#1058):
|
||||
# - An explicit file selection only blocks when THIS file is
|
||||
@@ -1725,13 +1663,6 @@ class DownloadManager:
|
||||
"success": False,
|
||||
"error": "Model version already exists in embedding library",
|
||||
}
|
||||
elif model_type == "other":
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
if await other_scanner.check_model_version_exists(version_id):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Model version already exists in other library",
|
||||
}
|
||||
|
||||
# Handle use_default_paths
|
||||
if use_default_paths:
|
||||
@@ -1771,60 +1702,6 @@ class DownloadManager:
|
||||
"error": "Default embedding root path not set in settings",
|
||||
}
|
||||
save_dir = default_path
|
||||
elif model_type == "other":
|
||||
other_sub_type = resolve_other_download_sub_type(
|
||||
model_type_from_info,
|
||||
file_types=(
|
||||
f.get("type", "")
|
||||
for f in version_info.get("files", [])
|
||||
if isinstance(f, dict)
|
||||
),
|
||||
selected_file_type=(
|
||||
target_file.get("type") if explicit_file else None
|
||||
),
|
||||
)
|
||||
default_other_roots = (
|
||||
settings_manager.get("default_other_roots") or {}
|
||||
)
|
||||
if other_sub_type and not settings_manager.is_other_sub_type_enabled(
|
||||
other_sub_type
|
||||
):
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"Other-model sub-type '{other_sub_type}' is "
|
||||
f"disabled in settings. Please pick a destination "
|
||||
f"folder explicitly instead of using default paths."
|
||||
),
|
||||
"reason": "other_sub_type_disabled",
|
||||
}
|
||||
default_path = (
|
||||
default_other_roots.get(other_sub_type)
|
||||
if other_sub_type
|
||||
else None
|
||||
)
|
||||
if not isinstance(default_path, str) or not default_path:
|
||||
if other_sub_type:
|
||||
detail = (
|
||||
f"No default root configured for other-model "
|
||||
f"sub-type '{other_sub_type}'"
|
||||
)
|
||||
reason = "other_no_default_root"
|
||||
else:
|
||||
detail = (
|
||||
"Could not determine the other-model sub-type "
|
||||
"from the model metadata"
|
||||
)
|
||||
reason = "other_sub_type_undecidable"
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
f"{detail}. Please pick a destination folder "
|
||||
f"explicitly instead of using default paths."
|
||||
),
|
||||
"reason": reason,
|
||||
}
|
||||
save_dir = default_path
|
||||
|
||||
# Calculate relative path using template
|
||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||
@@ -1981,24 +1858,6 @@ class DownloadManager:
|
||||
if not download_urls:
|
||||
return {"success": False, "error": "No mirror URL found"}
|
||||
|
||||
# The public REST API rewrites files[].name to
|
||||
# "{model}_{version}" for non-LoRA model types, so every
|
||||
# precision variant of a multi-file version shares one name and
|
||||
# lands on disk with a random short-hash suffix. The mini
|
||||
# endpoint returns the raw stored filename (#1100). CivArchive
|
||||
# already serves raw names.
|
||||
if source != "civarchive":
|
||||
raw_file_name = await self._fetch_raw_file_name(
|
||||
metadata_provider, resolved_version_id, file_info.get("id")
|
||||
)
|
||||
if raw_file_name and raw_file_name != file_info.get("name"):
|
||||
logger.info(
|
||||
"[download] Using raw stored filename '%s' instead of REST name '%s'",
|
||||
raw_file_name,
|
||||
file_info.get("name"),
|
||||
)
|
||||
file_info = {**file_info, "name": raw_file_name}
|
||||
|
||||
# 3. Prepare download
|
||||
file_name = file_info.get("name", "")
|
||||
if not file_name:
|
||||
@@ -2021,11 +1880,6 @@ class DownloadManager:
|
||||
version_info, file_info, save_path
|
||||
)
|
||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
||||
elif model_type == "other":
|
||||
metadata = OtherModelMetadata.from_civitai_info(
|
||||
version_info, file_info, save_path
|
||||
)
|
||||
logger.info(f"Creating OtherModelMetadata for {file_name}")
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
@@ -2238,8 +2092,6 @@ class DownloadManager:
|
||||
scanner = await self._get_checkpoint_scanner()
|
||||
elif model_type == "embedding":
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
elif model_type == "other":
|
||||
scanner = await ServiceRegistry.get_other_scanner()
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||
|
||||
@@ -2736,9 +2588,6 @@ class DownloadManager:
|
||||
elif model_type == "embedding":
|
||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
||||
elif model_type == "other":
|
||||
scanner = await ServiceRegistry.get_other_scanner()
|
||||
logger.info(f"Updating other-model cache for {actual_file_paths[0]}")
|
||||
|
||||
adjust_cached_entry = (
|
||||
getattr(scanner, "adjust_cached_entry", None)
|
||||
@@ -2828,7 +2677,7 @@ class DownloadManager:
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||
if model_type in ("checkpoint", "other"):
|
||||
if model_type == "checkpoint":
|
||||
return {
|
||||
".ckpt",
|
||||
".pt",
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Shared download routing logic.
|
||||
|
||||
Decides whether a download initiated from the checkpoint library should be
|
||||
routed to the unet/diffusion-model roots instead of the checkpoint roots.
|
||||
Used by both the download manager (at download time) and the download
|
||||
routing HTTP endpoint (when the user picks a location in the UI), so the
|
||||
two can never disagree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Iterable, Optional
|
||||
|
||||
from ..utils.constants import (
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||
DIFFUSION_MODEL_BASE_MODELS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# File types reported by the CivitAI API that indicate a raw diffusion
|
||||
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
|
||||
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
|
||||
|
||||
|
||||
def is_diffusion_model_download(
|
||||
model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
base_model: str = "",
|
||||
) -> bool:
|
||||
"""Return True when a download should be routed to the unet roots.
|
||||
|
||||
Only applies to downloads initiated from the checkpoint library.
|
||||
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
|
||||
direct signal from CivitAI), (2) baseModel is a known diffusion model.
|
||||
"""
|
||||
if model_type != "checkpoint":
|
||||
return False
|
||||
|
||||
for file_type in file_types:
|
||||
if file_type in DIFFUSION_FILE_TYPES:
|
||||
logger.info(
|
||||
"File type '%s' detected, routing checkpoint to unet folder",
|
||||
file_type,
|
||||
)
|
||||
return True
|
||||
|
||||
if base_model in DIFFUSION_MODEL_BASE_MODELS:
|
||||
logger.info(
|
||||
"baseModel '%s' is a known diffusion model, routing to unet folder",
|
||||
base_model,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def resolve_other_download_sub_type(
|
||||
civitai_model_type: str,
|
||||
file_types: Iterable[str] = (),
|
||||
selected_file_type: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
"""Resolve the "other"-page sub_type for a download.
|
||||
|
||||
Fixed priority (locked design, docs/plans/other-models-page.md §9.2):
|
||||
|
||||
1. Explicit user file pick — when the picked file's type maps, it wins
|
||||
even when model.type maps to something else.
|
||||
2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE.
|
||||
3. file.type fallback — only when model.type maps to nothing. Must NOT
|
||||
override a mapped model.type: checkpoint models routinely bundle
|
||||
VAE/Text Encoder component files.
|
||||
4. Still undecidable -> None (caller must ask the user for a folder).
|
||||
"""
|
||||
if selected_file_type:
|
||||
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type)
|
||||
if mapped:
|
||||
logger.info(
|
||||
"Explicit file pick type '%s' routes other download to '%s'",
|
||||
selected_file_type,
|
||||
mapped,
|
||||
)
|
||||
return mapped
|
||||
|
||||
normalized_model_type = (civitai_model_type or "").strip().lower()
|
||||
mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type)
|
||||
if mapped:
|
||||
return mapped
|
||||
|
||||
for file_type in file_types:
|
||||
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type)
|
||||
if mapped:
|
||||
logger.info(
|
||||
"model.type '%s' unmapped; file type '%s' routes other download to '%s'",
|
||||
civitai_model_type,
|
||||
file_type,
|
||||
mapped,
|
||||
)
|
||||
return mapped
|
||||
|
||||
return None
|
||||
+49
-92
@@ -11,7 +11,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
@@ -33,26 +32,8 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
|
||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
# Monotonic timestamp of the last failed catalog fetch (None = no failure
|
||||
# yet). Failed fetches are negatively cached: further calls return the
|
||||
# empty fallback without hitting the network until the cooldown elapses,
|
||||
# so users on broken networks don't stall on every settings-modal open.
|
||||
_catalog_last_failure: Optional[float] = None
|
||||
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
|
||||
|
||||
# Serializes catalog fetches so concurrent callers don't duplicate requests.
|
||||
_catalog_lock = asyncio.Lock()
|
||||
|
||||
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||
|
||||
# Cloudflare serves brotli when the client advertises it, and brotli is a
|
||||
# required dependency here — a corrupted br stream can crash the native
|
||||
# decoder with a Windows access violation (issue #1099). Request gzip
|
||||
# instead; zlib decompression is not affected and corrupt gzip data only
|
||||
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
|
||||
# exception handlers below already catch.
|
||||
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
|
||||
|
||||
|
||||
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||
"""Fetch and parse the model catalog.
|
||||
@@ -65,85 +46,61 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
||||
in memory after the first successful fetch.
|
||||
Subsequent calls return the cached data immediately.
|
||||
|
||||
Failed fetches are negatively cached: further calls return an empty
|
||||
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
|
||||
has elapsed, so a broken network does not stall every settings-modal
|
||||
open. Concurrent callers are serialized behind :data:`_catalog_lock`
|
||||
so only one request is ever in flight.
|
||||
"""
|
||||
global _catalog_cache, _model_output_limits, _catalog_last_failure
|
||||
global _catalog_cache, _model_output_limits
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
|
||||
async with _catalog_lock:
|
||||
# Re-check under the lock: another caller may have fetched (or
|
||||
# failed) while we were waiting.
|
||||
if _catalog_cache is not None:
|
||||
return _catalog_cache
|
||||
if (
|
||||
_catalog_last_failure is not None
|
||||
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
||||
):
|
||||
logger.debug(
|
||||
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
||||
time.monotonic() - _catalog_last_failure,
|
||||
)
|
||||
return {}
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||
return _catalog_cache or {}
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||
return _catalog_cache or {}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
|
||||
if resp.status != 200:
|
||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
return _catalog_cache or {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||
_catalog_last_failure = time.monotonic()
|
||||
return {}
|
||||
|
||||
result: Dict[str, List[str]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
for provider_id, provider_info in data.items():
|
||||
if not isinstance(provider_info, dict):
|
||||
result: Dict[str, List[str]] = {}
|
||||
output_limits: Dict[str, Dict[str, int]] = {}
|
||||
for provider_id, provider_info in data.items():
|
||||
if not isinstance(provider_info, dict):
|
||||
continue
|
||||
models_dict = provider_info.get("models")
|
||||
if not isinstance(models_dict, dict):
|
||||
continue
|
||||
model_ids: List[str] = []
|
||||
provider_limits: Dict[str, int] = {}
|
||||
for mid, model_info in models_dict.items():
|
||||
if not isinstance(mid, str):
|
||||
continue
|
||||
models_dict = provider_info.get("models")
|
||||
if not isinstance(models_dict, dict):
|
||||
continue
|
||||
model_ids: List[str] = []
|
||||
provider_limits: Dict[str, int] = {}
|
||||
for mid, model_info in models_dict.items():
|
||||
if not isinstance(mid, str):
|
||||
continue
|
||||
model_ids.append(mid)
|
||||
if isinstance(model_info, dict):
|
||||
limit = model_info.get("limit")
|
||||
if isinstance(limit, dict):
|
||||
output = limit.get("output")
|
||||
if isinstance(output, (int, float)) and output > 0:
|
||||
provider_limits[mid] = int(output)
|
||||
if model_ids:
|
||||
result[provider_id] = model_ids
|
||||
if provider_limits:
|
||||
output_limits[provider_id] = provider_limits
|
||||
model_ids.append(mid)
|
||||
if isinstance(model_info, dict):
|
||||
limit = model_info.get("limit")
|
||||
if isinstance(limit, dict):
|
||||
output = limit.get("output")
|
||||
if isinstance(output, (int, float)) and output > 0:
|
||||
provider_limits[mid] = int(output)
|
||||
if model_ids:
|
||||
result[provider_id] = model_ids
|
||||
if provider_limits:
|
||||
output_limits[provider_id] = provider_limits
|
||||
|
||||
_catalog_cache = result
|
||||
_model_output_limits = output_limits
|
||||
logger.debug(
|
||||
"Loaded model catalog: %d providers, %d total models "
|
||||
"(%d providers have output limits)",
|
||||
len(result),
|
||||
sum(len(m) for m in result.values()),
|
||||
len(output_limits),
|
||||
)
|
||||
return result
|
||||
_catalog_cache = result
|
||||
_model_output_limits = output_limits
|
||||
logger.debug(
|
||||
"Loaded model catalog: %d providers, %d total models "
|
||||
"(%d providers have output limits)",
|
||||
len(result),
|
||||
sum(len(m) for m in result.values()),
|
||||
len(output_limits),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
||||
@@ -169,12 +126,12 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
|
||||
url = f"{api_base.rstrip('/')}/models"
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
||||
async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
|
||||
async with session.get(url) as resp:
|
||||
if resp.status != 200:
|
||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||
return []
|
||||
data = await resp.json()
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||
logger.debug("Ollama not reachable at %s: %s", api_base, exc)
|
||||
return []
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ class ModelCache:
|
||||
|
||||
raw_data: List[Dict[str, Any]]
|
||||
folders: List[str]
|
||||
# Every directory under the model roots (including empty ones), as
|
||||
# recorded by the last scan/hydration. ``None`` means "never recorded"
|
||||
# (e.g. a persisted snapshot predating this field) and triggers a
|
||||
# background filesystem backfill in the scanner.
|
||||
all_folders: Optional[List[str]] = None
|
||||
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
|
||||
|
||||
@@ -169,17 +169,6 @@ class ModelMetadataProvider(ABC):
|
||||
"""Published model count for the user; None when unsupported."""
|
||||
return None
|
||||
|
||||
async def get_version_file_mini(
|
||||
self, version_id: int, file_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Fetch raw stored file info via CivitAI's model-versions/mini endpoint.
|
||||
|
||||
Only the CivitAI provider implements this (#1100); other providers
|
||||
already serve raw file names (CivArchive) or cannot resolve this
|
||||
lookup (SQLite), so the default is None.
|
||||
"""
|
||||
return None
|
||||
|
||||
class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that uses Civitai API for metadata"""
|
||||
|
||||
@@ -214,11 +203,6 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||
return await self.client.get_creator_model_count(username)
|
||||
|
||||
async def get_version_file_mini(
|
||||
self, version_id: int, file_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self.client.get_version_file_mini(version_id, file_id)
|
||||
|
||||
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
||||
"""Provider that uses CivArchive API for metadata"""
|
||||
|
||||
@@ -716,37 +700,6 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
||||
continue
|
||||
return None
|
||||
|
||||
async def get_version_file_mini(
|
||||
self, version_id: int, file_id: int
|
||||
) -> 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,
|
||||
provider.get_version_file_mini,
|
||||
version_id,
|
||||
file_id,
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
except RateLimitError as exc:
|
||||
rate_limited = True
|
||||
logger.warning(
|
||||
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
|
||||
label,
|
||||
exc.retry_after or 0,
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Provider %s failed for get_version_file_mini: %s", label, e
|
||||
)
|
||||
continue
|
||||
return None
|
||||
|
||||
def _iter_providers(self):
|
||||
return zip(self.providers, self._provider_labels)
|
||||
|
||||
@@ -838,16 +791,6 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
|
||||
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||
return await self._provider.get_creator_model_count(username)
|
||||
|
||||
async def get_version_file_mini(
|
||||
self, version_id: int, file_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
return await self._rate_limit_helper.run(
|
||||
self._label,
|
||||
self._provider.get_version_file_mini,
|
||||
version_id,
|
||||
file_id,
|
||||
)
|
||||
|
||||
class ModelMetadataProviderManager:
|
||||
"""Manager for selecting and using model metadata providers"""
|
||||
|
||||
|
||||
+77
-256
@@ -62,13 +62,16 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
||||
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
|
||||
|
||||
# Maps a scanner model type to the manager page type used in progress
|
||||
# broadcasts (e.g. 'lora' -> 'loras').
|
||||
PAGE_TYPE_MAP = {
|
||||
'lora': 'loras',
|
||||
'checkpoint': 'checkpoints',
|
||||
'embedding': 'embeddings',
|
||||
'other': 'other',
|
||||
}
|
||||
|
||||
|
||||
@@ -86,10 +89,6 @@ class CacheBuildResult:
|
||||
hash_index: ModelHashIndex
|
||||
tags_count: Dict[str, int]
|
||||
excluded_models: List[str]
|
||||
# Every directory under the model roots (including empty ones) discovered
|
||||
# during the scan, or None when the source has no folder information
|
||||
# (e.g. a persisted snapshot predating folder recording).
|
||||
all_folders: Optional[List[str]] = None
|
||||
|
||||
class ModelScanner:
|
||||
"""Base service for scanning and managing model files"""
|
||||
@@ -145,9 +144,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
|
||||
# Guard against concurrent all-folders backfill walks (cold fallback
|
||||
# for persisted snapshots that predate folder recording).
|
||||
self._all_folders_backfill_running = False
|
||||
# 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:
|
||||
@@ -210,14 +208,8 @@ class ModelScanner:
|
||||
"""
|
||||
self._cache_version += 1
|
||||
|
||||
def on_library_changed(self, reconcile: bool = False) -> None:
|
||||
"""Reset caches when the active library changes.
|
||||
|
||||
When ``reconcile`` is True an incremental reconcile runs right after
|
||||
the cache is re-hydrated, so newly configured roots are scanned and
|
||||
entries for removed roots are purged. Used when scanner-affecting
|
||||
settings (e.g. the Other Models toggles) change.
|
||||
"""
|
||||
def on_library_changed(self) -> None:
|
||||
"""Reset caches when the active library changes."""
|
||||
self._persistent_cache = get_persistent_cache()
|
||||
self._cache = None
|
||||
self._hash_index = ModelHashIndex()
|
||||
@@ -225,6 +217,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:
|
||||
@@ -235,7 +228,7 @@ class ModelScanner:
|
||||
if loop and not loop.is_closed():
|
||||
self._loop = loop
|
||||
self.loop = loop
|
||||
loop.create_task(self.initialize_in_background(reconcile=reconcile))
|
||||
loop.create_task(self.initialize_in_background())
|
||||
|
||||
def _resolve_name_display_mode(self) -> str:
|
||||
"""Return the configured display mode for name sorting."""
|
||||
@@ -466,14 +459,8 @@ class ModelScanner:
|
||||
_, license_flags = resolve_license_info(license_source)
|
||||
entry['license_flags'] = license_flags
|
||||
|
||||
async def initialize_in_background(self, reconcile: bool = False) -> None:
|
||||
"""Initialize cache in background using thread pool
|
||||
|
||||
Args:
|
||||
reconcile: When True and a persisted snapshot is hydrated, run an
|
||||
incremental reconcile afterwards so the cache matches the
|
||||
current root configuration.
|
||||
"""
|
||||
async def initialize_in_background(self) -> None:
|
||||
"""Initialize cache in background using thread pool"""
|
||||
try:
|
||||
# Set initial empty cache to avoid None reference errors
|
||||
if self._cache is None:
|
||||
@@ -513,11 +500,6 @@ class ModelScanner:
|
||||
logger.info(
|
||||
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
|
||||
)
|
||||
if reconcile:
|
||||
# Root configuration changed (e.g. Other Models toggles):
|
||||
# pick up newly enabled folders and drop rows for folders
|
||||
# that are no longer managed.
|
||||
await self.get_cached_data(force_refresh=True)
|
||||
return
|
||||
|
||||
# Persistent load failed; fall back to a full scan
|
||||
@@ -680,33 +662,21 @@ class ModelScanner:
|
||||
if not persisted or not persisted.raw_data:
|
||||
return None
|
||||
|
||||
# Drop entries the scanner no longer manages (e.g. an other-model
|
||||
# sub_type the user just disabled) before rebuilding the indexes, so
|
||||
# hash/autov3 lookups cannot resolve to unmanaged files either.
|
||||
kept_items = [
|
||||
item
|
||||
for item in persisted.raw_data
|
||||
if self._should_keep_cached_entry(item)
|
||||
]
|
||||
kept_paths = {
|
||||
item.get("file_path") for item in kept_items if item.get("file_path")
|
||||
}
|
||||
|
||||
hash_index = ModelHashIndex()
|
||||
for sha_value, path in persisted.hash_rows:
|
||||
if sha_value and path and path in kept_paths:
|
||||
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 and path in kept_paths:
|
||||
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 kept_items:
|
||||
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.
|
||||
@@ -732,8 +702,7 @@ class ModelScanner:
|
||||
raw_data=valid_entries,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=list(persisted.excluded_models),
|
||||
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
|
||||
excluded_models=list(persisted.excluded_models)
|
||||
)
|
||||
return scan_result, invalid_entries
|
||||
|
||||
@@ -768,7 +737,6 @@ class ModelScanner:
|
||||
hash_snapshot,
|
||||
list(scan_result.excluded_models),
|
||||
autov3_snapshot,
|
||||
scan_result.all_folders,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
||||
@@ -816,12 +784,7 @@ class ModelScanner:
|
||||
raw_data=list(self._cache.raw_data),
|
||||
hash_index=self._hash_index,
|
||||
tags_count=dict(self._tags_count),
|
||||
excluded_models=list(self._excluded_models),
|
||||
all_folders=(
|
||||
list(self._cache.all_folders)
|
||||
if self._cache.all_folders is not None
|
||||
else None
|
||||
),
|
||||
excluded_models=list(self._excluded_models)
|
||||
)
|
||||
await self._save_persistent_cache(snapshot)
|
||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||
@@ -1042,36 +1005,20 @@ class ModelScanner:
|
||||
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
||||
|
||||
# Get current cached file paths
|
||||
cached_size_before = len(self._cache.raw_data)
|
||||
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
||||
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
|
||||
|
||||
# physical path -> cached business path, for the alias case where the
|
||||
# same file is reachable under a different path than the cached one
|
||||
# (overlapping roots / symlink layout changes): keep the existing
|
||||
# entry instead of delete + re-add (which would re-read metadata and
|
||||
# re-hash every file). Built lazily on the first miss, because a
|
||||
# realpath per cached entry is ~half the cost of a no-change
|
||||
# reconcile and the map is only ever consulted for misses.
|
||||
cached_real_paths: Optional[Dict[str, str]] = None
|
||||
|
||||
def lookup_cached_real_path(real_path: str) -> Optional[str]:
|
||||
nonlocal cached_real_paths
|
||||
if cached_real_paths is None:
|
||||
cached_real_paths = {}
|
||||
for cached_path in cached_paths:
|
||||
try:
|
||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||
except Exception:
|
||||
continue
|
||||
return cached_real_paths.get(real_path)
|
||||
cached_real_paths = {}
|
||||
for cached_path in cached_paths:
|
||||
try:
|
||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Track found files and new files
|
||||
found_paths = set()
|
||||
new_files = []
|
||||
visited_real_paths = set()
|
||||
discovered_real_files = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
|
||||
# Scan all model roots
|
||||
for root_path in self.get_model_roots():
|
||||
@@ -1086,31 +1033,19 @@ class ModelScanner:
|
||||
continue
|
||||
visited_real_paths.add(real_root)
|
||||
|
||||
# Record every visited directory (including empty ones) so
|
||||
# the folder tree stays accurate without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
os.path.abspath(root), os.path.abspath(root_path)
|
||||
).replace(os.path.sep, "/")
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered_folders.add(rel_dir)
|
||||
|
||||
for file in files:
|
||||
ext = os.path.splitext(file)[1].lower()
|
||||
if ext in self.file_extensions:
|
||||
# Construct paths exactly as they would be in cache
|
||||
file_path = os.path.join(root, file).replace(os.sep, '/')
|
||||
|
||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||
|
||||
# Check if this file is already in cache
|
||||
if file_path in cached_paths:
|
||||
found_paths.add(file_path)
|
||||
continue
|
||||
|
||||
# Only a cache miss needs the physical path, so the
|
||||
# realpath syscalls are paid per changed file rather
|
||||
# than per file in the library.
|
||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||
|
||||
cached_real_match = lookup_cached_real_path(real_file_path)
|
||||
cached_real_match = cached_real_paths.get(real_file_path)
|
||||
if cached_real_match:
|
||||
found_paths.add(cached_real_match)
|
||||
continue
|
||||
@@ -1155,9 +1090,6 @@ class ModelScanner:
|
||||
total_new = len(new_files)
|
||||
processed_new = 0
|
||||
last_progress_time = time.time()
|
||||
# Snapshot the roots once: this matches the walk above (which
|
||||
# also snapshots them) and avoids a config read per new file.
|
||||
model_roots = self.get_model_roots()
|
||||
for i in range(0, total_new, batch_size):
|
||||
batch = new_files[i:i+batch_size]
|
||||
for path in batch:
|
||||
@@ -1166,10 +1098,12 @@ class ModelScanner:
|
||||
try:
|
||||
# Find the appropriate root path for this file
|
||||
root_path = None
|
||||
normalized_path = os.path.normpath(path)
|
||||
model_roots = self.get_model_roots()
|
||||
for potential_root in model_roots:
|
||||
# Normalize both paths for comparison
|
||||
if normalized_path.startswith(os.path.normpath(potential_root)):
|
||||
normalized_path = os.path.normpath(path)
|
||||
normalized_root = os.path.normpath(potential_root)
|
||||
if normalized_path.startswith(normalized_root):
|
||||
root_path = potential_root
|
||||
break
|
||||
|
||||
@@ -1266,41 +1200,25 @@ class ModelScanner:
|
||||
# Update cache data
|
||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||
|
||||
# Defensive integrity pass: drop entries sharing a business path.
|
||||
# Duplicates can only be introduced by external code rewriting
|
||||
# raw_data directly or by this pass's own appends, so an unchanged
|
||||
# filesystem walk over a clean cache has nothing to clean. The size
|
||||
# mismatch is an O(1) tell that the snapshot already contained
|
||||
# duplicates; skipping the O(N) pass when it is provably clean is
|
||||
# what keeps a no-change Refresh cheap.
|
||||
if cached_size_before != len(cached_paths) or total_added > 0:
|
||||
dedup_removed = 0
|
||||
seen_paths: set[str] = set()
|
||||
deduped: list[Dict[str, Any]] = []
|
||||
for item in reversed(self._cache.raw_data):
|
||||
path = item.get('file_path', '')
|
||||
if path not in seen_paths:
|
||||
seen_paths.add(path)
|
||||
deduped.append(item)
|
||||
else:
|
||||
for tag in item.get('tags', []):
|
||||
if tag in self._tags_count:
|
||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||
if self._tags_count[tag] == 0:
|
||||
del self._tags_count[tag]
|
||||
dedup_removed += 1
|
||||
if dedup_removed > 0:
|
||||
self._cache.raw_data = list(reversed(deduped))
|
||||
total_removed += dedup_removed
|
||||
dedup_removed = 0
|
||||
seen_paths: set[str] = set()
|
||||
deduped: list[Dict[str, Any]] = []
|
||||
for item in reversed(self._cache.raw_data):
|
||||
path = item.get('file_path', '')
|
||||
if path not in seen_paths:
|
||||
seen_paths.add(path)
|
||||
deduped.append(item)
|
||||
else:
|
||||
for tag in item.get('tags', []):
|
||||
if tag in self._tags_count:
|
||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||
if self._tags_count[tag] == 0:
|
||||
del self._tags_count[tag]
|
||||
dedup_removed += 1
|
||||
if dedup_removed > 0:
|
||||
self._cache.raw_data = list(reversed(deduped))
|
||||
total_removed += dedup_removed
|
||||
|
||||
# The walk above visited every directory, so refresh the recorded
|
||||
# folder list (including empty folders) even when no model files
|
||||
# changed — e.g. an empty folder was created or removed externally.
|
||||
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
||||
folders_changed = self._cache.all_folders != sorted_discovered
|
||||
if folders_changed:
|
||||
self._cache.all_folders = sorted_discovered
|
||||
|
||||
# Resort cache if changes were made
|
||||
if total_added > 0 or total_removed > 0:
|
||||
# Update folders list
|
||||
@@ -1313,8 +1231,6 @@ class ModelScanner:
|
||||
await self._cache.resort()
|
||||
|
||||
await self._persist_current_cache()
|
||||
elif folders_changed:
|
||||
await self._persist_current_cache()
|
||||
|
||||
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
|
||||
await self._broadcast_scan_progress(
|
||||
@@ -1354,73 +1270,22 @@ class ModelScanner:
|
||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||
|
||||
async def get_all_folders(self) -> List[str]:
|
||||
"""Return every known directory under the model roots.
|
||||
|
||||
The directory list (including empty ones) is recorded during cache
|
||||
scans and hydrated from the persisted snapshot, so this is a pure
|
||||
in-memory read — no filesystem walk ever runs on the event loop
|
||||
(walking network roots synchronously used to freeze the whole
|
||||
server, see issue #1110). The result is unioned with the
|
||||
model-derived folders so it is always a superset of
|
||||
``cache.folders``.
|
||||
|
||||
Cold fallback: when the cache was hydrated from a persisted snapshot
|
||||
that predates folder recording (``all_folders is None``), a one-shot
|
||||
background walk is scheduled off the event loop to backfill and
|
||||
persist the list; until it lands, the models-only folders are
|
||||
returned.
|
||||
"""
|
||||
folders: Set[str] = set()
|
||||
cache = self._cache
|
||||
if cache is not None:
|
||||
folders |= {item.get('folder', '') for item in cache.raw_data}
|
||||
recorded = getattr(cache, 'all_folders', None)
|
||||
if recorded is None:
|
||||
self._schedule_all_folders_backfill()
|
||||
else:
|
||||
folders |= set(recorded)
|
||||
else:
|
||||
self._schedule_all_folders_backfill()
|
||||
|
||||
return sorted(folders, key=lambda x: x.lower())
|
||||
|
||||
def _schedule_all_folders_backfill(self) -> None:
|
||||
"""Kick off a one-shot background folder walk if none is running."""
|
||||
if self._all_folders_backfill_running:
|
||||
return
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
self._all_folders_backfill_running = True
|
||||
loop.create_task(self._run_all_folders_backfill())
|
||||
|
||||
async def _run_all_folders_backfill(self) -> None:
|
||||
"""Walk the roots in a worker thread, then record and persist the result."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
folders = await loop.run_in_executor(None, self._walk_all_folders_sync)
|
||||
cache = self._cache
|
||||
# A scan may have recorded the list while the walk was in flight;
|
||||
# prefer the fresher scan data in that case.
|
||||
if cache is not None and cache.all_folders is None:
|
||||
cache.all_folders = folders
|
||||
await self._persist_current_cache()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"%s Scanner: all-folders backfill failed: %s",
|
||||
self.model_type.capitalize(),
|
||||
exc,
|
||||
)
|
||||
finally:
|
||||
self._all_folders_backfill_running = False
|
||||
|
||||
def _walk_all_folders_sync(self) -> List[str]:
|
||||
"""Enumerate every directory under the model roots, live from disk.
|
||||
|
||||
Runs in a worker thread. Hidden directories (any segment starting
|
||||
with '.') and the pending-delete staging dir are excluded.
|
||||
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()
|
||||
|
||||
@@ -1442,7 +1307,17 @@ class ModelScanner:
|
||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||
discovered.add(rel_dir)
|
||||
|
||||
return sorted(discovered, key=lambda x: x.lower())
|
||||
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)"""
|
||||
@@ -1464,23 +1339,6 @@ class ModelScanner:
|
||||
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
||||
return entry
|
||||
|
||||
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Hook for subclasses: decide whether a persisted entry is still managed.
|
||||
|
||||
Entries rejected here are dropped (with their hash/autov3 index rows)
|
||||
while hydrating the persisted cache, so a scanner whose configured
|
||||
roots shrank does not surface stale models before the next reconcile.
|
||||
"""
|
||||
return True
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Hook for subclasses: resolve the location-derived sub_type for a file.
|
||||
|
||||
Returns ``None`` when the model type has no location-derived sub-types
|
||||
(the default), in which case any stored value is left untouched.
|
||||
"""
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _normalize_path_value(path: Optional[str]) -> str:
|
||||
if not path:
|
||||
@@ -1675,9 +1533,6 @@ class ModelScanner:
|
||||
else:
|
||||
self._cache.raw_data = list(scan_result.raw_data)
|
||||
|
||||
if scan_result.all_folders is not None:
|
||||
self._cache.all_folders = list(scan_result.all_folders)
|
||||
|
||||
# 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()
|
||||
@@ -1775,7 +1630,6 @@ class ModelScanner:
|
||||
processed_files = 0
|
||||
processed_real_files: Set[str] = set()
|
||||
visited_real_dirs: Set[str] = set()
|
||||
discovered_folders: Set[str] = set()
|
||||
|
||||
async def handle_progress(current_name: str = '') -> None:
|
||||
if progress_callback is None:
|
||||
@@ -1854,13 +1708,6 @@ class ModelScanner:
|
||||
elif entry.is_dir(follow_symlinks=True):
|
||||
if _is_excluded_dir(entry.name):
|
||||
continue
|
||||
# Record every directory (including empty ones) so
|
||||
# the folder tree can be served without a live walk.
|
||||
rel_dir = os.path.relpath(
|
||||
os.path.abspath(entry.path), os.path.abspath(root_path)
|
||||
).replace(os.path.sep, "/")
|
||||
if not _is_hidden_relative_path(rel_dir):
|
||||
discovered_folders.add(rel_dir)
|
||||
await scan_recursive(entry.path, root_path, visited_paths)
|
||||
except Exception as entry_error:
|
||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||
@@ -1880,8 +1727,7 @@ class ModelScanner:
|
||||
raw_data=raw_data,
|
||||
hash_index=hash_index,
|
||||
tags_count=tags_count,
|
||||
excluded_models=excluded_models,
|
||||
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
|
||||
excluded_models=excluded_models
|
||||
)
|
||||
|
||||
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
||||
@@ -2023,20 +1869,6 @@ class ModelScanner:
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving metadata file: {e}")
|
||||
|
||||
if metadata is not None:
|
||||
# sub_type is derived from the model's location (e.g. a file
|
||||
# moved from a checkpoints root into a unet root becomes a
|
||||
# diffusion_model). Persist the recalculated value into the
|
||||
# moved metadata file so later metadata-driven cache syncs
|
||||
# do not revert the cache entry to the stale sub_type.
|
||||
new_sub_type = self.resolve_sub_type_for_path(target_file)
|
||||
if new_sub_type and metadata.get('sub_type') != new_sub_type:
|
||||
metadata['sub_type'] = new_sub_type
|
||||
try:
|
||||
await MetadataManager.save_metadata(moved_metadata_path, metadata)
|
||||
except Exception as e:
|
||||
logger.error(f"Error persisting sub_type for moved model: {e}")
|
||||
|
||||
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
||||
|
||||
return {
|
||||
@@ -2138,16 +1970,6 @@ class ModelScanner:
|
||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||
|
||||
# The move target may live in directories the last scan never saw;
|
||||
# record the destination folder (and its parents) in the known
|
||||
# folder list so the folder tree reflects it without a rescan.
|
||||
if cache.all_folders is not None and folder_value:
|
||||
parts = folder_value.split("/")
|
||||
known = set(cache.all_folders)
|
||||
for i in range(1, len(parts) + 1):
|
||||
known.add("/".join(parts[:i]))
|
||||
cache.all_folders = sorted(known, key=lambda x: x.lower())
|
||||
|
||||
for tag in cache_entry.get('tags', []):
|
||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||
|
||||
@@ -2155,6 +1977,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()
|
||||
@@ -2238,11 +2064,6 @@ class ModelScanner:
|
||||
file_path_override=file_path,
|
||||
)
|
||||
|
||||
# Location-derived fields (e.g. the checkpoint sub_type) must be
|
||||
# re-resolved from the file path rather than trusting the on-disk
|
||||
# metadata snapshot, which may predate a cross-root move.
|
||||
desired_entry = self.adjust_cached_entry(desired_entry)
|
||||
|
||||
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||
if (
|
||||
not desired_entry.get("sha256")
|
||||
|
||||
@@ -118,24 +118,19 @@ class ModelServiceFactory:
|
||||
|
||||
|
||||
def register_default_model_types():
|
||||
"""Register the default model types (LoRA, Checkpoint, Embedding, and Other)"""
|
||||
"""Register the default model types (LoRA, Checkpoint, and Embedding)"""
|
||||
from ..services.lora_service import LoraService
|
||||
from ..services.checkpoint_service import CheckpointService
|
||||
from ..services.embedding_service import EmbeddingService
|
||||
from ..services.other_model_service import OtherModelService
|
||||
from ..routes.lora_routes import LoraRoutes
|
||||
from ..routes.checkpoint_routes import CheckpointRoutes
|
||||
from ..routes.embedding_routes import EmbeddingRoutes
|
||||
from ..routes.other_routes import OtherRoutes
|
||||
|
||||
|
||||
# Register LoRA model type
|
||||
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
||||
|
||||
|
||||
# Register Checkpoint model type
|
||||
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
|
||||
|
||||
|
||||
# Register Embedding model type
|
||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||
|
||||
# Register Other model type (VAE, upscaler, text encoder, ...)
|
||||
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
|
||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||
@@ -1,79 +0,0 @@
|
||||
import os
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .base_model_service import BaseModelService
|
||||
from .auto_tag_service import extract_auto_tags
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..config import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtherModelService(BaseModelService):
|
||||
"""Other-model-specific service implementation (VAE, upscaler, text encoder, ...)"""
|
||||
|
||||
def __init__(self, scanner, update_service=None):
|
||||
"""Initialize Other-model service
|
||||
|
||||
Args:
|
||||
scanner: Other-model scanner instance
|
||||
update_service: Optional service for remote update tracking.
|
||||
"""
|
||||
super().__init__("other", scanner, OtherModelMetadata, update_service=update_service)
|
||||
|
||||
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Format other-model data for API response.
|
||||
|
||||
Returns None when the entry is missing critical fields (corrupted cache
|
||||
row), so the handler layer can filter it out. See issue #730.
|
||||
"""
|
||||
# Guard against corrupted cache entries missing critical fields
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path or not isinstance(file_path, str):
|
||||
logger.warning(
|
||||
"Skipping corrupted other-model entry (missing file_path): %s",
|
||||
model_data.get("file_name", "<unknown>"),
|
||||
)
|
||||
return None
|
||||
|
||||
# Get sub_type from cache entry (new canonical field)
|
||||
sub_type = model_data.get("sub_type", "vae")
|
||||
|
||||
file_name = model_data.get("file_name") or ""
|
||||
model_name = model_data.get("model_name") or file_name
|
||||
folder = model_data.get("folder") or ""
|
||||
|
||||
return {
|
||||
"model_name": model_name,
|
||||
"file_name": file_name,
|
||||
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
|
||||
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
|
||||
"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", ""),
|
||||
"tags": model_data.get("tags", []),
|
||||
"from_civitai": model_data.get("from_civitai", True),
|
||||
"notes": model_data.get("notes", ""),
|
||||
"sub_type": sub_type,
|
||||
"favorite": model_data.get("favorite", False),
|
||||
"exclude": bool(model_data.get("exclude", False)),
|
||||
"update_available": bool(model_data.get("update_available", False)),
|
||||
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
|
||||
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||
"version_count": model_data.get("version_count"),
|
||||
"hf_url": model_data.get("hf_url", ""),
|
||||
}
|
||||
|
||||
def find_duplicate_hashes(self) -> Dict[str, Any]:
|
||||
"""Find other models with duplicate SHA256 hashes"""
|
||||
return self.scanner._hash_index.get_duplicate_hashes()
|
||||
|
||||
def find_duplicate_filenames(self) -> Dict[str, Any]:
|
||||
"""Find other models with conflicting filenames"""
|
||||
return self.scanner._hash_index.get_duplicate_filenames()
|
||||
@@ -1,478 +0,0 @@
|
||||
# pyright: reportImportCycles=false
|
||||
# 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 asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from ..utils.models import OtherModelMetadata
|
||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||
from ..utils.metadata_manager import MetadataManager
|
||||
from ..config import config
|
||||
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||
from .model_hash_index import ModelHashIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OtherScanner(ModelScanner):
|
||||
"""Service for scanning and managing "other" model files.
|
||||
|
||||
Aggregates every enabled folder_paths category from
|
||||
OTHER_MODEL_FOLDER_SUBTYPES (VAE, upscalers, text encoders, CLIP vision,
|
||||
opt-in ControlNet) into one scanner; sub_type is derived from the root
|
||||
containing the file (mirrors CheckpointScanner's checkpoints/unet split).
|
||||
|
||||
Hashing is lazy (checkpoint-style): text encoders can be ~10 GB, so the
|
||||
initial scan records hash_status="pending" and the SHA256 is computed
|
||||
on-demand via calculate_hash_for_model (e.g. when fetching CivitAI
|
||||
metadata).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
# Same extension set as CheckpointScanner (ComfyUI's
|
||||
# supported_pt_extensions plus ".gguf").
|
||||
file_extensions = {
|
||||
".ckpt",
|
||||
".pt",
|
||||
".pt2",
|
||||
".bin",
|
||||
".pth",
|
||||
".safetensors",
|
||||
".pkl",
|
||||
".sft",
|
||||
".gguf",
|
||||
}
|
||||
super().__init__(
|
||||
model_type="other",
|
||||
model_class=OtherModelMetadata,
|
||||
file_extensions=file_extensions,
|
||||
hash_index=ModelHashIndex(),
|
||||
)
|
||||
if not hasattr(self, "_hash_calculation_lock"):
|
||||
self._hash_calculation_lock = asyncio.Lock()
|
||||
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
|
||||
|
||||
async def _create_default_metadata(
|
||||
self, file_path: str
|
||||
) -> Optional[OtherModelMetadata]:
|
||||
"""Create default metadata without calculating hash (lazy hash).
|
||||
|
||||
Other models include multi-GB text encoders, so hash calculation is
|
||||
deferred until on-demand (e.g. CivitAI metadata fetch).
|
||||
"""
|
||||
try:
|
||||
real_path = os.path.realpath(file_path)
|
||||
if not os.path.exists(real_path):
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return None
|
||||
|
||||
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||
dir_path = os.path.dirname(file_path)
|
||||
|
||||
# Find preview image
|
||||
preview_url = find_preview_file(base_name, dir_path)
|
||||
|
||||
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||
# large files; record the checked state at creation time ("" =
|
||||
# checked but unavailable).
|
||||
autov3 = calculate_autov3(real_path)
|
||||
|
||||
# Create metadata WITHOUT calculating hash
|
||||
metadata = OtherModelMetadata(
|
||||
file_name=base_name,
|
||||
model_name=base_name,
|
||||
file_path=normalize_path(file_path),
|
||||
size=os.path.getsize(real_path),
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256="", # Empty hash - will be calculated on-demand
|
||||
base_model="Unknown",
|
||||
preview_url=normalize_path(preview_url),
|
||||
tags=[],
|
||||
modelDescription="",
|
||||
sub_type=self.resolve_sub_type_for_path(file_path) or "vae",
|
||||
from_civitai=False, # Mark as local model since no hash yet
|
||||
hash_status="pending", # Mark hash as pending
|
||||
autov3=autov3 or "",
|
||||
)
|
||||
|
||||
# Save the created metadata
|
||||
logger.info(f"Creating other-model metadata (hash pending) for {file_path}")
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
return metadata
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error creating default other-model metadata for {file_path}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
||||
"""Calculate hash for a model on-demand with per-file singleflight.
|
||||
|
||||
Args:
|
||||
file_path: Path to the model file
|
||||
|
||||
Returns:
|
||||
SHA256 hash string, or None if calculation failed
|
||||
"""
|
||||
try:
|
||||
real_path = os.path.realpath(file_path)
|
||||
if not os.path.exists(real_path):
|
||||
logger.error(f"File not found for hash calculation: {file_path}")
|
||||
return None
|
||||
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if (
|
||||
metadata is not None
|
||||
and metadata.hash_status == "completed"
|
||||
and metadata.sha256
|
||||
):
|
||||
# Ensure the in-memory hash index is populated even when
|
||||
# the hash was already computed and persisted to the metadata
|
||||
# file. Without this, usage tracking (and any other caller
|
||||
# that queries get_hash_by_filename first) will miss on every
|
||||
# lookup and keep calling back into this method, creating a
|
||||
# tight loop that never populates the index.
|
||||
self._hash_index.add_entry(
|
||||
metadata.sha256.lower(),
|
||||
file_path,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
return metadata.sha256
|
||||
|
||||
async with self._hash_calculation_lock:
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if (
|
||||
metadata is not None
|
||||
and metadata.hash_status == "completed"
|
||||
and metadata.sha256
|
||||
):
|
||||
self._hash_index.add_entry(
|
||||
metadata.sha256.lower(),
|
||||
file_path,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
return metadata.sha256
|
||||
|
||||
task = self._hash_calculation_tasks.get(real_path)
|
||||
if task is None:
|
||||
task = asyncio.create_task(
|
||||
self._run_hash_calculation_task(file_path, real_path)
|
||||
)
|
||||
self._hash_calculation_tasks[real_path] = task
|
||||
|
||||
return await asyncio.shield(task)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||
return None
|
||||
|
||||
async def _run_hash_calculation_task(
|
||||
self, file_path: str, real_path: str
|
||||
) -> Optional[str]:
|
||||
"""Run a hash calculation task and remove it from the in-flight map."""
|
||||
try:
|
||||
return await self._calculate_hash_for_model_uncached(file_path, real_path)
|
||||
finally:
|
||||
task = asyncio.current_task()
|
||||
async with self._hash_calculation_lock:
|
||||
if self._hash_calculation_tasks.get(real_path) is task:
|
||||
del self._hash_calculation_tasks[real_path]
|
||||
|
||||
async def _calculate_hash_for_model_uncached(
|
||||
self, file_path: str, real_path: str
|
||||
) -> Optional[str]:
|
||||
"""Calculate hash for a model without checking in-flight tasks."""
|
||||
from ..utils.file_utils import calculate_sha256
|
||||
|
||||
try:
|
||||
# Load current metadata
|
||||
metadata, should_skip = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata is None:
|
||||
if should_skip:
|
||||
logger.error(f"Invalid metadata found for {file_path}")
|
||||
return None
|
||||
created_metadata = await self._create_default_metadata(file_path)
|
||||
if created_metadata is None:
|
||||
logger.error(f"No metadata found for {file_path}")
|
||||
return None
|
||||
metadata = created_metadata
|
||||
|
||||
# Check if hash is already calculated
|
||||
if metadata.hash_status == "completed" and metadata.sha256:
|
||||
# Populate the in-memory hash index even for pre-computed
|
||||
# hashes, mirroring the fix in calculate_hash_for_model.
|
||||
self._hash_index.add_entry(
|
||||
metadata.sha256.lower(),
|
||||
file_path,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
return metadata.sha256
|
||||
|
||||
# Update status to calculating
|
||||
metadata.hash_status = "calculating"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
# Calculate hash
|
||||
logger.info(f"Calculating hash for other model: {file_path}")
|
||||
sha256 = await calculate_sha256(real_path)
|
||||
|
||||
# Update metadata with hash
|
||||
metadata.sha256 = sha256
|
||||
metadata.hash_status = "completed"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
|
||||
# Update hash index
|
||||
self._hash_index.add_entry(
|
||||
sha256.lower(),
|
||||
file_path,
|
||||
getattr(metadata, "autov3", None) or None,
|
||||
)
|
||||
|
||||
# Update the in-memory cache entry so that subsequent
|
||||
# _persist_current_cache / _save_persistent_cache calls
|
||||
# write the hash back to the SQLite models table. Without
|
||||
# this the hash only lives in the metadata file and the
|
||||
# in-memory hash index, both of which are lost across
|
||||
# restarts, causing the same re-computation loop on the
|
||||
# next session.
|
||||
if self._cache is not None and self._cache.raw_data:
|
||||
for entry in self._cache.raw_data:
|
||||
if entry.get("file_path") == file_path:
|
||||
entry["sha256"] = sha256.lower()
|
||||
entry["hash_status"] = "completed"
|
||||
self.bump_cache_version()
|
||||
break
|
||||
|
||||
logger.info(f"Hash calculated for other model: {file_path}")
|
||||
return sha256
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||
# Update status to failed
|
||||
try:
|
||||
metadata, _ = await MetadataManager.load_metadata(
|
||||
file_path, self.model_class
|
||||
)
|
||||
if metadata:
|
||||
metadata.hash_status = "failed"
|
||||
await MetadataManager.save_metadata(file_path, metadata)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
async def calculate_all_pending_hashes(
|
||||
self, progress_callback=None
|
||||
) -> Dict[str, int]:
|
||||
"""Calculate hashes for all other models with pending hash status.
|
||||
|
||||
If cache is not initialized, scans filesystem directly for metadata files
|
||||
with hash_status != 'completed'.
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback(progress, total, current_file)
|
||||
|
||||
Returns:
|
||||
Dict with 'completed', 'failed', 'total' counts
|
||||
"""
|
||||
# Try to get from cache first
|
||||
cache = await self.get_cached_data()
|
||||
|
||||
if cache and cache.raw_data:
|
||||
# Use cache if available
|
||||
pending_models = [
|
||||
item
|
||||
for item in cache.raw_data
|
||||
if item.get("hash_status") != "completed" or not item.get("sha256")
|
||||
]
|
||||
else:
|
||||
# Cache not initialized, scan filesystem directly
|
||||
pending_models = await self._find_pending_models_from_filesystem()
|
||||
|
||||
if not pending_models:
|
||||
return {"completed": 0, "failed": 0, "total": 0}
|
||||
|
||||
total = len(pending_models)
|
||||
completed = 0
|
||||
failed = 0
|
||||
|
||||
for i, model_data in enumerate(pending_models):
|
||||
file_path = model_data.get("file_path")
|
||||
if not file_path:
|
||||
continue
|
||||
|
||||
try:
|
||||
sha256 = await self.calculate_hash_for_model(file_path)
|
||||
if sha256:
|
||||
completed += 1
|
||||
else:
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||
failed += 1
|
||||
|
||||
if progress_callback:
|
||||
try:
|
||||
await progress_callback(i + 1, total, file_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"completed": completed, "failed": failed, "total": total}
|
||||
|
||||
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||
"""Scan filesystem for other-model metadata files with pending hash status."""
|
||||
pending_models = []
|
||||
|
||||
for root_path in self.get_model_roots():
|
||||
if not os.path.exists(root_path):
|
||||
continue
|
||||
|
||||
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||
for filename in filenames:
|
||||
if not filename.endswith(".metadata.json"):
|
||||
continue
|
||||
|
||||
metadata_path = os.path.join(dirpath, filename)
|
||||
try:
|
||||
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Check if hash is pending
|
||||
hash_status = data.get("hash_status", "completed")
|
||||
sha256 = data.get("sha256", "")
|
||||
|
||||
if hash_status != "completed" or not sha256:
|
||||
# Find corresponding model file
|
||||
model_name = filename.replace(".metadata.json", "")
|
||||
model_path = None
|
||||
|
||||
# Look for model file with matching name
|
||||
for ext in self.file_extensions:
|
||||
potential_path = os.path.join(dirpath, model_name + ext)
|
||||
if os.path.exists(potential_path):
|
||||
model_path = potential_path
|
||||
break
|
||||
|
||||
if model_path:
|
||||
pending_models.append(
|
||||
{
|
||||
"file_path": model_path.replace(os.sep, "/"),
|
||||
"hash_status": hash_status,
|
||||
"sha256": sha256,
|
||||
**{
|
||||
k: v
|
||||
for k, v in data.items()
|
||||
if k
|
||||
not in [
|
||||
"file_path",
|
||||
"hash_status",
|
||||
"sha256",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
logger.debug(
|
||||
f"Error reading metadata file {metadata_path}: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
return pending_models
|
||||
|
||||
def _root_sub_type_map(self) -> Dict[str, str]:
|
||||
"""Return the configured business root -> sub_type map."""
|
||||
root_map = getattr(config, "other_root_subtypes", None)
|
||||
return root_map if isinstance(root_map, dict) else {}
|
||||
|
||||
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve the sub_type for a configured root path."""
|
||||
if not root_path:
|
||||
return None
|
||||
|
||||
normalized_root = self._normalize_path_value(root_path)
|
||||
for root, sub_type in self._root_sub_type_map().items():
|
||||
if self._normalize_path_value(root) == normalized_root:
|
||||
return sub_type
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||
"""Resolve sub_type from the configured root that contains the file.
|
||||
|
||||
Uses the longest-prefix match so nested roots (e.g. a controlnet root
|
||||
inside a vae root) resolve to the most specific category.
|
||||
"""
|
||||
normalized_path = self._normalize_path_value(file_path)
|
||||
if not normalized_path:
|
||||
return None
|
||||
|
||||
best_length = 0
|
||||
best_sub_type: Optional[str] = None
|
||||
for root, sub_type in self._root_sub_type_map().items():
|
||||
normalized_root = self._normalize_path_value(root)
|
||||
if not normalized_root:
|
||||
continue
|
||||
if (
|
||||
normalized_path == normalized_root
|
||||
or normalized_path.startswith(f"{normalized_root}/")
|
||||
) and len(normalized_root) > best_length:
|
||||
best_length = len(normalized_root)
|
||||
best_sub_type = sub_type
|
||||
|
||||
return best_sub_type
|
||||
|
||||
def adjust_metadata(self, metadata, file_path, root_path):
|
||||
"""Adjust metadata during scanning to set sub_type."""
|
||||
sub_type = self._resolve_sub_type(root_path) or self.resolve_sub_type_for_path(
|
||||
file_path
|
||||
)
|
||||
if sub_type:
|
||||
metadata.sub_type = sub_type
|
||||
return metadata
|
||||
|
||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Adjust entries loaded from the persisted cache to ensure sub_type is set.
|
||||
|
||||
sub_type is location-derived: it is re-derived on cache load, never
|
||||
trusted from the persisted snapshot.
|
||||
"""
|
||||
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||
if sub_type:
|
||||
entry["sub_type"] = sub_type
|
||||
return entry
|
||||
|
||||
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
|
||||
"""Drop persisted entries whose folder is no longer a managed root.
|
||||
|
||||
sub_type is location-derived and config only maps enabled roots, so a
|
||||
file under a disabled sub_type - or under any other root while the
|
||||
feature is off - resolves to None here and is filtered out while the
|
||||
persisted cache is hydrated.
|
||||
"""
|
||||
return self.resolve_sub_type_for_path(entry.get("file_path")) is not None
|
||||
|
||||
def get_model_roots(self) -> List[str]:
|
||||
"""Get other-model root directories"""
|
||||
roots: List[str] = []
|
||||
roots.extend(config.other_roots or [])
|
||||
# Remove duplicates while preserving order
|
||||
seen: set[str] = set()
|
||||
unique_roots: List[str] = []
|
||||
for root in roots:
|
||||
if root and root not in seen:
|
||||
seen.add(root)
|
||||
unique_roots.append(root)
|
||||
return unique_roots
|
||||
@@ -59,7 +59,6 @@ _MODEL_TYPE_PAGE_MAP = {
|
||||
"lora": "loras",
|
||||
"checkpoint": "checkpoints",
|
||||
"embedding": "embeddings",
|
||||
"other": "other",
|
||||
}
|
||||
|
||||
# Module-level alias so tests can spy on timer task creation without patching
|
||||
@@ -984,7 +983,6 @@ class PendingDeleteService:
|
||||
"get_lora_scanner",
|
||||
"get_checkpoint_scanner",
|
||||
"get_embedding_scanner",
|
||||
"get_other_scanner",
|
||||
):
|
||||
getter = getattr(ServiceRegistry, getter_name, None)
|
||||
if not callable(getter):
|
||||
|
||||
@@ -19,9 +19,6 @@ class PersistedCacheData:
|
||||
hash_rows: List[Tuple[str, str]]
|
||||
excluded_models: List[str]
|
||||
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
|
||||
# Every directory under the model roots (including empty ones), or None
|
||||
# when the snapshot predates folder recording.
|
||||
all_folders: Optional[List[str]] = None
|
||||
|
||||
|
||||
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
|
||||
@@ -131,14 +128,6 @@ class PersistentModelCache:
|
||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
folder_rows = conn.execute(
|
||||
"SELECT path FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
).fetchall()
|
||||
folders_recorded = conn.execute(
|
||||
"SELECT value FROM cache_meta WHERE key = ?",
|
||||
(f"folders_recorded:{model_type}",),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception as exc:
|
||||
@@ -227,20 +216,14 @@ class PersistentModelCache:
|
||||
]
|
||||
|
||||
excluded_paths = [row["file_path"] for row in excluded]
|
||||
all_folders: Optional[List[str]] = None
|
||||
if folders_recorded is not None:
|
||||
all_folders = sorted(
|
||||
(row["path"] for row in folder_rows), key=lambda x: x.lower()
|
||||
)
|
||||
return PersistedCacheData(
|
||||
raw_data=raw_data,
|
||||
hash_rows=hash_pairs,
|
||||
excluded_models=excluded_paths,
|
||||
autov3_hash_rows=autov3_pairs,
|
||||
all_folders=all_folders,
|
||||
)
|
||||
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
|
||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
|
||||
if not self.is_enabled():
|
||||
return
|
||||
if not self._schema_initialized:
|
||||
@@ -486,27 +469,6 @@ class PersistentModelCache:
|
||||
excluded_inserts,
|
||||
)
|
||||
|
||||
if all_folders is not None:
|
||||
conn.execute(
|
||||
"DELETE FROM folders WHERE model_type = ?",
|
||||
(model_type,),
|
||||
)
|
||||
folder_inserts = [
|
||||
(model_type, path) for path in all_folders if path
|
||||
]
|
||||
if folder_inserts:
|
||||
conn.executemany(
|
||||
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||
folder_inserts,
|
||||
)
|
||||
# Mark the snapshot as having folder data even when the
|
||||
# library has no subfolders, so an empty list is not
|
||||
# mistaken for "never recorded" on load.
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||
(f"folders_recorded:{model_type}", "1"),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -592,17 +554,6 @@ class PersistentModelCache:
|
||||
file_path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, file_path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
model_type TEXT NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
PRIMARY KEY (model_type, path)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
)
|
||||
self._ensure_additional_model_columns(conn)
|
||||
|
||||
@@ -52,6 +52,7 @@ class PersistentRecipeCache:
|
||||
"file_mtime",
|
||||
"file_size",
|
||||
"favorite",
|
||||
"repair_version",
|
||||
"preview_nsfw_level",
|
||||
"loras_json",
|
||||
"checkpoint_json",
|
||||
@@ -441,6 +442,7 @@ class PersistentRecipeCache:
|
||||
file_mtime REAL,
|
||||
file_size INTEGER,
|
||||
favorite INTEGER DEFAULT 0,
|
||||
repair_version INTEGER DEFAULT 0,
|
||||
preview_nsfw_level INTEGER DEFAULT 0,
|
||||
loras_json TEXT,
|
||||
checkpoint_json TEXT,
|
||||
@@ -539,6 +541,7 @@ class PersistentRecipeCache:
|
||||
file_mtime,
|
||||
file_size,
|
||||
1 if recipe.get("favorite") else 0,
|
||||
int(recipe.get("repair_version") or 0),
|
||||
int(recipe.get("preview_nsfw_level") or 0),
|
||||
loras_json,
|
||||
checkpoint_json,
|
||||
@@ -596,6 +599,7 @@ class PersistentRecipeCache:
|
||||
"created_date": row["created_date"] or 0.0,
|
||||
"modified": row["modified"] or 0.0,
|
||||
"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,
|
||||
|
||||
+233
-158
@@ -94,6 +94,8 @@ class RecipeScanner:
|
||||
cls._instance._civitai_client = None # Will be lazily initialized
|
||||
return cls._instance
|
||||
|
||||
REPAIR_VERSION = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
lora_scanner: Optional[LoraScanner] = None,
|
||||
@@ -483,43 +485,32 @@ class RecipeScanner:
|
||||
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
||||
return suggestions[:limit]
|
||||
|
||||
def _is_rematch_candidate(
|
||||
self, entry: dict[str, Any], relaxed: bool = False
|
||||
) -> bool:
|
||||
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
||||
"""Return True when a recipe entry is eligible for local re-matching.
|
||||
|
||||
An entry counts as unresolved when its identity is known to be
|
||||
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
||||
identity fields (``hash``/``file_name``). A healthy entry whose
|
||||
hash is simply not present in the local library is NOT a candidate
|
||||
in the default strict mode: it may be a recipe imported without
|
||||
downloading the model yet, and its CivitAI-valid hash must never be
|
||||
overwritten by the imprecise filename fallback.
|
||||
|
||||
With ``relaxed=True`` any entry carrying an identifier is a
|
||||
candidate, including healthy ones — the caller opted into trying to
|
||||
reconnect "Not in Library" entries by file name. Entries without
|
||||
any identifier are never candidates in either mode.
|
||||
hash is simply not present in the local library is NOT a candidate:
|
||||
it may be a recipe imported without downloading the model yet, and
|
||||
its CivitAI-valid hash must never be overwritten by the imprecise
|
||||
filename fallback.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return False
|
||||
has_identifier = (
|
||||
entry.get("hash")
|
||||
or entry.get("modelVersionId")
|
||||
or entry.get("id")
|
||||
or entry.get("file_name")
|
||||
)
|
||||
if not has_identifier:
|
||||
return False
|
||||
if relaxed:
|
||||
return True
|
||||
unresolved = (
|
||||
entry.get("isDeleted")
|
||||
or entry.get("hashInvalid")
|
||||
or not entry.get("hash")
|
||||
or not entry.get("file_name")
|
||||
)
|
||||
return bool(unresolved)
|
||||
has_identifier = (
|
||||
entry.get("hash")
|
||||
or entry.get("modelVersionId")
|
||||
or entry.get("id")
|
||||
or entry.get("file_name")
|
||||
)
|
||||
return bool(unresolved and has_identifier)
|
||||
|
||||
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
|
||||
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
||||
@@ -820,9 +811,208 @@ class RecipeScanner:
|
||||
"""Check if cancellation has been requested."""
|
||||
return self._cancel_requested
|
||||
|
||||
async def rematch_recipe_by_id(
|
||||
self, recipe_id: str, *, relaxed: bool = False
|
||||
async def repair_all_recipes(
|
||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Repair all recipes by enrichment with Civitai and embedded metadata.
|
||||
|
||||
Args:
|
||||
persistence_service: Service for saving updated recipes
|
||||
progress_callback: Optional callback for progress updates
|
||||
|
||||
Returns:
|
||||
Dict summary of repair results
|
||||
"""
|
||||
if progress_callback:
|
||||
await progress_callback({"status": "started"})
|
||||
async with self._mutation_lock:
|
||||
cache = await self.get_cached_data()
|
||||
all_recipes = list(cache.raw_data)
|
||||
total = len(all_recipes)
|
||||
repaired_count = 0
|
||||
skipped_count = 0
|
||||
errors_count = 0
|
||||
|
||||
civitai_client = await self._get_civitai_client()
|
||||
self.reset_cancellation()
|
||||
|
||||
for i, recipe in enumerate(all_recipes):
|
||||
if self.is_cancelled():
|
||||
logger.info("Recipe repair cancelled by user")
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
{
|
||||
"status": "cancelled",
|
||||
"current": i,
|
||||
"total": total,
|
||||
"repaired": repaired_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"status": "cancelled",
|
||||
"repaired": repaired_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
try:
|
||||
# Report progress
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
{
|
||||
"status": "processing",
|
||||
"current": i + 1,
|
||||
"total": total,
|
||||
"recipe_name": recipe.get("name", "Unknown"),
|
||||
}
|
||||
)
|
||||
|
||||
if await self._repair_single_recipe(recipe, civitai_client):
|
||||
repaired_count += 1
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"Error repairing recipe {recipe.get('file_path')}: {e}"
|
||||
)
|
||||
errors_count += 1
|
||||
|
||||
# Final progress update
|
||||
if progress_callback:
|
||||
await progress_callback(
|
||||
{
|
||||
"status": "completed",
|
||||
"repaired": repaired_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"total": total,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"repaired": repaired_count,
|
||||
"skipped": skipped_count,
|
||||
"errors": errors_count,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
async def repair_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
"""Repair a single recipe by its ID.
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to repair
|
||||
|
||||
Returns:
|
||||
Dict summary of repair result
|
||||
"""
|
||||
async with self._mutation_lock:
|
||||
# Get raw recipe from cache directly to avoid formatted fields
|
||||
cache = await self.get_cached_data()
|
||||
recipe = next(
|
||||
(r for r in cache.raw_data if str(r.get("id", "")) == recipe_id), None
|
||||
)
|
||||
|
||||
if not recipe:
|
||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
||||
|
||||
civitai_client = await self._get_civitai_client()
|
||||
success = await self._repair_single_recipe(recipe, civitai_client)
|
||||
|
||||
# If successfully repaired, we should return the formatted version for the UI
|
||||
return {
|
||||
"success": True,
|
||||
"repaired": 1 if success else 0,
|
||||
"skipped": 0 if success else 1,
|
||||
"recipe": await self.get_recipe_by_id(recipe_id) if success else recipe,
|
||||
}
|
||||
|
||||
async def _repair_single_recipe(
|
||||
self, recipe: Dict[str, Any], civitai_client: Any
|
||||
) -> bool:
|
||||
"""Internal helper to repair a single recipe object.
|
||||
|
||||
Args:
|
||||
recipe: The recipe dictionary to repair (modified in-place)
|
||||
civitai_client: Authenticated Civitai client
|
||||
|
||||
Returns:
|
||||
bool: True if recipe was repaired or updated, False if skipped
|
||||
"""
|
||||
# 1. Skip if already at latest repair version
|
||||
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
||||
return False
|
||||
|
||||
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
|
||||
# A checkpoint whose modelVersionId also appears in a LoRA entry is
|
||||
# definitely wrong — the CivitAI import code used to pick
|
||||
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
|
||||
# Clearing it lets the enrichment flow re-resolve the correct
|
||||
# checkpoint from CivitAI image metadata.
|
||||
cp = recipe.get("checkpoint")
|
||||
lora_mvids = {
|
||||
l.get("modelVersionId")
|
||||
for l in recipe.get("loras", [])
|
||||
if l.get("modelVersionId")
|
||||
}
|
||||
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
|
||||
cp_mvid = cp["modelVersionId"]
|
||||
logger.info(
|
||||
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
|
||||
"clearing corrupted checkpoint and removing matching LoRA entry",
|
||||
recipe.get("id"),
|
||||
cp_mvid,
|
||||
)
|
||||
recipe["checkpoint"] = None
|
||||
recipe["loras"] = [
|
||||
l for l in recipe.get("loras", [])
|
||||
if l.get("modelVersionId") != cp_mvid
|
||||
]
|
||||
|
||||
# 2. Identification: Is repair needed?
|
||||
has_checkpoint = (
|
||||
"checkpoint" in recipe
|
||||
and recipe["checkpoint"]
|
||||
and recipe["checkpoint"].get("name")
|
||||
)
|
||||
gen_params = recipe.get("gen_params", {})
|
||||
has_prompt = bool(gen_params.get("prompt"))
|
||||
|
||||
needs_repair = not has_checkpoint or not has_prompt
|
||||
|
||||
if not needs_repair:
|
||||
# Even if no repair needed, we mark it with version if it was processed
|
||||
# Always update and save because if we are here, the version is old (checked in step 1)
|
||||
recipe["repair_version"] = self.REPAIR_VERSION
|
||||
await self._save_recipe_persistently(recipe)
|
||||
return True
|
||||
|
||||
# 3. Use Enricher to repair/enrich
|
||||
try:
|
||||
from ..recipes.enrichment import RecipeEnricher
|
||||
|
||||
updated = await RecipeEnricher.enrich_recipe(recipe, civitai_client)
|
||||
except Exception as e:
|
||||
logger.error(f"Error enriching recipe {recipe.get('id')}: {e}")
|
||||
updated = False
|
||||
|
||||
# 4. Mark version and save if updated or just marking version
|
||||
# If we updated it, OR if the version is old (which we know it is if we are here), save it.
|
||||
# Actually, if we are here and updated is False, it means we tried to repair but couldn't/didn't need to.
|
||||
# But we still want to mark it as processed so we don't try again until version bump.
|
||||
if updated or recipe.get("repair_version", 0) < self.REPAIR_VERSION:
|
||||
recipe["repair_version"] = self.REPAIR_VERSION
|
||||
await self._save_recipe_persistently(recipe)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Logs one INFO summary line for this run and delegates the per-recipe
|
||||
@@ -830,14 +1020,12 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
||||
Raises RecipeNotFoundError when the recipe is missing.
|
||||
"""
|
||||
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
|
||||
result = await self._rematch_recipe_by_id(recipe_id)
|
||||
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
|
||||
logger.info(
|
||||
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
||||
@@ -850,9 +1038,7 @@ class RecipeScanner:
|
||||
)
|
||||
return result
|
||||
|
||||
async def _rematch_recipe_by_id(
|
||||
self, recipe_id: str, *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||
@@ -863,16 +1049,12 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_id: ID of the recipe to rematch
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch result with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy rematched/skipped/errors
|
||||
fields) and a per-entry ``details`` report plus a flattened
|
||||
``l4_matches`` list (filename-level matches for review/undo,
|
||||
consistent with the bulk/global paths). The legacy ``skipped``
|
||||
fields) and a per-entry ``details`` report. The legacy ``skipped``
|
||||
field means "recipe not updated" and overlaps
|
||||
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
||||
as both). Raises RecipeNotFoundError when the recipe is missing.
|
||||
@@ -893,8 +1075,7 @@ class RecipeScanner:
|
||||
|
||||
try:
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache, filename_cache,
|
||||
relaxed=relaxed,
|
||||
recipe, local_cache, autov3_cache, filename_cache
|
||||
)
|
||||
except RecipePersistenceError as exc:
|
||||
logger.error(
|
||||
@@ -913,16 +1094,12 @@ class RecipeScanner:
|
||||
"unresolved_recipes": 0,
|
||||
"unresolved_entries": 0,
|
||||
"details": {"matched": [], "unresolved": []},
|
||||
"l4_matches": [],
|
||||
"recipe": recipe,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
unresolved_entries = len(details["unresolved"])
|
||||
unresolved_recipes = 1 if unresolved_entries > 0 else 0
|
||||
# Flattened L4 matches for the results modal, consistent with
|
||||
# the bulk/global paths.
|
||||
l4_matches = self._collect_l4_matches(recipe_id, details)
|
||||
|
||||
if rematched == 0:
|
||||
return {
|
||||
@@ -934,7 +1111,6 @@ class RecipeScanner:
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"l4_matches": l4_matches,
|
||||
"recipe": recipe,
|
||||
}
|
||||
|
||||
@@ -948,7 +1124,6 @@ class RecipeScanner:
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"details": details,
|
||||
"l4_matches": l4_matches,
|
||||
"recipe": await self.get_recipe_by_id(recipe_id),
|
||||
}
|
||||
|
||||
@@ -958,8 +1133,6 @@ class RecipeScanner:
|
||||
local_cache: dict[str, dict[str, Any]],
|
||||
autov3_cache: dict[str, dict[str, Any]],
|
||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||
*,
|
||||
relaxed: bool = False,
|
||||
) -> Tuple[int, int, Dict[str, Any]]:
|
||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||
|
||||
@@ -975,24 +1148,16 @@ class RecipeScanner:
|
||||
autov3_cache: L3 computed-autov3 cache snapshot
|
||||
filename_cache: L4 filename cache snapshot, or None to disable
|
||||
the filename fallback
|
||||
relaxed: When True, healthy entries ("Not in Library") are also
|
||||
rematch candidates. Anti-churn rule: an entry that is a
|
||||
candidate ONLY because of relaxed mode is skipped when its
|
||||
hash already resolves in the L1 ``local_cache`` — it is
|
||||
already correctly linked and rematching would only add noise
|
||||
and a pointless snapshot.
|
||||
|
||||
Returns:
|
||||
Tuple of (rematched_entries, errors, details). The errors element
|
||||
is always 0 on a normal return — a persistence failure RAISES
|
||||
``RecipePersistenceError`` so callers can count it. ``details``
|
||||
carries the per-entry outcome:
|
||||
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
|
||||
``{"matched": [{type, entry, file_name, match_level}],
|
||||
"unresolved": [{type, entry}]}`` where an unresolved entry is a
|
||||
rematch candidate that found no local match — an expected outcome
|
||||
(the model may simply not exist locally), not an error.
|
||||
``lora_index`` is only present for lora entries (the checkpoint
|
||||
restore endpoint needs no index).
|
||||
|
||||
Raises:
|
||||
RecipePersistenceError: when the recipe changed but
|
||||
@@ -1001,23 +1166,11 @@ class RecipeScanner:
|
||||
rematched = 0
|
||||
details: Dict[str, Any] = {"matched": [], "unresolved": []}
|
||||
|
||||
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
|
||||
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
|
||||
if self._is_rematch_candidate(entry):
|
||||
return True
|
||||
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
|
||||
return False
|
||||
# Relaxed-only candidate: skip when the stored hash already
|
||||
# resolves in the L1 local cache — the entry is already correctly
|
||||
# linked and rematching would just add noise and a snapshot.
|
||||
entry_hash = (entry.get("hash") or "").lower()
|
||||
return local_cache.get(entry_hash) is None
|
||||
|
||||
# Lora entries
|
||||
loras = recipe.get("loras", [])
|
||||
if isinstance(loras, list):
|
||||
for lora_index, entry in enumerate(loras):
|
||||
if not is_actionable_candidate(entry):
|
||||
for entry in loras:
|
||||
if not self._is_rematch_candidate(entry):
|
||||
continue
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
entry,
|
||||
@@ -1041,7 +1194,6 @@ class RecipeScanner:
|
||||
"entry": self._entry_identifier(entry),
|
||||
"file_name": item.get("file_name") or "",
|
||||
"match_level": level,
|
||||
"lora_index": lora_index,
|
||||
}
|
||||
)
|
||||
self._write_rematch_lora_entry(entry, item)
|
||||
@@ -1051,7 +1203,7 @@ class RecipeScanner:
|
||||
# silently since ``entry.get`` on a str would raise AttributeError).
|
||||
checkpoint = recipe.get("checkpoint")
|
||||
if isinstance(checkpoint, dict):
|
||||
if is_actionable_candidate(checkpoint):
|
||||
if self._is_rematch_candidate(checkpoint):
|
||||
item, level = await self._match_rematch_entry_with_level(
|
||||
checkpoint,
|
||||
local_cache,
|
||||
@@ -1116,36 +1268,8 @@ class RecipeScanner:
|
||||
self._update_fts_index_for_recipe(recipe, "update")
|
||||
return (rematched, 0, details)
|
||||
|
||||
@staticmethod
|
||||
def _collect_l4_matches(
|
||||
recipe_id: Any, details: Dict[str, Any]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Flatten a recipe's L4 (filename-level) matches for review.
|
||||
|
||||
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows —
|
||||
one per matched detail at level L4. ``lora_index`` is only present
|
||||
for lora entries (checkpoint restore needs no index).
|
||||
"""
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for match in details.get("matched", []):
|
||||
if match.get("match_level") != "L4":
|
||||
continue
|
||||
row: Dict[str, Any] = {
|
||||
"recipe_id": recipe_id,
|
||||
"type": match.get("type"),
|
||||
"entry": match.get("entry"),
|
||||
"file_name": match.get("file_name"),
|
||||
}
|
||||
if "lora_index" in match:
|
||||
row["lora_index"] = match["lora_index"]
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
async def rematch_all_recipes(
|
||||
self,
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||
*,
|
||||
relaxed: bool = False,
|
||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||
|
||||
@@ -1159,19 +1283,14 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
progress_callback: Optional callback for progress updates
|
||||
(started/processing/cancelled/completed events). The
|
||||
completed/cancelled payloads carry ``l4_matches``, a
|
||||
flattened list of filename-level matches for review/undo.
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
(started/processing/cancelled/completed events).
|
||||
|
||||
Returns:
|
||||
Dict summary of the rematch run with unified counters
|
||||
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
||||
entries plus the legacy success/status/rematched/skipped/errors/
|
||||
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
|
||||
updated recipes — use ``matched_entries`` for the entry-level
|
||||
total.
|
||||
total fields). ``rematched`` (legacy) counts updated recipes —
|
||||
use ``matched_entries`` for the entry-level total.
|
||||
"""
|
||||
start_time = time.perf_counter()
|
||||
|
||||
@@ -1193,7 +1312,6 @@ class RecipeScanner:
|
||||
unresolved_entries = 0
|
||||
skipped_count = 0
|
||||
errors_count = 0
|
||||
l4_matches: List[Dict[str, Any]] = []
|
||||
|
||||
for i, recipe in enumerate(all_recipes):
|
||||
if self.is_cancelled():
|
||||
@@ -1222,7 +1340,6 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
)
|
||||
return {
|
||||
@@ -1236,7 +1353,6 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -1252,15 +1368,11 @@ class RecipeScanner:
|
||||
)
|
||||
|
||||
rematched, _errors, details = await self._rematch_single_recipe(
|
||||
recipe, local_cache, autov3_cache, filename_cache,
|
||||
relaxed=relaxed,
|
||||
recipe, local_cache, autov3_cache, filename_cache
|
||||
)
|
||||
if rematched > 0:
|
||||
matched_recipes += 1
|
||||
matched_entries += rematched
|
||||
l4_matches.extend(
|
||||
self._collect_l4_matches(recipe.get("id"), details)
|
||||
)
|
||||
else:
|
||||
skipped_count += 1
|
||||
|
||||
@@ -1306,7 +1418,6 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1320,12 +1431,9 @@ class RecipeScanner:
|
||||
"matched_entries": matched_entries,
|
||||
"unresolved_recipes": unresolved_recipes,
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
async def rematch_recipes_bulk(
|
||||
self, recipe_ids: List[str], *, relaxed: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
||||
"""Rematch a set of recipes by their IDs.
|
||||
|
||||
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
||||
@@ -1336,18 +1444,14 @@ class RecipeScanner:
|
||||
|
||||
Args:
|
||||
recipe_ids: List of recipe ids to rematch.
|
||||
relaxed: When True, healthy entries are rematch candidates too
|
||||
(see ``_rematch_single_recipe``).
|
||||
|
||||
Returns:
|
||||
Dict summary of the bulk run with unified counters
|
||||
(matched_recipes, matched_entries, unresolved_recipes,
|
||||
unresolved_entries plus the legacy total/rematched/skipped/errors
|
||||
fields), a per-recipe ``details`` list, and ``l4_matches`` — a
|
||||
flattened list of filename-level matches for review/undo. The
|
||||
legacy ``rematched`` field is the total entry count (same as
|
||||
``matched_entries``) — unlike ``rematch_all_recipes`` where it
|
||||
counts updated recipes.
|
||||
fields) and a per-recipe ``details`` list. The legacy ``rematched``
|
||||
field is the total entry count (same as ``matched_entries``) —
|
||||
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
||||
"""
|
||||
total = len(recipe_ids)
|
||||
matched_recipes = 0
|
||||
@@ -1358,13 +1462,10 @@ class RecipeScanner:
|
||||
errors = 0
|
||||
recipes: List[Dict[str, Any]] = []
|
||||
details_list: List[Dict[str, Any]] = []
|
||||
l4_matches: List[Dict[str, Any]] = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
try:
|
||||
result = await self._rematch_recipe_by_id(
|
||||
recipe_id, relaxed=relaxed
|
||||
)
|
||||
result = await self._rematch_recipe_by_id(recipe_id)
|
||||
if result.get("success"):
|
||||
matched_recipes += result.get("matched_recipes", 0)
|
||||
matched_entries += result.get("matched_entries", 0)
|
||||
@@ -1377,9 +1478,6 @@ class RecipeScanner:
|
||||
details_list.append(
|
||||
{"recipe_id": recipe_id, **result["details"]}
|
||||
)
|
||||
l4_matches.extend(
|
||||
self._collect_l4_matches(recipe_id, result["details"])
|
||||
)
|
||||
else:
|
||||
errors += result.get("errors", 0)
|
||||
except RecipeNotFoundError:
|
||||
@@ -1414,22 +1512,12 @@ class RecipeScanner:
|
||||
"unresolved_entries": unresolved_entries,
|
||||
"recipes": recipes,
|
||||
"details": details_list,
|
||||
"l4_matches": l4_matches,
|
||||
}
|
||||
|
||||
def _write_rematch_lora_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
"""Write back a matched local model to a lora recipe entry."""
|
||||
# Snapshot the pre-rematch state so the association can be restored
|
||||
# later (undo), mirroring the manual reconnect flow in
|
||||
# ``update_lora_entry``. Never nest snapshots.
|
||||
snapshot = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in entry.items()
|
||||
if key != "reconnectSnapshot"
|
||||
}
|
||||
|
||||
entry["isDeleted"] = False
|
||||
entry["hashInvalid"] = False
|
||||
|
||||
@@ -1453,8 +1541,6 @@ class RecipeScanner:
|
||||
if civitai.get("name"):
|
||||
entry["modelVersionName"] = civitai["name"]
|
||||
|
||||
entry["reconnectSnapshot"] = snapshot
|
||||
|
||||
def _write_rematch_checkpoint_entry(
|
||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||
) -> None:
|
||||
@@ -1466,15 +1552,6 @@ class RecipeScanner:
|
||||
when they already exist on the entry (or written fresh for the
|
||||
identifier key when neither identifier form exists).
|
||||
"""
|
||||
# Snapshot the pre-rematch state so the association can be restored
|
||||
# later (undo), mirroring the manual reconnect flow. Never nest
|
||||
# snapshots.
|
||||
snapshot = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in entry.items()
|
||||
if key != "reconnectSnapshot"
|
||||
}
|
||||
|
||||
entry["isDeleted"] = False
|
||||
entry["hashInvalid"] = False
|
||||
|
||||
@@ -1515,8 +1592,6 @@ class RecipeScanner:
|
||||
else:
|
||||
entry["modelVersionId"] = civ_id
|
||||
|
||||
entry["reconnectSnapshot"] = snapshot
|
||||
|
||||
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||
recipe_id = recipe.get("id")
|
||||
|
||||
@@ -297,44 +297,23 @@ class ServiceRegistry:
|
||||
async def get_embedding_scanner(cls):
|
||||
"""Get or create Embedding scanner instance"""
|
||||
service_name = "embedding_scanner"
|
||||
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
# Double-check after acquiring lock
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .embedding_scanner import EmbeddingScanner
|
||||
|
||||
|
||||
scanner = await EmbeddingScanner.get_instance()
|
||||
cls._services[service_name] = scanner
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return scanner
|
||||
|
||||
@classmethod
|
||||
async def get_other_scanner(cls):
|
||||
"""Get or create Other-model scanner instance"""
|
||||
service_name = "other_scanner"
|
||||
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
async with cls._get_lock(service_name):
|
||||
# Double-check after acquiring lock
|
||||
if service_name in cls._services:
|
||||
return cls._services[service_name]
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from .other_scanner import OtherScanner
|
||||
|
||||
scanner = await OtherScanner.get_instance()
|
||||
cls._services[service_name] = scanner
|
||||
logger.debug(f"Created and registered {service_name}")
|
||||
return scanner
|
||||
|
||||
|
||||
@classmethod
|
||||
def clear_services(cls):
|
||||
"""Clear all registered services - mainly for testing"""
|
||||
|
||||
+15
-175
@@ -25,14 +25,9 @@ from typing import (
|
||||
from platformdirs import user_config_dir
|
||||
|
||||
from ..utils.constants import (
|
||||
DEFAULT_DOWNLOAD_PATH_TEMPLATES,
|
||||
DEFAULT_ENABLED_OTHER_SUB_TYPES,
|
||||
DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||
OTHER_SUB_TYPE_FOLDER_KEYS,
|
||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||
VALID_OTHER_SUB_TYPES,
|
||||
normalize_other_sub_types,
|
||||
)
|
||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||
from ..utils.settings_paths import (
|
||||
@@ -88,11 +83,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"default_checkpoint_root": "",
|
||||
"default_unet_root": "",
|
||||
"default_embedding_root": "",
|
||||
"default_other_roots": {},
|
||||
# Other Models management is opt-in: nothing is scanned, shown or offered
|
||||
# for download until the user turns the feature on.
|
||||
"enable_other_models": False,
|
||||
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
|
||||
"recipes_path": "",
|
||||
"base_model_path_mappings": {},
|
||||
"download_path_templates": {},
|
||||
@@ -126,7 +116,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
||||
"backup_retention_count": 5,
|
||||
"use_new_license_icons": True,
|
||||
"group_by_model": False,
|
||||
"sticky_controls": False,
|
||||
# AI / LLM provider configuration (BYOK)
|
||||
"llm_provider": "openai", # "openai" | "ollama" | "custom"
|
||||
"llm_api_key": "",
|
||||
@@ -319,7 +308,6 @@ class SettingsManager:
|
||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||
default_unet_root=merged.get("default_unet_root"),
|
||||
default_embedding_root=merged.get("default_embedding_root"),
|
||||
default_other_roots=merged.get("default_other_roots"),
|
||||
recipes_path=merged.get("recipes_path"),
|
||||
)
|
||||
}
|
||||
@@ -454,7 +442,6 @@ class SettingsManager:
|
||||
),
|
||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||
default_other_roots=self.settings.get("default_other_roots"),
|
||||
recipes_path=self.settings.get("recipes_path", ""),
|
||||
)
|
||||
libraries = {library_name: library_payload}
|
||||
@@ -506,7 +493,6 @@ class SettingsManager:
|
||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||
default_unet_root=data.get("default_unet_root"),
|
||||
default_embedding_root=data.get("default_embedding_root"),
|
||||
default_other_roots=data.get("default_other_roots"),
|
||||
recipes_path=data.get("recipes_path"),
|
||||
metadata=data.get("metadata"),
|
||||
base=data,
|
||||
@@ -554,9 +540,6 @@ class SettingsManager:
|
||||
self.settings["default_embedding_root"] = active_library.get(
|
||||
"default_embedding_root", ""
|
||||
)
|
||||
self.settings["default_other_roots"] = self._normalize_default_other_roots(
|
||||
active_library.get("default_other_roots", {})
|
||||
)
|
||||
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||
|
||||
if save:
|
||||
@@ -574,7 +557,6 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
base: Optional[Mapping[str, Any]] = None,
|
||||
@@ -614,15 +596,6 @@ class SettingsManager:
|
||||
else:
|
||||
payload.setdefault("default_embedding_root", "")
|
||||
|
||||
if default_other_roots is not None:
|
||||
payload["default_other_roots"] = self._normalize_default_other_roots(
|
||||
default_other_roots
|
||||
)
|
||||
else:
|
||||
payload["default_other_roots"] = self._normalize_default_other_roots(
|
||||
payload.get("default_other_roots", {})
|
||||
)
|
||||
|
||||
if recipes_path is not None:
|
||||
payload["recipes_path"] = recipes_path
|
||||
else:
|
||||
@@ -658,71 +631,6 @@ class SettingsManager:
|
||||
normalized[key] = cleaned
|
||||
return normalized
|
||||
|
||||
def _normalize_default_other_roots(
|
||||
self, value: Any, *, strict: bool = False
|
||||
) -> Dict[str, str]:
|
||||
"""Normalize a ``default_other_roots`` mapping ({sub_type: root path}).
|
||||
|
||||
Unknown sub_type keys and non-string/empty paths are dropped; with
|
||||
``strict=True`` unknown sub_type keys raise instead (used by ``set()``
|
||||
so typos in API payloads surface as errors).
|
||||
"""
|
||||
if not isinstance(value, Mapping):
|
||||
if strict and value is not None:
|
||||
raise ValueError("default_other_roots must be a mapping")
|
||||
return {}
|
||||
normalized: Dict[str, str] = {}
|
||||
for sub_type, path in value.items():
|
||||
if sub_type not in VALID_OTHER_SUB_TYPES:
|
||||
if strict:
|
||||
raise ValueError(
|
||||
f"Unknown other-model sub-type '{sub_type}'; "
|
||||
f"expected one of {sorted(VALID_OTHER_SUB_TYPES)}"
|
||||
)
|
||||
continue
|
||||
if not isinstance(path, str):
|
||||
continue
|
||||
stripped = path.strip()
|
||||
if stripped:
|
||||
normalized[sub_type] = stripped
|
||||
return normalized
|
||||
|
||||
def is_other_models_enabled(self) -> bool:
|
||||
"""Return True when the opt-in Other Models management is enabled."""
|
||||
return bool(self.settings.get("enable_other_models", False))
|
||||
|
||||
def get_enabled_other_sub_types(self) -> List[str]:
|
||||
"""Return the enabled other-model sub_types (empty when the feature is off)."""
|
||||
if not self.is_other_models_enabled():
|
||||
return []
|
||||
return normalize_other_sub_types(self.settings.get("enabled_other_sub_types"))
|
||||
|
||||
def is_other_sub_type_enabled(self, sub_type: Optional[str]) -> bool:
|
||||
"""Return True when ``sub_type`` is currently managed."""
|
||||
if not sub_type:
|
||||
return False
|
||||
return sub_type in self.get_enabled_other_sub_types()
|
||||
|
||||
def _apply_other_model_settings_change(self) -> None:
|
||||
"""Rebuild other-model roots and refresh the other scanner after a toggle."""
|
||||
try:
|
||||
from ..config import config # Local import to avoid circular dependency
|
||||
|
||||
config.refresh_other_roots()
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to refresh other-model roots: %s", exc)
|
||||
|
||||
try:
|
||||
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
|
||||
|
||||
scanner = ServiceRegistry.get_service_sync("other_scanner")
|
||||
if scanner is not None and hasattr(scanner, "on_library_changed"):
|
||||
# reconcile=True lets the scanner pick up newly enabled roots and
|
||||
# purge rows for folders that are no longer managed.
|
||||
scanner.on_library_changed(reconcile=True)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
|
||||
|
||||
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
||||
if not isinstance(folder_paths, Mapping):
|
||||
return False
|
||||
@@ -835,7 +743,6 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
) -> bool:
|
||||
libraries = self.settings.get("libraries", {})
|
||||
@@ -886,14 +793,6 @@ class SettingsManager:
|
||||
library["default_embedding_root"] = default_embedding_root
|
||||
changed = True
|
||||
|
||||
if default_other_roots is not None:
|
||||
normalized_other_roots = self._normalize_default_other_roots(
|
||||
default_other_roots
|
||||
)
|
||||
if library.get("default_other_roots") != normalized_other_roots:
|
||||
library["default_other_roots"] = normalized_other_roots
|
||||
changed = True
|
||||
|
||||
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||
library["recipes_path"] = recipes_path
|
||||
changed = True
|
||||
@@ -994,53 +893,12 @@ class SettingsManager:
|
||||
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
||||
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
||||
|
||||
# Other-model default roots: one entry per enabled sub_type; candidates
|
||||
# are the union of that sub_type's folder_paths keys (text_encoder
|
||||
# merges the legacy 'clip' key with 'text_encoders'). When the opt-in
|
||||
# feature is off the existing mapping is left untouched.
|
||||
other_roots = self._normalize_default_other_roots(
|
||||
self.settings.get("default_other_roots")
|
||||
)
|
||||
if self.is_other_models_enabled():
|
||||
for sub_type in self.get_enabled_other_sub_types():
|
||||
candidates: List[str] = []
|
||||
candidate_identities: set[str] = set()
|
||||
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
|
||||
for candidate in self._get_valid_root_candidates(folder_key):
|
||||
identity = _normalize_root_identity(candidate)
|
||||
if identity in candidate_identities:
|
||||
continue
|
||||
candidate_identities.add(identity)
|
||||
candidates.append(candidate)
|
||||
if not candidates:
|
||||
continue
|
||||
current = other_roots.get(sub_type, "")
|
||||
if current and _normalize_root_identity(current) in candidate_identities:
|
||||
continue
|
||||
other_roots[sub_type] = candidates[0]
|
||||
if current:
|
||||
logger.info(
|
||||
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
|
||||
sub_type,
|
||||
current,
|
||||
candidates[0],
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Auto-set default_other_roots[%s] to '%s'",
|
||||
sub_type,
|
||||
candidates[0],
|
||||
)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
self.settings["default_other_roots"] = other_roots
|
||||
self._update_active_library_entry(
|
||||
default_lora_root=self.settings.get("default_lora_root"),
|
||||
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
||||
default_unet_root=self.settings.get("default_unet_root"),
|
||||
default_embedding_root=self.settings.get("default_embedding_root"),
|
||||
default_other_roots=other_roots,
|
||||
)
|
||||
if self._bootstrap_reason == "missing":
|
||||
self._needs_initial_save = True
|
||||
@@ -1740,12 +1598,6 @@ class SettingsManager:
|
||||
value = self.normalize_download_skip_base_models(value)
|
||||
elif key == "mature_blur_level":
|
||||
value = self.normalize_mature_blur_level(value)
|
||||
elif key == "default_other_roots":
|
||||
value = self._normalize_default_other_roots(value, strict=True)
|
||||
elif key == "enabled_other_sub_types":
|
||||
value = normalize_other_sub_types(value)
|
||||
elif key == "enable_other_models":
|
||||
value = bool(value)
|
||||
elif key == "recipes_path":
|
||||
current_recipes_dir = self._get_effective_recipes_dir()
|
||||
value = self._normalize_recipes_path_value(value)
|
||||
@@ -1773,8 +1625,6 @@ class SettingsManager:
|
||||
self._update_active_library_entry(default_unet_root=str(value))
|
||||
elif key == "default_embedding_root":
|
||||
self._update_active_library_entry(default_embedding_root=str(value))
|
||||
elif key == "default_other_roots":
|
||||
self._update_active_library_entry(default_other_roots=value)
|
||||
elif key == "recipes_path":
|
||||
self._update_active_library_entry(recipes_path=str(value))
|
||||
elif key == "model_name_display":
|
||||
@@ -1782,8 +1632,6 @@ class SettingsManager:
|
||||
self._save_settings()
|
||||
if key == "recipes_path":
|
||||
self._notify_library_change(self.get_active_library_name())
|
||||
if key in ("enable_other_models", "enabled_other_sub_types"):
|
||||
self._apply_other_model_settings_change()
|
||||
if portable_switch_pending:
|
||||
self._finalize_portable_switch()
|
||||
|
||||
@@ -1947,7 +1795,6 @@ class SettingsManager:
|
||||
"lora_scanner",
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"other_scanner",
|
||||
"recipe_scanner",
|
||||
):
|
||||
service = ServiceRegistry.get_service_sync(service_name)
|
||||
@@ -2112,7 +1959,6 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
activate: bool = False,
|
||||
@@ -2157,11 +2003,6 @@ class SettingsManager:
|
||||
if default_embedding_root is not None
|
||||
else existing.get("default_embedding_root")
|
||||
),
|
||||
default_other_roots=(
|
||||
default_other_roots
|
||||
if default_other_roots is not None
|
||||
else existing.get("default_other_roots")
|
||||
),
|
||||
recipes_path=(
|
||||
recipes_path
|
||||
if recipes_path is not None
|
||||
@@ -2194,7 +2035,6 @@ class SettingsManager:
|
||||
default_checkpoint_root: str = "",
|
||||
default_unet_root: str = "",
|
||||
default_embedding_root: str = "",
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: str = "",
|
||||
metadata: Optional[Mapping[str, Any]] = None,
|
||||
activate: bool = False,
|
||||
@@ -2213,7 +2053,6 @@ class SettingsManager:
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_unet_root=default_unet_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
default_other_roots=default_other_roots,
|
||||
recipes_path=recipes_path,
|
||||
metadata=metadata,
|
||||
activate=activate,
|
||||
@@ -2274,7 +2113,6 @@ class SettingsManager:
|
||||
default_checkpoint_root: Optional[str] = None,
|
||||
default_unet_root: Optional[str] = None,
|
||||
default_embedding_root: Optional[str] = None,
|
||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||
recipes_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update folder paths for the active library."""
|
||||
@@ -2288,7 +2126,6 @@ class SettingsManager:
|
||||
default_checkpoint_root=default_checkpoint_root,
|
||||
default_unet_root=default_unet_root,
|
||||
default_embedding_root=default_embedding_root,
|
||||
default_other_roots=default_other_roots,
|
||||
recipes_path=recipes_path,
|
||||
activate=True,
|
||||
)
|
||||
@@ -2313,7 +2150,6 @@ class SettingsManager:
|
||||
"lora_scanner",
|
||||
"checkpoint_scanner",
|
||||
"embedding_scanner",
|
||||
"other_scanner",
|
||||
"recipe_scanner",
|
||||
"model_update_service",
|
||||
):
|
||||
@@ -2336,14 +2172,10 @@ class SettingsManager:
|
||||
"""Get download path template for specific model type
|
||||
|
||||
Args:
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||
'other')
|
||||
model_type: The type of model ('lora', 'checkpoint', 'embedding')
|
||||
|
||||
Returns:
|
||||
Template string for the model type. Falls back to the per-type
|
||||
default in ``DEFAULT_DOWNLOAD_PATH_TEMPLATES``; unknown model types
|
||||
resolve to an empty string (flat layout) rather than silently
|
||||
nesting downloads under an unconfigured subfolder.
|
||||
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
||||
"""
|
||||
templates = self.settings.get("download_path_templates", {})
|
||||
|
||||
@@ -2367,19 +2199,27 @@ class SettingsManager:
|
||||
logger.warning(
|
||||
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
|
||||
)
|
||||
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
|
||||
default_template = "{base_model}/{first_tag}"
|
||||
templates = {
|
||||
"lora": default_template,
|
||||
"checkpoint": default_template,
|
||||
"embedding": default_template,
|
||||
}
|
||||
self.settings["download_path_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
# Ensure templates is a dictionary
|
||||
if not isinstance(templates, dict):
|
||||
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
|
||||
default_template = "{base_model}/{first_tag}"
|
||||
templates = {
|
||||
"lora": default_template,
|
||||
"checkpoint": default_template,
|
||||
"embedding": default_template,
|
||||
}
|
||||
self.settings["download_path_templates"] = templates
|
||||
self._save_settings()
|
||||
|
||||
return templates.get(
|
||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||
)
|
||||
return templates.get(model_type, "{base_model}/{first_tag}")
|
||||
|
||||
|
||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||
|
||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
||||
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
||||
# Add auto-organize progress tracking
|
||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
||||
# Add recipe repair progress tracking
|
||||
self._recipe_repair_progress: Optional[Dict[str, Any]] = None
|
||||
# Add recipe rematch progress tracking
|
||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||
self._auto_organize_lock = asyncio.Lock()
|
||||
@@ -191,6 +193,14 @@ class WebSocketManager:
|
||||
# Broadcast via WebSocket
|
||||
await self.broadcast(data)
|
||||
|
||||
async def broadcast_recipe_repair_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast recipe repair progress to connected clients"""
|
||||
# Store progress data in memory
|
||||
self._recipe_repair_progress = data
|
||||
|
||||
# Broadcast via WebSocket
|
||||
await self.broadcast(data)
|
||||
|
||||
def get_auto_organize_progress(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get current auto-organize progress"""
|
||||
return self._auto_organize_progress
|
||||
@@ -199,6 +209,22 @@ class WebSocketManager:
|
||||
"""Clear auto-organize progress data"""
|
||||
self._auto_organize_progress = None
|
||||
|
||||
def get_recipe_repair_progress(self) -> Optional[Dict[str, Any]]:
|
||||
"""Get current recipe repair progress"""
|
||||
return self._recipe_repair_progress
|
||||
|
||||
def cleanup_recipe_repair_progress(self):
|
||||
"""Clear recipe repair progress data if it is in a finished state"""
|
||||
if self._recipe_repair_progress and self._recipe_repair_progress.get('status') in ['completed', 'cancelled', 'error']:
|
||||
self._recipe_repair_progress = None
|
||||
|
||||
def is_recipe_repair_running(self) -> bool:
|
||||
"""Check if recipe repair is currently running"""
|
||||
if not self._recipe_repair_progress:
|
||||
return False
|
||||
status = self._recipe_repair_progress.get('status')
|
||||
return status in ['started', 'processing']
|
||||
|
||||
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||
"""Broadcast recipe rematch progress to connected clients"""
|
||||
# Store progress data in memory
|
||||
|
||||
+1
-112
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any
|
||||
|
||||
NSFW_LEVELS = {
|
||||
"PG": 1,
|
||||
@@ -83,103 +83,6 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
||||
|
||||
# folder_paths key -> sub_type; single source of truth for extensibility.
|
||||
# Adding support for a new ComfyUI folder category is a one-line change here.
|
||||
OTHER_MODEL_FOLDER_SUBTYPES = {
|
||||
"vae": "vae",
|
||||
"upscale_models": "upscaler",
|
||||
"text_encoders": "text_encoder",
|
||||
"clip": "text_encoder", # legacy ComfyUI key
|
||||
"clip_vision": "clip_vision",
|
||||
"controlnet": "controlnet",
|
||||
}
|
||||
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
|
||||
# Sub-types managed when the (opt-in) Other Models feature is switched on.
|
||||
# The feature itself defaults to off (``enable_other_models`` = False), so
|
||||
# nothing here is scanned until the user enables it.
|
||||
#
|
||||
# The default set is deliberately limited to the dependency-style assets every
|
||||
# pipeline needs and where "which one am I actually using" is the real problem:
|
||||
# VAE, upscalers and text encoders. ``clip_vision`` and ``controlnet`` are
|
||||
# workflow-driven instead (IPAdapter/SVD, per-workflow ControlNet variants) and
|
||||
# ControlNet libraries routinely run to dozens of files, so both stay opt-in
|
||||
# and are treated symmetrically.
|
||||
DEFAULT_ENABLED_OTHER_SUB_TYPES: List[str] = [
|
||||
"vae",
|
||||
"upscaler",
|
||||
"text_encoder",
|
||||
]
|
||||
|
||||
|
||||
def other_sub_type_folder_keys() -> Dict[str, List[str]]:
|
||||
"""Invert OTHER_MODEL_FOLDER_SUBTYPES into sub_type -> folder_paths keys.
|
||||
|
||||
``text_encoder`` maps to two folder keys (``text_encoders`` and the legacy
|
||||
``clip``), so every consumer that resolves a sub_type back to folders must
|
||||
merge both.
|
||||
"""
|
||||
mapping: Dict[str, List[str]] = {}
|
||||
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||
mapping.setdefault(sub_type, []).append(folder_key)
|
||||
return mapping
|
||||
|
||||
|
||||
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
|
||||
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
|
||||
|
||||
|
||||
def normalize_other_sub_types(value: Any) -> List[str]:
|
||||
"""Normalize a stored/requested enabled-sub_type list.
|
||||
|
||||
Unknown values and duplicates are dropped; the result follows the
|
||||
canonical VALID_OTHER_SUB_TYPES order so the stored setting and the UI
|
||||
stay stable. Non-list input falls back to the defaults.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
candidates: Any = [value]
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
candidates = value
|
||||
else:
|
||||
return list(DEFAULT_ENABLED_OTHER_SUB_TYPES)
|
||||
|
||||
allowed = {item for item in candidates if isinstance(item, str)}
|
||||
return [sub_type for sub_type in VALID_OTHER_SUB_TYPES if sub_type in allowed]
|
||||
# CivitAI model.type values accepted by the "other" page's fetch-metadata
|
||||
# validation (lowercased). CLIP/CLIPVision are retired upstream but still
|
||||
# appear on grandfathered models.
|
||||
VALID_OTHER_CIVITAI_TYPES = {
|
||||
"vae",
|
||||
"upscaler",
|
||||
"textencoder",
|
||||
"clip",
|
||||
"clipvision",
|
||||
"controlnet",
|
||||
"other",
|
||||
}
|
||||
# CivitAI model.type -> internal sub_type for the "other" model page.
|
||||
CIVITAI_TYPE_TO_OTHER_SUB_TYPE = {
|
||||
"vae": "vae",
|
||||
"upscaler": "upscaler",
|
||||
"textencoder": "text_encoder",
|
||||
"clip": "text_encoder",
|
||||
"clipvision": "clip_vision",
|
||||
"controlnet": "controlnet",
|
||||
}
|
||||
|
||||
# CivitAI ModelFile.type values -> internal sub_type for the "other" model
|
||||
# page. Used for download routing only, and strictly as an explicit user file
|
||||
# pick or a fallback when model.type maps to nothing — checkpoint models
|
||||
# routinely bundle VAE/Text Encoder component files, so file types must never
|
||||
# override a mapped model.type.
|
||||
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
|
||||
"VAE": "vae",
|
||||
"Upscaler": "upscaler",
|
||||
"Text Encoder": "text_encoder",
|
||||
"Vision Encoder": "clip_vision",
|
||||
"CLIPVision": "clip_vision",
|
||||
"ControlNet": "controlnet",
|
||||
}
|
||||
|
||||
# Backward compatibility alias
|
||||
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
||||
|
||||
@@ -188,7 +91,6 @@ CIVITAI_USER_MODEL_TYPES = [
|
||||
*VALID_LORA_TYPES,
|
||||
"textualinversion",
|
||||
"checkpoint",
|
||||
*sorted(VALID_OTHER_CIVITAI_TYPES),
|
||||
]
|
||||
|
||||
# Default chunk size in megabytes used for hashing large files.
|
||||
@@ -257,19 +159,6 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
||||
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
||||
}
|
||||
|
||||
# Default download path template for each model type. "other" defaults to a
|
||||
# flat layout (empty template) on purpose: other-model downloads are already
|
||||
# separated by sub_type roots (default_other_roots), and priority_tags has no
|
||||
# "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and
|
||||
# scatter files into unstable folders. Users can still opt in to a template by
|
||||
# writing "other" into download_path_templates in settings.json.
|
||||
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
|
||||
"lora": "{base_model}/{first_tag}",
|
||||
"checkpoint": "{base_model}/{first_tag}",
|
||||
"embedding": "{base_model}/{first_tag}",
|
||||
"other": "",
|
||||
}
|
||||
|
||||
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
||||
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||
|
||||
@@ -420,10 +420,6 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
if "other" in model_types:
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
scanners.append(("other", other_scanner))
|
||||
|
||||
# Load progress file to check processed models (async to avoid blocking)
|
||||
settings_manager = get_settings_manager()
|
||||
active_library = settings_manager.get_active_library_name()
|
||||
@@ -604,10 +600,6 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
if "other" in model_types:
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
scanners.append(("other", other_scanner))
|
||||
|
||||
# Get all models
|
||||
all_models = []
|
||||
for scanner_type, scanner in scanners:
|
||||
@@ -1106,10 +1098,6 @@ class DownloadManager:
|
||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||
scanners.append(("embedding", embedding_scanner))
|
||||
|
||||
if "other" in model_types:
|
||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||
scanners.append(("other", other_scanner))
|
||||
|
||||
# Find the specified models
|
||||
models_to_process = []
|
||||
for scanner_type, scanner in scanners:
|
||||
|
||||
+7
-56
@@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict, field
|
||||
from typing import Callable, Dict, Optional, List, Any
|
||||
from datetime import datetime
|
||||
import os
|
||||
from .constants import CIVITAI_TYPE_TO_OTHER_SUB_TYPE, INVALID_AUTOV3_EMPTY_HASH
|
||||
from .constants import INVALID_AUTOV3_EMPTY_HASH
|
||||
from .model_utils import determine_base_model
|
||||
|
||||
|
||||
@@ -77,6 +77,9 @@ class BaseModelMetadata:
|
||||
last_checked_at: float = 0 # Last checked timestamp
|
||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
||||
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
|
||||
trainedWords: List[str] = field(
|
||||
default_factory=list
|
||||
) # Trigger words / activation prompts (source-agnostic)
|
||||
_unknown_fields: Dict[str, Any] = field(
|
||||
default_factory=dict, repr=False, compare=False
|
||||
) # Store unknown fields
|
||||
@@ -89,6 +92,9 @@ class BaseModelMetadata:
|
||||
if self.tags is None:
|
||||
self.tags = []
|
||||
|
||||
if self.trainedWords is None:
|
||||
self.trainedWords = []
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
||||
"""Create instance from dictionary"""
|
||||
@@ -318,61 +324,6 @@ class CheckpointMetadata(BaseModelMetadata):
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class OtherModelMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for an "other" model (VAE, upscaler,
|
||||
text encoder, CLIP vision, ControlNet, ...).
|
||||
|
||||
The sub_type is location-derived: the OtherScanner sets it from the
|
||||
folder_paths category whose root contains the file. The dataclass default
|
||||
is only a placeholder.
|
||||
"""
|
||||
|
||||
sub_type: str = "vae" # Placeholder; overridden by the scanner hooks
|
||||
|
||||
@classmethod
|
||||
def from_civitai_info(
|
||||
cls, version_info: Dict[str, Any], file_info: Dict[str, Any], save_path: str
|
||||
) -> "OtherModelMetadata":
|
||||
"""Create OtherModelMetadata instance from Civitai version info"""
|
||||
file_name = file_info.get("name", "")
|
||||
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
|
||||
# Map the CivitAI model type onto our sub_types; unknown types keep the
|
||||
# placeholder until the scanner re-derives sub_type from the location.
|
||||
# The type lives at version["model"]["type"], not version["type"].
|
||||
civitai_type = str((version_info.get("model") or {}).get("type", "") or "").lower()
|
||||
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(civitai_type, "vae")
|
||||
|
||||
# Extract tags and description if available
|
||||
tags = []
|
||||
description = ""
|
||||
model_data = version_info.get("model") or {}
|
||||
if "tags" in model_data:
|
||||
tags = model_data["tags"]
|
||||
if "description" in model_data:
|
||||
description = model_data["description"]
|
||||
|
||||
return cls(
|
||||
file_name=os.path.splitext(file_name)[0],
|
||||
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||
file_path=save_path.replace(os.sep, "/"),
|
||||
size=file_info.get("sizeKB", 0) * 1024,
|
||||
modified=datetime.now().timestamp(),
|
||||
sha256=sha256_value,
|
||||
base_model=base_model,
|
||||
preview_url="", # Will be updated after preview download
|
||||
preview_nsfw_level=0,
|
||||
from_civitai=True,
|
||||
civitai=version_info,
|
||||
sub_type=sub_type,
|
||||
tags=tags,
|
||||
modelDescription=description,
|
||||
# Direct read: the downloaded file IS file_info, no SHA256 matching.
|
||||
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EmbeddingMetadata(BaseModelMetadata):
|
||||
"""Represents the metadata structure for an Embedding model"""
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "comfyui-lora-manager"
|
||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||
version = "1.2.2"
|
||||
version = "1.2.1"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"aiohttp",
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
"C:/path/to/your/embeddings_folder",
|
||||
"C:/path/to/another/embeddings_folder"
|
||||
]
|
||||
}
|
||||
},
|
||||
"auto_organize_exclusions": []
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
}
|
||||
|
||||
.header-container {
|
||||
max-width: none;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 0 15px;
|
||||
display: flex;
|
||||
@@ -38,6 +38,19 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Responsive header container for larger screens */
|
||||
@media (min-width: 2150px) {
|
||||
.header-container {
|
||||
max-width: 1800px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 3000px) {
|
||||
.header-container {
|
||||
max-width: 2400px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo and title styling */
|
||||
.header-branding {
|
||||
display: flex;
|
||||
@@ -83,12 +96,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Opt-in pages (e.g. Other Models) hide their nav entry until enabled.
|
||||
A class is used instead of [hidden] because .nav-item sets display: flex. */
|
||||
.nav-item--hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item:focus-visible {
|
||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||
@@ -113,9 +120,6 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
max-width: 600px;
|
||||
/* No hard floor: the field shrinks with the available space instead of parking
|
||||
at a fixed width and crowding its own placeholder (see the 1366px query). */
|
||||
min-width: 0;
|
||||
margin: 0 auto;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
@@ -124,7 +128,6 @@
|
||||
.header-search .search-container {
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -146,12 +149,7 @@
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
padding-left: 2.25rem !important;
|
||||
/* Reserve exactly the inline chrome so typed text never runs under it:
|
||||
cue(58) + clear(28) + toggles(28 + 28 + 4 gap) + edges(8 + 8) = 126px.
|
||||
Below 1366px the cue is hidden and the reservation drops to 68px.
|
||||
!important is required: search-filter.css loads later and sets its own
|
||||
right padding at equal specificity (.search-container input). */
|
||||
padding-right: 7.875rem !important;
|
||||
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
@@ -699,20 +697,6 @@
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
/* Responsive: the Ctrl+F cue is pure decoration and, above 950px, the widest
|
||||
thing inside the field. Below 1366px the header (branding + full nav) leaves
|
||||
too little room for it, so it steps aside and the field reclaims its 58px.
|
||||
The shortcut itself keeps working - only the visual hint is dropped. */
|
||||
@media (max-width: 1366px) {
|
||||
.header-search .search-shortcut-cue {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-search input {
|
||||
padding-right: 4.25rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||
@media (max-width: 1200px) {
|
||||
.header-container {
|
||||
@@ -732,6 +716,11 @@
|
||||
.header-controls {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.header-controls > div {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive: Hide nav icons at 1100px to save space */
|
||||
@@ -808,12 +797,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* For narrower screens - switch nav to icons only.
|
||||
A labelled nav needs ~383px and a readable search field needs ~300px, so the
|
||||
two cannot coexist below ~700px: at 601-700px the search input was previously
|
||||
squeezed to 200px, leaving only ~96px of text room and overlapping the
|
||||
placeholder with the inline toggles. Labels therefore collapse here. */
|
||||
@media (max-width: 700px) {
|
||||
/* For very small screens - switch nav to icons only */
|
||||
@media (max-width: 600px) {
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.main-nav {
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
@@ -821,7 +811,8 @@
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.25rem 0.4rem;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-item span {
|
||||
@@ -830,22 +821,6 @@
|
||||
|
||||
.nav-item i {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* For very small screens - tighten container spacing */
|
||||
@media (max-width: 600px) {
|
||||
.header-container {
|
||||
padding: 0 8px;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
padding: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,145 +592,3 @@ button:disabled,
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Recipe Rematch Options Modal */
|
||||
#rematchOptionsModal .modal-body {
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .confirmation-message {
|
||||
color: var(--text-color);
|
||||
margin-bottom: var(--space-3);
|
||||
font-size: 1em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Selectable option card — click anywhere toggles the checkbox (label wrap).
|
||||
Checkmark follows the batch-import modal's custom checkbox pattern. */
|
||||
#rematchOptionsModal .rematch-option-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--border-radius-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card:hover {
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
|
||||
border-color: var(--lora-accent);
|
||||
background: oklch(from var(--lora-accent) l c h / 0.08);
|
||||
}
|
||||
|
||||
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
|
||||
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
|
||||
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-checkmark {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: var(--transition-base);
|
||||
background: var(--bg-color);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
|
||||
background: var(--lora-accent);
|
||||
border-color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
|
||||
content: '\f00c';
|
||||
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||
font-weight: 900;
|
||||
color: var(--lora-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-color);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-caveat {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
font-size: 0.85em;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
#rematchOptionsModal .rematch-option-caveat i {
|
||||
color: var(--lora-accent);
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
|
||||
stat cards / failure table / summary header come from
|
||||
metadata-refresh-result.css and download-batch-summary.css). */
|
||||
.rematch-summary-modal {
|
||||
max-width: 700px;
|
||||
}
|
||||
|
||||
.rematch-cancelled-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin: 0 0 var(--space-2) 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.rematch-cancelled-note i {
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Review section heading uses the accent (review, not failure) instead of
|
||||
the failure-section error color. */
|
||||
.rematch-review-section h4 {
|
||||
color: var(--lora-accent);
|
||||
}
|
||||
|
||||
#rematchSummaryModal .rematch-undo-btn {
|
||||
padding: var(--space-1) var(--space-2);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
|
||||
text-decoration: line-through;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@@ -1744,39 +1744,3 @@ input:checked + .toggle-slider:before {
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Other Models opt-in: sub_type checkbox row */
|
||||
.other-subtype-checkboxes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 6px 14px;
|
||||
}
|
||||
|
||||
.other-subtype-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-color);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.other-subtype-checkbox input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.other-subtype-toggles.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.other-subtype-toggles.is-disabled .other-subtype-checkbox {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Disabled default-root selects for switched-off sub_types / feature */
|
||||
.select-control select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@
|
||||
z-index: var(--z-toast);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* No align-items (defaults to stretch) so every toast shares one equal width */
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
padding: 8px 20px 0; /* Small breathing room below the header */
|
||||
pointer-events: none; /* Allow clicking through the container */
|
||||
|
||||
+37
-68
@@ -25,23 +25,6 @@
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Wrapper around the controls bar and breadcrumb nav. With the sticky-controls
|
||||
setting off it is transparent to layout (display: contents), preserving the
|
||||
original behavior (only the breadcrumb stays visible). When enabled, the whole
|
||||
wrapper sticks as one unit so the two bars can never drift apart. */
|
||||
.sticky-topbar {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
body.sticky-controls .sticky-topbar {
|
||||
display: block;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: calc(var(--z-header) - 1);
|
||||
background: var(--bg-color);
|
||||
box-shadow: var(--shadow-xs);
|
||||
}
|
||||
|
||||
/* Responsive container for larger screens */
|
||||
@media (min-width: 2150px) {
|
||||
.container {
|
||||
@@ -59,10 +42,7 @@ body.sticky-controls .sticky-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* Push to the right of the row. Because it is also the flex item that is
|
||||
allowed to drop to a second row, an auto margin keeps it right-aligned on
|
||||
either row — no width: 100% / viewport breakpoint needed. */
|
||||
margin-left: auto;
|
||||
margin-left: auto; /* Push to the right */
|
||||
}
|
||||
|
||||
.actions {
|
||||
@@ -70,11 +50,7 @@ body.sticky-controls .sticky-topbar {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
|
||||
viewport width. Viewport-based wrapping wasted space on high-DPI displays
|
||||
(e.g. a 2560px monitor at 200% scaling reports a ~1280px CSS viewport even
|
||||
when the window is maximized). */
|
||||
flex-wrap: wrap;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -82,11 +58,7 @@ body.sticky-controls .sticky-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
/* Let the group shrink rather than overflow so .controls-right only wraps
|
||||
when it really has to. */
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
/* Action button styling */
|
||||
@@ -95,9 +67,7 @@ body.sticky-controls .sticky-topbar {
|
||||
}
|
||||
|
||||
.control-group button {
|
||||
/* Keeps the toolbar visually even without forcing the row to overflow (the
|
||||
old 100px floor pushed the total past the container on wide screens). */
|
||||
min-width: 90px;
|
||||
min-width: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -231,17 +201,20 @@ body.sticky-controls .sticky-topbar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
margin-left: 6px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
border-radius: var(--border-radius-xs);
|
||||
background-color: var(--shortcut-bg);
|
||||
border: 1px solid var(--shortcut-border);
|
||||
box-shadow: var(--shortcut-shadow);
|
||||
color: var(--shortcut-text);
|
||||
vertical-align: middle;
|
||||
opacity: 0.8;
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
@@ -252,21 +225,10 @@ body.sticky-controls .sticky-topbar {
|
||||
border-color: var(--shortcut-border-hover);
|
||||
}
|
||||
|
||||
/* Invert the keycap on active (accent-filled) buttons for contrast.
|
||||
Must come after the hover rule above so it wins on active+hover. */
|
||||
.control-group button.active .shortcut-key,
|
||||
.control-group button.active:hover .shortcut-key {
|
||||
color: var(--lora-accent);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ensure correct vertical alignment for text+shortcut */
|
||||
.control-group button span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* Select dropdown styling */
|
||||
@@ -640,42 +602,49 @@ body.sticky-controls .sticky-topbar {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
|
||||
one row at common laptop/high-DPI widths. The buttons are allowed to shrink to
|
||||
their content (min-width: 0) here, which is what reclaims the space the old
|
||||
100px floor plus a forced wrap used to waste. .controls-right is deliberately
|
||||
NOT forced onto its own row: it stays inline while it fits and only drops to a
|
||||
second row (staying right-aligned through its auto margin) when it does not. */
|
||||
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
||||
@media (max-width: 1500px) {
|
||||
.actions {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.control-group button {
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
.controls-right {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
min-width: 0;
|
||||
/* Reduce button sizes to fit better */
|
||||
.control-group button {
|
||||
min-width: 80px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.actions {
|
||||
gap: var(--space-2);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
|
||||
.action-buttons {
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Narrow screens: let the right-hand group wrap below the buttons, still
|
||||
right-aligned. */
|
||||
|
||||
.controls-right {
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-1);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.control-group button:hover {
|
||||
|
||||
@@ -44,13 +44,6 @@
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
|
||||
.onboarding-target-highlight's position: relative would pull it into
|
||||
normal flow and it would move away from the spotlight cutout */
|
||||
.folder-sidebar.onboarding-target-highlight {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.onboarding-popup {
|
||||
position: absolute;
|
||||
background: var(--lora-surface);
|
||||
|
||||
@@ -9,8 +9,7 @@ import { state } from '../state/index.js';
|
||||
export const MODEL_TYPES = {
|
||||
LORA: 'loras',
|
||||
CHECKPOINT: 'checkpoints',
|
||||
EMBEDDING: 'embeddings',
|
||||
OTHER: 'other'
|
||||
EMBEDDING: 'embeddings' // Future model type
|
||||
};
|
||||
|
||||
// Base API configuration for each model type
|
||||
@@ -41,15 +40,6 @@ export const MODEL_CONFIG = {
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: true,
|
||||
templateName: 'embeddings.html'
|
||||
},
|
||||
[MODEL_TYPES.OTHER]: {
|
||||
displayName: 'Other Model',
|
||||
singularName: 'other',
|
||||
defaultPageSize: 100,
|
||||
supportsLetterFilter: false,
|
||||
supportsBulkOperations: true,
|
||||
supportsMove: true,
|
||||
templateName: 'other.html'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -143,10 +133,6 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
||||
},
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
|
||||
},
|
||||
[MODEL_TYPES.OTHER]: {
|
||||
metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`,
|
||||
roots_by_subtype: `/api/lm/${MODEL_TYPES.OTHER}/roots_by_subtype`,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -198,7 +184,6 @@ export const DOWNLOAD_ENDPOINTS = {
|
||||
downloadGet: '/api/lm/download-model-get',
|
||||
cancelGet: '/api/lm/cancel-download-get',
|
||||
progress: '/api/lm/download-progress',
|
||||
routing: '/api/lm/download/routing',
|
||||
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
|
||||
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { LoraApiClient } from './loraApi.js';
|
||||
import { CheckpointApiClient } from './checkpointApi.js';
|
||||
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||
import { OtherApiClient } from './otherApi.js';
|
||||
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
||||
import { state } from '../state/index.js';
|
||||
|
||||
@@ -13,8 +12,6 @@ export function createModelApiClient(modelType) {
|
||||
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
||||
case MODEL_TYPES.EMBEDDING:
|
||||
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
||||
case MODEL_TYPES.OTHER:
|
||||
return new OtherApiClient(MODEL_TYPES.OTHER);
|
||||
default:
|
||||
throw new Error(`Unsupported model type: ${modelType}`);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { BaseModelApiClient } from './baseModelApi.js';
|
||||
|
||||
/**
|
||||
* Other-models-specific API client (VAE, upscalers, text encoders, etc.)
|
||||
*/
|
||||
export class OtherApiClient extends BaseModelApiClient {
|
||||
/**
|
||||
* Get other-model roots, optionally narrowed to one sub_type
|
||||
* (vae/upscaler/text_encoder/clip_vision/controlnet).
|
||||
*
|
||||
* Without a sub_type this falls back to the merged roots list
|
||||
* (GET /api/lm/other/roots); with one it reads the grouped
|
||||
* roots_by_subtype map and extracts the matching list.
|
||||
*/
|
||||
async fetchModelRoots(subType = null) {
|
||||
if (!subType) {
|
||||
return super.fetchModelRoots();
|
||||
}
|
||||
|
||||
const data = await this.fetchRootsBySubType();
|
||||
const groupedRoots = data.roots_by_subtype || {};
|
||||
return {
|
||||
success: data.success !== false,
|
||||
roots: groupedRoots[subType] || [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get other-model roots grouped by sub_type.
|
||||
* GET /api/lm/other/roots_by_subtype
|
||||
* -> { success, roots_by_subtype: {sub_type: [...]} }
|
||||
*/
|
||||
async fetchRootsBySubType() {
|
||||
try {
|
||||
const response = await fetch(this.apiConfig.endpoints.specific.roots_by_subtype);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch other-model roots by sub_type');
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching other-model roots by sub_type:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ const RECIPE_ENDPOINTS = {
|
||||
move: '/api/lm/recipe/move',
|
||||
moveBulk: '/api/lm/recipes/move-bulk',
|
||||
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||
repairBulk: '/api/lm/recipes/repair-bulk',
|
||||
rematchBulk: '/api/lm/recipes/rematch-bulk',
|
||||
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
|
||||
};
|
||||
@@ -677,7 +678,7 @@ export class RecipeSidebarApiClient {
|
||||
};
|
||||
}
|
||||
|
||||
async rematchBulkModels(filePaths, options = {}) {
|
||||
async repairBulkModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
@@ -690,11 +691,36 @@ export class RecipeSidebarApiClient {
|
||||
throw new Error('No recipe IDs could be derived from file paths');
|
||||
}
|
||||
|
||||
const body = { recipe_ids: recipeIds };
|
||||
// Only sent when opted in — the strict body stays exactly
|
||||
// {recipe_ids} for backward compatibility.
|
||||
if (options.relaxed === true) {
|
||||
body.relaxed = true;
|
||||
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to repair recipes');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async rematchBulkModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
throw new Error('No recipe IDs could be derived from file paths');
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
|
||||
@@ -702,7 +728,9 @@ export class RecipeSidebarApiClient {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -41,9 +41,13 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
||||
const repairMetadataItem = this.menu.querySelector('[data-action="repair-metadata"]');
|
||||
const reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]');
|
||||
const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
|
||||
|
||||
if (repairMetadataItem) {
|
||||
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
|
||||
}
|
||||
if (reimportMetadataItem) {
|
||||
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
|
||||
}
|
||||
@@ -139,8 +143,8 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
|
||||
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
|
||||
if (downloadExampleImagesSubmenu) {
|
||||
// Show on model pages (loras, checkpoints, embeddings, other), hide on recipes
|
||||
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none';
|
||||
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
||||
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||
@@ -279,6 +283,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
||||
case 'delete-all':
|
||||
bulkManager.showBulkDeleteModal();
|
||||
break;
|
||||
case 'repair-metadata':
|
||||
bulkManager.repairSelectedRecipes();
|
||||
break;
|
||||
case 'rematch-metadata':
|
||||
bulkManager.rematchSelectedRecipes();
|
||||
break;
|
||||
|
||||
@@ -25,7 +25,6 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
this.updateEnrichMenuItem(card);
|
||||
|
||||
// Update the "Move to other root" label based on current model type
|
||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||
|
||||
@@ -4,8 +4,6 @@ import { translate } from '../../utils/i18nHelpers.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
|
||||
export class GlobalContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -25,6 +23,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
||||
const repairRecipesItem = this.menu.querySelector('[data-action="repair-recipes"]');
|
||||
const rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
|
||||
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||
@@ -42,6 +41,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
cleanupExamplesItem?.classList.add('hidden');
|
||||
excludedModelsItem?.classList.add('hidden');
|
||||
groupByModelItem?.classList.add('hidden');
|
||||
repairRecipesItem?.classList.remove('hidden');
|
||||
rematchRecipesItem?.classList.remove('hidden');
|
||||
} else {
|
||||
modelUpdateItem?.classList.remove('hidden');
|
||||
@@ -50,6 +50,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
cleanupExamplesItem?.classList.remove('hidden');
|
||||
excludedModelsItem?.classList.remove('hidden');
|
||||
groupByModelItem?.classList.remove('hidden');
|
||||
repairRecipesItem?.classList.add('hidden');
|
||||
rematchRecipesItem?.classList.add('hidden');
|
||||
}
|
||||
|
||||
@@ -94,6 +95,11 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
console.error('Failed to refresh missing license metadata:', error);
|
||||
});
|
||||
break;
|
||||
case 'repair-recipes':
|
||||
this.repairRecipes(menuItem).catch((error) => {
|
||||
console.error('Failed to repair recipes:', error);
|
||||
});
|
||||
break;
|
||||
case 'rematch-recipes':
|
||||
this.rematchRecipes(menuItem).catch((error) => {
|
||||
console.error('Failed to rematch recipes:', error);
|
||||
@@ -365,19 +371,100 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
return `${displayName}s`;
|
||||
}
|
||||
|
||||
async rematchRecipes(menuItem) {
|
||||
if (this._rematchInProgress) {
|
||||
async repairRecipes(menuItem) {
|
||||
if (this._repairInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect options (relaxed matching) before starting anything; the
|
||||
// run only begins when the user confirms the dialog.
|
||||
rematchModalManager.showOptionsModal({
|
||||
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
|
||||
});
|
||||
this._repairInProgress = true;
|
||||
menuItem?.classList.add('disabled');
|
||||
|
||||
const loadingMessage = translate(
|
||||
'globalContextMenu.repairRecipes.loading',
|
||||
{},
|
||||
'Repairing recipe data...'
|
||||
);
|
||||
|
||||
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
|
||||
progressUI?.showCancelButton(() => this.cancelRepair());
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipes/repair', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to start repair');
|
||||
}
|
||||
|
||||
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
|
||||
let isComplete = false;
|
||||
while (!isComplete && this._repairInProgress) {
|
||||
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
|
||||
if (progressResponse.ok) {
|
||||
const progressResult = await progressResponse.json();
|
||||
if (progressResult.success && progressResult.progress) {
|
||||
const p = progressResult.progress;
|
||||
if (p.status === 'processing') {
|
||||
const percent = (p.current / p.total) * 100;
|
||||
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
|
||||
} else if (p.status === 'completed') {
|
||||
isComplete = true;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.repairRecipes.success',
|
||||
{ count: p.repaired },
|
||||
`Repaired ${p.repaired} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
|
||||
// Refresh recipes page if active
|
||||
if (window.recipesPage) {
|
||||
window.recipesPage.refresh();
|
||||
}
|
||||
} else if (p.status === 'error') {
|
||||
throw new Error(p.error || 'Repair failed');
|
||||
} else if (p.status === 'cancelled') {
|
||||
isComplete = true;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.repairRecipes.cancelled',
|
||||
{ count: p.repaired },
|
||||
`Repair cancelled. ${p.repaired} recipes were repaired.`
|
||||
));
|
||||
showToast('globalContextMenu.repairRecipes.cancelled', { count: p.repaired }, 'info');
|
||||
}
|
||||
} else if (progressResponse.status === 404) {
|
||||
// Progress might have finished quickly and been cleaned up
|
||||
isComplete = true;
|
||||
progressUI?.complete();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isComplete) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recipe repair failed:', error);
|
||||
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
|
||||
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
|
||||
} finally {
|
||||
this._repairInProgress = false;
|
||||
menuItem?.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
async _startRematch(menuItem, relaxed = false) {
|
||||
async cancelRepair() {
|
||||
try {
|
||||
await fetch('/api/lm/recipes/cancel-repair', {
|
||||
method: 'POST',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel recipe repair:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async rematchRecipes(menuItem) {
|
||||
if (this._rematchInProgress) {
|
||||
return;
|
||||
}
|
||||
@@ -398,7 +485,6 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const response = await fetch('/api/lm/recipes/rematch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: !!relaxed }),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
@@ -426,32 +512,48 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||
const failures = p.errors || 0;
|
||||
const unresolved = p.unresolved_entries ?? 0;
|
||||
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
|
||||
// Complete no-op (nothing matched, nothing
|
||||
// unresolved, no errors) keeps the lightweight
|
||||
// toast; anything else opens the post-run summary
|
||||
// modal.
|
||||
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
|
||||
if (isNoop) {
|
||||
if (entries > 0) {
|
||||
const successKey = failures > 0
|
||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
||||
: 'globalContextMenu.rematchRecipes.success';
|
||||
const successText = failures > 0
|
||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
||||
progressUI?.complete(translate(
|
||||
successKey,
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
successText
|
||||
));
|
||||
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
|
||||
} else if (failures > 0) {
|
||||
// Nothing matched and at least one recipe
|
||||
// errored — "no rematch needed" would be
|
||||
// actively misleading here.
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.allFailed',
|
||||
{ total: p.total, recipes, entries, failures },
|
||||
`Rematch failed for ${failures} of ${p.total} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
|
||||
} else if (unresolved > 0) {
|
||||
// Entries existed but have no local model —
|
||||
// expected for models deleted from Civitai;
|
||||
// informational, not an error.
|
||||
const unresolvedRecipes = p.unresolved_recipes ?? 0;
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.noMatch',
|
||||
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
|
||||
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
|
||||
} else {
|
||||
// Everything was skipped (nothing to do).
|
||||
progressUI?.complete(translate(
|
||||
'globalContextMenu.rematchRecipes.success',
|
||||
{ count: recipes, recipes, entries, failures },
|
||||
`Matched ${entries} entries across ${recipes} recipes.`
|
||||
));
|
||||
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
||||
} else {
|
||||
progressUI?.complete();
|
||||
showRematchSummary({
|
||||
scope: 'global',
|
||||
total: p.total || 0,
|
||||
matchedRecipes: recipes,
|
||||
matchedEntries: entries,
|
||||
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||
unresolvedEntries: unresolved,
|
||||
skipped: p.skipped || 0,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
}
|
||||
// Refresh recipes page if active
|
||||
if (window.recipesPage) {
|
||||
@@ -468,23 +570,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
||||
));
|
||||
// A cancelled run still reports partial results
|
||||
// via the summary modal (marked as cancelled).
|
||||
showRematchSummary({
|
||||
scope: 'global',
|
||||
cancelled: true,
|
||||
total: p.total || 0,
|
||||
matchedRecipes: cancelledRecipes,
|
||||
matchedEntries: cancelledEntries,
|
||||
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||
unresolvedEntries: p.unresolved_entries ?? 0,
|
||||
skipped: p.skipped || 0,
|
||||
errors: p.errors || 0,
|
||||
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
|
||||
});
|
||||
if (window.recipesPage) {
|
||||
window.recipesPage.refresh();
|
||||
}
|
||||
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
||||
}
|
||||
} else if (progressResponse.status === 404) {
|
||||
// Progress might have finished quickly and been cleaned up
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
||||
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
|
||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
|
||||
@@ -26,6 +27,16 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
this.updateEnrichMenuItem(card);
|
||||
}
|
||||
|
||||
updateEnrichMenuItem(card) {
|
||||
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
||||
if (!enrichItem) return;
|
||||
const hasHfUrl = !!card.dataset.hf_url;
|
||||
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||
enrichItem.title = hasHfUrl
|
||||
? ''
|
||||
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
|
||||
}
|
||||
|
||||
handleMenuAction(action, menuItem) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
@@ -64,6 +75,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
case 'refresh-metadata':
|
||||
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'enrich-hf-llm':
|
||||
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
@@ -73,6 +87,68 @@ export class LoraContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
async enrichWithAgent(filePath) {
|
||||
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||
|
||||
const configured = await agentManager.isLlmConfigured();
|
||||
if (!configured) {
|
||||
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
agentManager.connect();
|
||||
|
||||
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||
'Enriching metadata with AI...'
|
||||
);
|
||||
|
||||
function cleanupCallbacks() {
|
||||
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||
}
|
||||
|
||||
const onProgress = (data) => {
|
||||
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||
if (state.virtualScroller?.updateSingleItem) {
|
||||
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||
}
|
||||
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||
const name = data.current_path.split('/').pop();
|
||||
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
||||
}
|
||||
};
|
||||
agentManager.onProgress(onProgress);
|
||||
|
||||
const onComplete = (data) => {
|
||||
cleanupCallbacks();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
progressUI.complete(data.summary || 'Enrich complete');
|
||||
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
||||
}
|
||||
};
|
||||
agentManager.onComplete(onComplete);
|
||||
|
||||
const onError = (data) => {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
||||
};
|
||||
agentManager.onError(onError);
|
||||
|
||||
try {
|
||||
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
||||
} catch (error) {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
sendLoraToWorkflow(replaceMode) {
|
||||
const card = this.currentCard;
|
||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||
|
||||
@@ -112,8 +112,7 @@ export const ModelContextMenuMixin = {
|
||||
const prefixMap = {
|
||||
lora: 'loras',
|
||||
checkpoint: 'checkpoints',
|
||||
embedding: 'embeddings',
|
||||
other: 'other'
|
||||
embedding: 'embeddings'
|
||||
};
|
||||
return prefixMap[this.modelType] || 'loras';
|
||||
},
|
||||
@@ -279,79 +278,6 @@ export const ModelContextMenuMixin = {
|
||||
setTimeout(() => urlInput.focus(), 50);
|
||||
},
|
||||
|
||||
// HF metadata enrichment (AI agent) methods
|
||||
updateEnrichMenuItem(card) {
|
||||
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
||||
if (!enrichItem) return;
|
||||
const hasHfUrl = !!card.dataset.hf_url;
|
||||
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||
enrichItem.title = hasHfUrl
|
||||
? ''
|
||||
: 'Link this model to a HuggingFace repo first (Link Model → Link to HuggingFace)';
|
||||
},
|
||||
|
||||
async enrichWithAgent(filePath) {
|
||||
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||
|
||||
const configured = await agentManager.isLlmConfigured();
|
||||
if (!configured) {
|
||||
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
agentManager.connect();
|
||||
|
||||
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||
'Enriching metadata with AI...'
|
||||
);
|
||||
|
||||
function cleanupCallbacks() {
|
||||
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||
}
|
||||
|
||||
const onProgress = (data) => {
|
||||
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||
if (state.virtualScroller?.updateSingleItem) {
|
||||
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||
}
|
||||
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||
const name = data.current_path.split('/').pop();
|
||||
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
||||
}
|
||||
};
|
||||
agentManager.onProgress(onProgress);
|
||||
|
||||
const onComplete = (data) => {
|
||||
cleanupCallbacks();
|
||||
|
||||
if (data.status === 'completed') {
|
||||
progressUI.complete(data.summary || 'Enrich complete');
|
||||
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
||||
}
|
||||
};
|
||||
agentManager.onComplete(onComplete);
|
||||
|
||||
const onError = (data) => {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
||||
};
|
||||
agentManager.onError(onError);
|
||||
|
||||
try {
|
||||
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
||||
} catch (error) {
|
||||
cleanupCallbacks();
|
||||
state.loadingManager.hide();
|
||||
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
||||
}
|
||||
},
|
||||
|
||||
parseModelId(value) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return null;
|
||||
@@ -446,10 +372,7 @@ export const ModelContextMenuMixin = {
|
||||
this.downloadExampleImages(true);
|
||||
return true;
|
||||
case 'civitai':
|
||||
// Gate on actual CivitAI data (not the `from_civitai` flag) so
|
||||
// that linking HuggingFace does not make the model look like it
|
||||
// has no CivitAI info (#1094).
|
||||
if (this.currentCard.dataset.has_civitai === 'true') {
|
||||
if (this.currentCard.dataset.from_civitai === 'true') {
|
||||
if (this.currentCard.querySelector('.fa-globe')) {
|
||||
this.currentCard.querySelector('.fa-globe').click();
|
||||
} else {
|
||||
@@ -465,9 +388,6 @@ export const ModelContextMenuMixin = {
|
||||
case 'link-hf':
|
||||
this.showLinkHfModal();
|
||||
return true;
|
||||
case 'enrich-hf-llm':
|
||||
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||
return true;
|
||||
case 'set-nsfw':
|
||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||
return true;
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||
|
||||
export class OtherContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
super('otherContextMenu', '.model-card');
|
||||
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||
this.modelType = 'other';
|
||||
this.resetAndReload = resetAndReload;
|
||||
|
||||
this.initNSFWSelector();
|
||||
}
|
||||
|
||||
// Implementation needed by the mixin
|
||||
async saveModelMetadata(filePath, data) {
|
||||
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||
}
|
||||
|
||||
showMenu(x, y, card) {
|
||||
super.showMenu(x, y, card);
|
||||
this.updateExcludeMenuItem();
|
||||
}
|
||||
|
||||
handleMenuAction(action) {
|
||||
// First try to handle with common actions
|
||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiClient = getModelApiClient();
|
||||
|
||||
// Otherwise handle other-models-specific actions
|
||||
switch(action) {
|
||||
case 'details':
|
||||
// Show model details
|
||||
this.currentCard.click();
|
||||
break;
|
||||
case 'replace-preview':
|
||||
// Add new action for replacing preview images
|
||||
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'delete':
|
||||
showDeleteModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'copyname':
|
||||
// Copy model name
|
||||
if (this.currentCard.querySelector('.fa-copy')) {
|
||||
this.currentCard.querySelector('.fa-copy').click();
|
||||
}
|
||||
break;
|
||||
case 'refresh-metadata':
|
||||
// Refresh metadata from CivitAI
|
||||
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'move':
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'exclude':
|
||||
showExcludeModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'restore':
|
||||
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in shared methods
|
||||
Object.assign(OtherContextMenu.prototype, ModelContextMenuMixin);
|
||||
@@ -6,9 +6,6 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
|
||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -96,6 +93,10 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Download missing LoRAs
|
||||
this.downloadMissingLoRAs(recipeId);
|
||||
break;
|
||||
case 'repair':
|
||||
// Repair recipe metadata
|
||||
this.repairRecipe(recipeId);
|
||||
break;
|
||||
case 'rematch':
|
||||
// Rematch recipe resources to local models
|
||||
this.rematchRecipe(recipeId);
|
||||
@@ -296,6 +297,44 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
}
|
||||
}
|
||||
|
||||
// Repair recipe metadata
|
||||
async repairRecipe(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
showToast('recipes.contextMenu.repair.starting', {}, 'info');
|
||||
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
if (result.repaired > 0) {
|
||||
showToast('recipes.contextMenu.repair.success', {}, 'success');
|
||||
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
if (detailResponse.ok) {
|
||||
const updatedRecipe = await detailResponse.json();
|
||||
const filePath = this.currentCard?.dataset?.filepath;
|
||||
if (filePath && state.virtualScroller) {
|
||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Repair failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error repairing recipe:', error);
|
||||
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async rematchRecipe(recipeId) {
|
||||
if (!recipeId) {
|
||||
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
|
||||
@@ -305,36 +344,26 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Capture before any await: the menu's click handler nulls currentCard
|
||||
const filePath = this.currentCard?.dataset?.filepath;
|
||||
|
||||
// Collect options (relaxed matching) before starting anything; the
|
||||
// run only begins when the user confirms the dialog.
|
||||
rematchModalManager.showOptionsModal({
|
||||
scope: 'single',
|
||||
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
|
||||
});
|
||||
}
|
||||
|
||||
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
|
||||
try {
|
||||
showToast('Rematching recipe to local models...', {}, 'info');
|
||||
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ relaxed: !!relaxed }),
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
const matchedEntries = result.matched_entries || result.rematched || 0;
|
||||
const failures = result.errors || 0;
|
||||
const unresolvedEntries = result.unresolved_entries || 0;
|
||||
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||
// Complete no-op (nothing matched, nothing unresolved, no
|
||||
// errors) keeps the lightweight toast; anything else opens
|
||||
// the post-run summary modal.
|
||||
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||
|
||||
if (matchedEntries > 0) {
|
||||
const toastKey = failures > 0
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
toastKey,
|
||||
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
|
||||
failures > 0 ? 'warning' : 'success'
|
||||
);
|
||||
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||
if (detailResponse.ok) {
|
||||
const updatedRecipe = await detailResponse.json();
|
||||
@@ -342,22 +371,16 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoop) {
|
||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||
} else if (result.unresolved_entries > 0) {
|
||||
// Entries existed but have no local model — expected for
|
||||
// models deleted from Civitai; informational, not an error.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showRematchSummary({
|
||||
scope: 'single',
|
||||
total: 1,
|
||||
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
|
||||
matchedEntries,
|
||||
unresolvedRecipes: result.unresolved_recipes || 0,
|
||||
unresolvedEntries,
|
||||
skipped: result.skipped || 0,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||
}
|
||||
} else {
|
||||
throw new Error(result.error || 'Rematch failed');
|
||||
@@ -374,24 +397,6 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
return;
|
||||
}
|
||||
|
||||
// Recipes imported from a CivitAI image page can carry incomplete
|
||||
// metadata (0 LoRAs); the companion browser extension can re-import
|
||||
// them with the full page data. Fall back to the native path whenever
|
||||
// the extension is absent, unlicensed, or the delegation fails.
|
||||
const recipeItem = state.virtualScroller?.items?.find(item => item?.id === recipeId);
|
||||
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
|
||||
if (civitaiImage) {
|
||||
try {
|
||||
const probe = await probeExtension();
|
||||
if (probe?.supported && probe?.licenseValid) {
|
||||
await this.reimportViaExtension(recipeId, civitaiImage, recipeItem?.title || '');
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Extension re-import unavailable, using native path:', error);
|
||||
}
|
||||
}
|
||||
|
||||
state.loadingManager.showSimpleLoading('Re-importing recipe from source...');
|
||||
|
||||
try {
|
||||
@@ -414,34 +419,6 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-import a single CivitAI-image recipe through the companion browser
|
||||
// extension. Throws on delegation failure so the caller can fall back to
|
||||
// the native path.
|
||||
async reimportViaExtension(recipeId, civitaiImage, title) {
|
||||
state.loadingManager.showSimpleLoading('Re-importing recipe via browser extension...');
|
||||
|
||||
try {
|
||||
const { failed } = await delegateReimport([{
|
||||
recipeId,
|
||||
imageId: civitaiImage.imageId,
|
||||
imageUrl: civitaiImage.imageUrl,
|
||||
title,
|
||||
}]);
|
||||
|
||||
state.loadingManager.hide();
|
||||
if (failed > 0) {
|
||||
showToast('recipes.contextMenu.reimport.failed', { message: 'Extension re-import failed' }, 'error');
|
||||
} else {
|
||||
showToast('toast.recipes.reimportSuccess', {}, 'success');
|
||||
}
|
||||
const { resetAndReload } = await import('../../api/recipeApi.js');
|
||||
resetAndReload(false, { preserveScroll: false });
|
||||
} catch (error) {
|
||||
state.loadingManager.hide();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mix in shared methods from ModelContextMenuMixin
|
||||
|
||||
@@ -2,7 +2,6 @@ export { LoraContextMenu } from './LoraContextMenu.js';
|
||||
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||
export { OtherContextMenu } from './OtherContextMenu.js';
|
||||
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||
|
||||
@@ -10,7 +9,6 @@ import { LoraContextMenu } from './LoraContextMenu.js';
|
||||
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||
import { OtherContextMenu } from './OtherContextMenu.js';
|
||||
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||
|
||||
// Factory method to create page-specific context menu instances
|
||||
@@ -24,8 +22,6 @@ export function createPageContextMenu(pageType) {
|
||||
return new CheckpointContextMenu();
|
||||
case 'embeddings':
|
||||
return new EmbeddingContextMenu();
|
||||
case 'other':
|
||||
return new OtherContextMenu();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,6 @@ export class HeaderManager {
|
||||
if (path.includes('/loras/recipes')) return 'recipes';
|
||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||
if (path.includes('/embeddings')) return 'embeddings';
|
||||
if (path.includes('/other')) return 'other';
|
||||
if (path.includes('/statistics')) return 'statistics';
|
||||
if (path.includes('/loras')) return 'loras';
|
||||
return 'unknown';
|
||||
@@ -50,18 +49,6 @@ export class HeaderManager {
|
||||
initializeCommonElements() {
|
||||
this.initializeThemePopover();
|
||||
|
||||
// Header icon buttons are divs with role="button"; make Enter/Space activate them
|
||||
const headerControls = document.getElementById('headerControls');
|
||||
if (headerControls) {
|
||||
headerControls.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const target = e.target.closest('[role="button"]');
|
||||
if (!target || !headerControls.contains(target)) return;
|
||||
e.preventDefault();
|
||||
target.click();
|
||||
});
|
||||
}
|
||||
|
||||
const settingsToggle = document.querySelector('.settings-toggle');
|
||||
if (settingsToggle) {
|
||||
settingsToggle.addEventListener('click', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Recipe Modal Component
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
@@ -1078,9 +1078,8 @@ class RecipeModal {
|
||||
|
||||
// Mirror the checkpoint "broken" rule: deleted, an
|
||||
// unresolvable hash, or a name-only remnant with no CivitAI
|
||||
// identifiers at all cannot be fixed by downloading, so no
|
||||
// download button is offered. Reconnect is always available
|
||||
// for missing entries (see renderLoraItemActions).
|
||||
// identifiers at all cannot be fixed by downloading —
|
||||
// reconnecting a local LoRA is the only remediation.
|
||||
const needsReconnect = !existsLocally
|
||||
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
||||
|
||||
@@ -1181,7 +1180,7 @@ class RecipeModal {
|
||||
</div>
|
||||
${actionsRow}
|
||||
</div>
|
||||
${!existsLocally ? `
|
||||
${needsReconnect ? `
|
||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||
<div class="reconnect-instructions">
|
||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||
@@ -2854,7 +2853,11 @@ class RecipeModal {
|
||||
* the model cannot be resolved — never for transient transport errors.
|
||||
*/
|
||||
_isUnresolvableDownloadError(message) {
|
||||
return isUnresolvableDownloadError(message);
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
const text = String(message).toLowerCase();
|
||||
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
|
||||
}
|
||||
|
||||
getResourceCivitaiUrl(resource) {
|
||||
@@ -2874,14 +2877,12 @@ class RecipeModal {
|
||||
|
||||
canDownloadLora(lora) {
|
||||
if (!lora) return false;
|
||||
const modelId = lora.modelId || lora.modelID || lora.model_id;
|
||||
const versionId = lora.id || lora.modelVersionId;
|
||||
// A bare CivitAI version id is enough: it uniquely pins the exact
|
||||
// file, and downloadRecipeLora resolves the owning model id from the
|
||||
// version on demand (the same fallback the bulk "download missing"
|
||||
// flow uses). A hash alone is likewise sufficient. A model id without
|
||||
// an exact version id is NOT enough — downloading the model's latest
|
||||
// version could silently mismatch the recipe's pinned version.
|
||||
return !!(versionId || lora.hash);
|
||||
// Direct download needs both identifiers; a hash alone is enough
|
||||
// because downloadRecipeLora resolves it to a version on demand —
|
||||
// the same fallback the bulk "download missing" flow uses.
|
||||
return !!((modelId && versionId) || lora.hash);
|
||||
}
|
||||
|
||||
renderCivitaiLink(url) {
|
||||
@@ -2912,9 +2913,19 @@ class RecipeModal {
|
||||
}
|
||||
|
||||
const controls = [];
|
||||
if (!needsReconnect) {
|
||||
if (needsReconnect) {
|
||||
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
|
||||
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
|
||||
controls.push(`
|
||||
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
|
||||
<i class="fas fa-link" aria-hidden="true"></i>
|
||||
<span>${escapeHtml(reconnectLabel)}</span>
|
||||
</button>
|
||||
`);
|
||||
} else {
|
||||
// needsReconnect already implies canDownloadLora() here, so the
|
||||
// download action is unconditional in this branch.
|
||||
// download action is unconditional.
|
||||
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||
controls.push(`
|
||||
@@ -2925,18 +2936,6 @@ class RecipeModal {
|
||||
</button>
|
||||
`);
|
||||
}
|
||||
// Reconnect is always offered for missing entries — when the LoRA
|
||||
// already exists locally under a different hash, downloading first
|
||||
// just to flip the button would be a waste.
|
||||
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
|
||||
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
|
||||
controls.push(`
|
||||
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
|
||||
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
|
||||
<i class="fas fa-link" aria-hidden="true"></i>
|
||||
<span>${escapeHtml(reconnectLabel)}</span>
|
||||
</button>
|
||||
`);
|
||||
|
||||
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
|
||||
}
|
||||
@@ -2992,9 +2991,6 @@ class RecipeModal {
|
||||
* Resolve the Civitai model/version identifiers needed for download.
|
||||
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
|
||||
* it through the same endpoint the bulk "download missing" flow uses.
|
||||
* Version-only entries (page-imported recipes whose CivitAI version has
|
||||
* no sha256) are resolved through the version endpoint, which returns
|
||||
* the owning model id.
|
||||
*/
|
||||
async resolveLoraDownloadIdentifiers(lora) {
|
||||
let modelId = lora.modelId || lora.modelID || lora.model_id;
|
||||
@@ -3005,41 +3001,21 @@ class RecipeModal {
|
||||
return { modelId, versionId, versionName };
|
||||
}
|
||||
|
||||
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
|
||||
// model/version through the same endpoint the bulk "download
|
||||
// missing" flow uses.
|
||||
if (lora.hash) {
|
||||
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
|
||||
const versionInfo = await response.json();
|
||||
if (versionInfo?.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
modelId = versionInfo.modelId || versionInfo.model?.id;
|
||||
versionId = versionInfo.id;
|
||||
versionName = versionInfo.name || versionName;
|
||||
|
||||
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
||||
if (!lora.hash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Version-only entries (page-imported recipes whose CivitAI versions
|
||||
// expose no sha256): the version id still pins the exact file, so
|
||||
// resolve the owning model id from the version endpoint on demand.
|
||||
if (versionId) {
|
||||
const response = await fetch(`/api/lm/loras/civitai/model/version/${versionId}`);
|
||||
const versionInfo = await response.json();
|
||||
if (!versionInfo || versionInfo?.error === 'Model not found') {
|
||||
return null;
|
||||
}
|
||||
|
||||
modelId = versionInfo.modelId || versionInfo.model?.id;
|
||||
versionId = versionInfo.id || versionId;
|
||||
versionName = versionInfo.name || versionName;
|
||||
|
||||
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
||||
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
|
||||
const versionInfo = await response.json();
|
||||
if (versionInfo?.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
modelId = versionInfo.modelId || versionInfo.model?.id;
|
||||
versionId = versionInfo.id;
|
||||
versionName = versionInfo.name || versionName;
|
||||
|
||||
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
|
||||
/**
|
||||
* Escape HTML entities in a string to prevent injection when interpolating
|
||||
* into innerHTML (same approach as DownloadBatchSummaryModal).
|
||||
* @param {string} str - The string to escape
|
||||
* @returns {string} - The escaped string
|
||||
*/
|
||||
function _escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(str);
|
||||
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the 3-state summary header (mirrors the batch download/import
|
||||
* summary semantics).
|
||||
*
|
||||
* - error: nothing matched and at least one recipe errored
|
||||
* - warning: errors, unresolved entries, filename-level (L4) matches to
|
||||
* review, or a cancelled run
|
||||
* - success: otherwise
|
||||
*/
|
||||
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
|
||||
if (matchedEntries === 0 && errors > 0) {
|
||||
return {
|
||||
state: 'error',
|
||||
icon: 'fa-times-circle',
|
||||
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
|
||||
};
|
||||
}
|
||||
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
|
||||
return {
|
||||
state: 'warning',
|
||||
icon: 'fa-exclamation-circle',
|
||||
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: 'success',
|
||||
icon: 'fa-check-circle',
|
||||
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
|
||||
* L4 rows undone so far, so the report reflects the undo status at copy time.
|
||||
*/
|
||||
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
|
||||
const scopeFallbacks = {
|
||||
global: 'All recipes',
|
||||
bulk: 'Selected recipes',
|
||||
single: 'Single recipe',
|
||||
};
|
||||
const scopeLabel = translate(
|
||||
`modals.rematchSummary.scope_${scope}`,
|
||||
{},
|
||||
scopeFallbacks[scope] || scope
|
||||
);
|
||||
const lines = [
|
||||
'=== Recipe Rematch Report ===',
|
||||
`Date: ${new Date().toLocaleString()}`,
|
||||
`Scope: ${scopeLabel}`,
|
||||
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
|
||||
`Total recipes: ${total}`,
|
||||
`Matched recipes: ${matchedRecipes}`,
|
||||
`Matched entries: ${matchedEntries}`,
|
||||
`Needs review (filename matches): ${l4Matches.length}`,
|
||||
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
|
||||
`Skipped: ${skipped}`,
|
||||
`Errors: ${errors}`,
|
||||
'',
|
||||
];
|
||||
if (l4Matches.length > 0) {
|
||||
lines.push('--- Filename matches (L4) ---');
|
||||
l4Matches.forEach((match, i) => {
|
||||
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
|
||||
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
|
||||
});
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('====================');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle a successful clipboard write: confirm via toast and briefly swap the
|
||||
* trigger button to a "Copied!" state (mirrors the batch summary modal).
|
||||
*/
|
||||
function _onCopyReportSuccess(btn) {
|
||||
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||
if (btn) {
|
||||
const origHTML = btn.innerHTML;
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback for environments without the async Clipboard API (e.g. insecure
|
||||
* contexts over LAN http): copy via a hidden textarea and execCommand.
|
||||
*/
|
||||
function _copyReportWithExecCommand(text) {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||
}
|
||||
|
||||
function _copyReport(btn, reportArgs) {
|
||||
const text = _buildReportText(reportArgs);
|
||||
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => _onCopyReportSuccess(btn))
|
||||
.catch(() => _copyReportWithExecCommand(text));
|
||||
} else {
|
||||
_copyReportWithExecCommand(text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo a single L4 match via the existing restore endpoints (moved from
|
||||
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
|
||||
* restore additionally takes lora_index.
|
||||
*/
|
||||
async function _undoMatch(match) {
|
||||
const isCheckpoint = match.type === 'checkpoint';
|
||||
const body = isCheckpoint
|
||||
? { recipe_id: match.recipe_id }
|
||||
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
||||
const response = await fetch(
|
||||
isCheckpoint
|
||||
? '/api/lm/recipe/checkpoint/restore'
|
||||
: '/api/lm/recipe/lora/restore',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Restore failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the post-run rematch summary modal. Mirrors the batch download
|
||||
* summary lifecycle: the modal element is appended directly to
|
||||
* document.body and removed on close; it is not registered with
|
||||
* ModalManager.
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
|
||||
* @param {boolean} options.cancelled - Whether the run was cancelled
|
||||
* @param {number} options.total - Recipes scanned
|
||||
* @param {number} options.matchedRecipes - Recipes updated
|
||||
* @param {number} options.matchedEntries - Entries reconnected
|
||||
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
|
||||
* @param {number} options.unresolvedEntries - Candidate entries with no local match
|
||||
* @param {number} options.skipped - Recipes left untouched
|
||||
* @param {number} options.errors - Per-recipe errors
|
||||
* @param {Array} options.l4Matches - Filename-level matches for review/undo
|
||||
* ({ recipe_id, type, entry, file_name, lora_index? })
|
||||
*/
|
||||
export function showRematchSummary({
|
||||
scope = 'global',
|
||||
cancelled = false,
|
||||
total = 0,
|
||||
matchedRecipes = 0,
|
||||
matchedEntries = 0,
|
||||
unresolvedRecipes = 0,
|
||||
unresolvedEntries = 0,
|
||||
skipped = 0,
|
||||
errors = 0,
|
||||
l4Matches = [],
|
||||
} = {}) {
|
||||
const matches = Array.isArray(l4Matches) ? l4Matches : [];
|
||||
const undoneIndexes = new Set();
|
||||
const header = _resolveHeader({
|
||||
matchedEntries,
|
||||
errors,
|
||||
unresolvedEntries,
|
||||
l4Count: matches.length,
|
||||
cancelled,
|
||||
});
|
||||
|
||||
const matchRows = matches.map((match, i) => `
|
||||
<tr data-l4-index="${i}">
|
||||
<td class="failure-index">${i + 1}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
|
||||
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
|
||||
<td class="rematch-undo-cell">
|
||||
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
|
||||
${translate('modals.rematchResults.undo', {}, 'Undo')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>`).join('');
|
||||
|
||||
const modalHtml = `
|
||||
<div id="rematchSummaryModal" class="modal" style="display: block;">
|
||||
<div class="modal-content rematch-summary-modal">
|
||||
<button class="close" data-action="close-modal">×</button>
|
||||
|
||||
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
|
||||
|
||||
<div class="summary-header ${header.state}">
|
||||
<i class="fas ${header.icon}"></i>
|
||||
<span class="summary-title">${header.text}</span>
|
||||
<span class="summary-hint">${matchedRecipes}/${total}</span>
|
||||
</div>
|
||||
${cancelled ? `
|
||||
<p class="rematch-cancelled-note">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
|
||||
</p>` : ''}
|
||||
|
||||
<div class="refresh-summary-stats">
|
||||
<div class="stat-card stat-card-success">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
|
||||
<span class="stat-card-value">${matchedEntries}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-skipped">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
|
||||
<span class="stat-card-value">${matches.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-total">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
|
||||
<span class="stat-card-value">${unresolvedEntries}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card stat-card-failure">
|
||||
<div class="stat-card-body">
|
||||
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
|
||||
<span class="stat-card-value">${errors}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${matches.length > 0 ? `
|
||||
<div class="refresh-failures-section rematch-review-section">
|
||||
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
|
||||
<div class="failure-table-wrapper">
|
||||
<table class="failure-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
|
||||
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
|
||||
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
|
||||
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${matchRows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="modal-actions">
|
||||
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const existing = document.getElementById('rematchSummaryModal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.innerHTML = modalHtml;
|
||||
const modal = container.firstElementChild;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const reportArgs = {
|
||||
scope,
|
||||
cancelled,
|
||||
total,
|
||||
matchedRecipes,
|
||||
matchedEntries,
|
||||
unresolvedRecipes,
|
||||
unresolvedEntries,
|
||||
skipped,
|
||||
errors,
|
||||
l4Matches: matches,
|
||||
undoneIndexes,
|
||||
};
|
||||
|
||||
modal.addEventListener('click', async (e) => {
|
||||
const actionEl = e.target.closest('[data-action]');
|
||||
const action = actionEl?.dataset.action;
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
|
||||
switch (action) {
|
||||
case 'close-modal':
|
||||
modal.remove();
|
||||
break;
|
||||
case 'copy-report':
|
||||
_copyReport(actionEl, reportArgs);
|
||||
break;
|
||||
case 'undo-match': {
|
||||
const index = Number(actionEl.dataset.index);
|
||||
const match = matches[index];
|
||||
if (!match || actionEl.disabled) break;
|
||||
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
|
||||
try {
|
||||
await _undoMatch(match);
|
||||
undoneIndexes.add(index);
|
||||
row?.classList.add('undone');
|
||||
actionEl.disabled = true;
|
||||
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
||||
} catch (error) {
|
||||
console.error('Failed to undo rematch match:', error);
|
||||
showToast(
|
||||
'modals.rematchResults.undoFailed',
|
||||
{ message: error.message },
|
||||
'error'
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -11,13 +11,6 @@ import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||
|
||||
// Pages whose folder sidebar starts hidden. "other" downloads default to a flat
|
||||
// layout (no subfolders are created), so on a fresh library the tree is empty
|
||||
// there and the sidebar would only consume horizontal space. The preference is
|
||||
// still persisted per page once the user toggles it, and the edge indicator
|
||||
// makes the hidden sidebar discoverable/recoverable.
|
||||
const SIDEBAR_DEFAULT_HIDDEN_PAGES = new Set(['other']);
|
||||
|
||||
export class SidebarManager {
|
||||
constructor() {
|
||||
this.pageControls = null;
|
||||
@@ -1133,7 +1126,6 @@ export class SidebarManager {
|
||||
recipes: 'Recipes',
|
||||
checkpoints: 'Checkpoints',
|
||||
embeddings: 'Embeddings',
|
||||
other: 'Other Models',
|
||||
};
|
||||
return names[this.pageType] || this.pageType;
|
||||
}
|
||||
@@ -1792,10 +1784,7 @@ export class SidebarManager {
|
||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||
this.isDisabledByPage = getStorageItem(
|
||||
`${this.pageType}_sidebarDisabled`,
|
||||
SIDEBAR_DEFAULT_HIDDEN_PAGES.has(this.pageType)
|
||||
);
|
||||
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
||||
|
||||
this.expandedNodes = new Set(expandedPaths);
|
||||
this.displayMode = displayMode;
|
||||
@@ -1815,7 +1804,7 @@ export class SidebarManager {
|
||||
_migrateOldSettings() {
|
||||
if (getStorageItem('_sidebar_migration_done')) return;
|
||||
|
||||
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
|
||||
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
|
||||
|
||||
// 1. Migrate global hide setting to per-page
|
||||
if (state?.global?.settings?.show_folder_sidebar === false) {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
// OtherControls.js - Specific implementation for the Other Models page
|
||||
import { PageControls } from './PageControls.js';
|
||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||
import { showToast } from '../../utils/uiHelpers.js';
|
||||
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||
|
||||
/**
|
||||
* OtherControls class - Extends PageControls for the Other Models page
|
||||
* (VAE, upscalers, text encoders, CLIP vision, ControlNet, ...)
|
||||
*/
|
||||
export class OtherControls extends PageControls {
|
||||
constructor() {
|
||||
// Initialize with 'other' page type
|
||||
super('other');
|
||||
|
||||
// Register API methods specific to the Other Models page
|
||||
this.registerOtherAPI();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register Other-models-specific API methods
|
||||
*/
|
||||
registerOtherAPI() {
|
||||
const otherAPI = {
|
||||
// Core API functions
|
||||
loadMoreModels: async (resetPage = false, updateFolders = false) => {
|
||||
return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders);
|
||||
},
|
||||
|
||||
resetAndReload: async (updateFolders = false) => {
|
||||
return await resetAndReload(updateFolders);
|
||||
},
|
||||
|
||||
refreshModels: async (fullRebuild = false) => {
|
||||
return await getModelApiClient().refreshModels(fullRebuild);
|
||||
},
|
||||
|
||||
// Add fetch from Civitai functionality for other models
|
||||
fetchFromCivitai: async () => {
|
||||
return await getModelApiClient().fetchCivitaiMetadata();
|
||||
},
|
||||
|
||||
// Add show download modal functionality
|
||||
showDownloadModal: () => {
|
||||
downloadManager.showDownloadModal();
|
||||
},
|
||||
|
||||
toggleBulkMode: () => {
|
||||
if (window.bulkManager) {
|
||||
window.bulkManager.toggleBulkMode();
|
||||
} else {
|
||||
console.error('Bulk manager not available');
|
||||
}
|
||||
},
|
||||
|
||||
// No clearCustomFilter implementation is needed for other models
|
||||
// as custom filters are currently only used for LoRAs
|
||||
clearCustomFilter: async () => {
|
||||
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
|
||||
}
|
||||
};
|
||||
|
||||
// Register the API
|
||||
this.registerAPI(otherAPI);
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,13 @@ import { PageControls } from './PageControls.js';
|
||||
import { LorasControls } from './LorasControls.js';
|
||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
||||
import { OtherControls } from './OtherControls.js';
|
||||
|
||||
// Export the classes
|
||||
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls };
|
||||
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls };
|
||||
|
||||
/**
|
||||
* Factory function to create the appropriate controls based on page type
|
||||
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'other')
|
||||
* @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings')
|
||||
* @returns {PageControls} - The appropriate controls instance
|
||||
*/
|
||||
export function createPageControls(pageType) {
|
||||
@@ -20,10 +19,8 @@ export function createPageControls(pageType) {
|
||||
return new CheckpointsControls();
|
||||
} else if (pageType === 'embeddings') {
|
||||
return new EmbeddingsControls();
|
||||
} else if (pageType === 'other') {
|
||||
return new OtherControls();
|
||||
} else {
|
||||
console.error(`Unknown page type: ${pageType}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,8 +58,6 @@ class InitializationManager {
|
||||
this.pageType = 'recipes';
|
||||
} else if (path.includes('/checkpoints')) {
|
||||
this.pageType = 'checkpoints';
|
||||
} else if (path.includes('/other')) {
|
||||
this.pageType = 'other';
|
||||
} else if (path.includes('/loras')) {
|
||||
this.pageType = 'loras';
|
||||
} else if (path.includes('/embeddings')) {
|
||||
@@ -223,7 +221,6 @@ class InitializationManager {
|
||||
'lora': 'loras',
|
||||
'checkpoint': 'checkpoints',
|
||||
'embedding': 'embeddings',
|
||||
'other': 'other',
|
||||
'recipe': 'recipes'
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||
import { state, getCurrentPageState } from '../../state/index.js';
|
||||
import { showModelModal } from './ModelModal.js';
|
||||
import { hasCivitaiSource } from './utils.js';
|
||||
import { bulkManager } from '../../managers/BulkManager.js';
|
||||
import { modalManager } from '../../managers/ModalManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||||
@@ -64,10 +63,7 @@ function handleModelCardEvent_internal(event, modelType) {
|
||||
|
||||
if (event.target.closest('.fa-globe')) {
|
||||
event.stopPropagation();
|
||||
// CivitAI wins when the model actually has CivitAI data; otherwise fall
|
||||
// back to HuggingFace. Relying on `from_civitai` here made the two
|
||||
// sources mutually exclusive whenever one of them was (re)linked (#1094).
|
||||
if (card.dataset.has_civitai === 'true') {
|
||||
if (card.dataset.from_civitai === 'true') {
|
||||
openCivitai(card.dataset.filepath);
|
||||
} else if (card.dataset.hf_url) {
|
||||
openHuggingFace(card.dataset.hf_url);
|
||||
@@ -254,11 +250,6 @@ function handleCopyAction(card, modelType) {
|
||||
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
||||
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
||||
copyToClipboard(embeddingCode, message);
|
||||
} else {
|
||||
// Other model types (VAE, upscalers, ...) - copy the file name
|
||||
const fileName = card.dataset.file_name;
|
||||
const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied');
|
||||
copyToClipboard(fileName, message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,9 +473,6 @@ export function createModelCard(model, modelType) {
|
||||
card.dataset.modified = model.modified;
|
||||
card.dataset.file_size = model.file_size;
|
||||
card.dataset.from_civitai = model.from_civitai;
|
||||
// Independent of `from_civitai`: a model can have both CivitAI data and an
|
||||
// HF link, and the card globe must keep pointing at CivitAI when it does.
|
||||
card.dataset.has_civitai = hasCivitaiSource(model.civitai) ? 'true' : 'false';
|
||||
card.dataset.usage_count = String(model.usage_count);
|
||||
card.dataset.notes = model.notes || '';
|
||||
card.dataset.base_model = model.base_model || 'Unknown';
|
||||
@@ -607,13 +595,12 @@ export function createModelCard(model, modelType) {
|
||||
const favoriteTitle = isFavorite ?
|
||||
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||
const hasCivitai = hasCivitaiSource(model.civitai);
|
||||
const globeTitle = hasCivitai ?
|
||||
const globeTitle = model.from_civitai ?
|
||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||
model.hf_url ?
|
||||
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||
const globeEnabled = hasCivitai || !!model.hf_url;
|
||||
const globeEnabled = model.from_civitai || !!model.hf_url;
|
||||
let sendTitle;
|
||||
let copyTitle;
|
||||
if (modelType === MODEL_TYPES.LORA) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from './ModelMetadata.js';
|
||||
import { setupTagEditMode } from './ModelTags.js';
|
||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml, hasCivitaiSource } from './utils.js';
|
||||
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml } from './utils.js';
|
||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||
@@ -389,11 +389,7 @@ export async function showModelModal(model, modelType) {
|
||||
const licenseIcons = useNewIcons
|
||||
? renderNewLicenseIcons(modelWithFullData)
|
||||
: renderLicenseIcons(modelWithFullData);
|
||||
// Gate the CivitAI link on actual CivitAI data, not the `from_civitai`
|
||||
// provenance flag: a model can be linked to HuggingFace and to CivitAI at
|
||||
// the same time, and both links must coexist (#1094).
|
||||
const hasCivitai = hasCivitaiSource(modelWithFullData.civitai);
|
||||
const viewOnCivitaiAction = hasCivitai ? `
|
||||
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||
</div>`.trim() : '';
|
||||
|
||||
@@ -1390,22 +1390,8 @@ export function initVersionsTab({
|
||||
|
||||
try {
|
||||
const client = ensureClient();
|
||||
// On the checkpoints page a diffusion model lives under the unet
|
||||
// roots, so both root sets are needed to locate the current file.
|
||||
let roots;
|
||||
if (modelType === 'checkpoints') {
|
||||
const [checkpointRoots, unetRoots] = await Promise.all([
|
||||
client.fetchModelRoots(),
|
||||
client.fetchModelRoots('diffusion_model'),
|
||||
]);
|
||||
roots = [
|
||||
...(checkpointRoots?.roots || []),
|
||||
...(unetRoots?.roots || []),
|
||||
];
|
||||
} else {
|
||||
const rootsData = await client.fetchModelRoots();
|
||||
roots = rootsData?.roots;
|
||||
}
|
||||
const rootsData = await client.fetchModelRoots();
|
||||
const roots = rootsData?.roots;
|
||||
if (!Array.isArray(roots) || roots.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -36,24 +36,6 @@ export function formatFileSize(bytes) {
|
||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a model has usable CivitAI metadata to link to.
|
||||
*
|
||||
* CivitAI links must be gated on the presence of actual CivitAI data rather
|
||||
* than the `from_civitai` provenance flag: linking a model to HuggingFace used
|
||||
* to flip `from_civitai` to false, which hid the CivitAI link even though the
|
||||
* model still had CivitAI metadata. See issue #1094.
|
||||
*
|
||||
* @param {Object} [civitaiData] - The model's `civitai` payload
|
||||
* @returns {boolean} True when a CivitAI model/version id is available
|
||||
*/
|
||||
export function hasCivitaiSource(civitaiData) {
|
||||
if (!civitaiData || typeof civitaiData !== 'object') return false;
|
||||
return Boolean(
|
||||
civitaiData.modelId ?? civitaiData.model_id ?? civitaiData.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render compact tags
|
||||
* @param {Array} tags - Array of tags
|
||||
|
||||
+1
-3
@@ -7,7 +7,6 @@ import { HeaderManager } from './components/Header.js';
|
||||
import { settingsManager } from './managers/SettingsManager.js';
|
||||
import { moveManager } from './managers/MoveManager.js';
|
||||
import { bulkManager } from './managers/BulkManager.js';
|
||||
import { rematchModalManager } from './managers/RematchModalManager.js';
|
||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||
import { helpManager } from './managers/HelpManager.js';
|
||||
import { doctorManager } from './managers/DoctorManager.js';
|
||||
@@ -69,7 +68,6 @@ export class AppCore {
|
||||
window.doctorManager = doctorManager;
|
||||
window.moveManager = moveManager;
|
||||
window.bulkManager = bulkManager;
|
||||
window.rematchModalManager = rematchModalManager;
|
||||
|
||||
// Initialize UI components
|
||||
window.headerManager = new HeaderManager();
|
||||
@@ -116,7 +114,7 @@ export class AppCore {
|
||||
initializePageFeatures() {
|
||||
const pageType = this.getPageType();
|
||||
|
||||
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
|
||||
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
||||
this.initializeContextMenus(pageType);
|
||||
initializeInfiniteScroll(pageType);
|
||||
}
|
||||
|
||||
@@ -6,11 +6,9 @@ import {
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { enableOtherModels, openOtherModelsSettings } from '../utils/otherModels.js';
|
||||
|
||||
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
||||
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
|
||||
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
|
||||
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
|
||||
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
||||
@@ -82,7 +80,6 @@ class BannerService {
|
||||
});
|
||||
|
||||
this.prepareCommunitySupportBanner();
|
||||
this.prepareOtherModelsBanner();
|
||||
|
||||
await this.showActiveBanners();
|
||||
this.initialized = true;
|
||||
@@ -427,13 +424,12 @@ class BannerService {
|
||||
|
||||
/**
|
||||
* Get the current page type from the URL
|
||||
* @returns {string} Page type (loras, checkpoints, embeddings, other, recipes)
|
||||
* @returns {string} Page type (loras, checkpoints, embeddings, recipes)
|
||||
*/
|
||||
getCurrentPageType() {
|
||||
const path = window.location.pathname;
|
||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||
if (path.includes('/embeddings')) return 'embeddings';
|
||||
if (path.includes('/other')) return 'other';
|
||||
if (path.includes('/recipes')) return 'recipes';
|
||||
return 'loras';
|
||||
}
|
||||
@@ -447,8 +443,7 @@ class BannerService {
|
||||
const endpoints = {
|
||||
'loras': '/api/lm/loras/reload?rebuild=true',
|
||||
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
|
||||
'embeddings': '/api/lm/embeddings/reload?rebuild=true',
|
||||
'other': '/api/lm/other/reload?rebuild=true'
|
||||
'embeddings': '/api/lm/embeddings/reload?rebuild=true'
|
||||
};
|
||||
return endpoints[pageType] || endpoints['loras'];
|
||||
}
|
||||
@@ -544,99 +539,6 @@ class BannerService {
|
||||
this.updateContainerVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Announce the opt-in Other Models management to users who have not turned
|
||||
* it on yet. Dismissal is persisted through the shared dismissed_banners
|
||||
* setting, so users who are not interested are not nagged again.
|
||||
*/
|
||||
prepareOtherModelsBanner() {
|
||||
if (state.global.settings.enable_other_models) {
|
||||
return;
|
||||
}
|
||||
// Only announce when the host can actually resolve other-model folders.
|
||||
// Standalone installs only know the folder_paths keys present in
|
||||
// settings.json, so announcing there would land the user on an empty
|
||||
// page. `=== false` (not falsy) keeps older payloads working.
|
||||
if (state.global.settings.other_models_paths_available === false) {
|
||||
return;
|
||||
}
|
||||
if (this.isBannerDismissed(OTHER_MODELS_BANNER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerBanner(OTHER_MODELS_BANNER_ID, {
|
||||
id: OTHER_MODELS_BANNER_ID,
|
||||
title: translate(
|
||||
'banners.otherModels.title',
|
||||
{},
|
||||
'Other Models Management is available'
|
||||
),
|
||||
content: translate(
|
||||
'banners.otherModels.content',
|
||||
{},
|
||||
'Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.'
|
||||
),
|
||||
actions: [
|
||||
{
|
||||
text: translate(
|
||||
'banners.otherModels.enable',
|
||||
{},
|
||||
'Enable Other Models'
|
||||
),
|
||||
icon: 'fas fa-shapes',
|
||||
type: 'primary',
|
||||
action: 'enable-other-models'
|
||||
},
|
||||
{
|
||||
text: translate(
|
||||
'banners.otherModels.openSettings',
|
||||
{},
|
||||
'Open Settings'
|
||||
),
|
||||
icon: 'fas fa-cog',
|
||||
type: 'secondary',
|
||||
action: 'open-other-models-settings'
|
||||
}
|
||||
],
|
||||
dismissible: true,
|
||||
priority: 0,
|
||||
onRegister: (bannerElement) => {
|
||||
const enableButton = bannerElement.querySelector(
|
||||
'.banner-action[data-action="enable-other-models"]'
|
||||
);
|
||||
if (enableButton) {
|
||||
enableButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
enableOtherModels().catch((error) => {
|
||||
console.error('Failed to enable Other Models:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const settingsButton = bannerElement.querySelector(
|
||||
'.banner-action[data-action="open-other-models-settings"]'
|
||||
);
|
||||
if (settingsButton) {
|
||||
settingsButton.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
openOtherModelsSettings();
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.updateContainerVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the Other Models announcement once the feature is enabled.
|
||||
* Dismissal is deliberately NOT persisted, so the announcement can come
|
||||
* back if the user switches the feature off again.
|
||||
*/
|
||||
removeOtherModelsAnnouncement() {
|
||||
this.removeBannerElement(OTHER_MODELS_BANNER_ID);
|
||||
}
|
||||
|
||||
initializeCommunitySupportState() {
|
||||
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ export class BatchImportManager {
|
||||
this.results = null;
|
||||
this.isCancelled = false;
|
||||
this.isImporting = false;
|
||||
this.currentParentPath = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -719,10 +718,9 @@ export class BatchImportManager {
|
||||
browser.style.display = isVisible ? 'none' : 'block';
|
||||
|
||||
if (!isVisible) {
|
||||
// Load initial directory when opening. An empty path lets the
|
||||
// server pick its default (user home); "/" would be POSIX-only.
|
||||
// Load initial directory when opening
|
||||
const currentPath = document.getElementById('batchDirectoryInput').value;
|
||||
this.loadDirectory(currentPath || '');
|
||||
this.loadDirectory(currentPath || '/');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -763,10 +761,6 @@ export class BatchImportManager {
|
||||
const directoryCount = document.getElementById('batchDirectoryCount');
|
||||
const imageCount = document.getElementById('batchImageCount');
|
||||
|
||||
// Remember the server-computed parent path so the "up" navigation
|
||||
// works with Windows paths too (they cannot be split on "/").
|
||||
this.currentParentPath = data.parent_path || null;
|
||||
|
||||
if (currentPathEl) {
|
||||
currentPathEl.textContent = data.current_path;
|
||||
}
|
||||
@@ -817,9 +811,11 @@ export class BatchImportManager {
|
||||
`;
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
// The parent entry uses the server-provided parent_path (or the
|
||||
// Windows drive-list token) directly — both are plain load targets.
|
||||
this.loadDirectory(path);
|
||||
if (isParent) {
|
||||
this.navigateToParentDirectory();
|
||||
} else {
|
||||
this.loadDirectory(path);
|
||||
}
|
||||
});
|
||||
|
||||
return item;
|
||||
@@ -843,12 +839,15 @@ export class BatchImportManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to parent directory using the path reported by the server.
|
||||
* Deriving it client-side by splitting on "/" breaks Windows paths.
|
||||
* Navigate to parent directory
|
||||
*/
|
||||
navigateToParentDirectory() {
|
||||
if (this.currentParentPath) {
|
||||
this.loadDirectory(this.currentParentPath);
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
if (currentPath) {
|
||||
// Get parent path using path manipulation
|
||||
const lastSeparator = currentPath.lastIndexOf('/');
|
||||
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
|
||||
this.loadDirectory(parentPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,14 +857,8 @@ export class BatchImportManager {
|
||||
selectCurrentDirectory() {
|
||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||
|
||||
if (!currentPath) {
|
||||
// Virtual levels (e.g. the Windows drive list) have no path.
|
||||
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (directoryInput) {
|
||||
|
||||
if (currentPath && directoryInput) {
|
||||
directoryInput.value = currentPath;
|
||||
this.toggleDirectoryBrowser(); // Close browser
|
||||
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
||||
|
||||
+103
-106
@@ -3,8 +3,6 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
|
||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { rematchModalManager } from './RematchModalManager.js';
|
||||
import { showRematchSummary } from '../components/RematchSummaryModal.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
@@ -12,7 +10,6 @@ import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../componen
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
import { eventManager } from '../utils/EventManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
|
||||
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
|
||||
|
||||
export class BulkManager {
|
||||
@@ -93,20 +90,6 @@ export class BulkManager {
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
},
|
||||
[MODEL_TYPES.OTHER]: {
|
||||
addTags: true,
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: true,
|
||||
checkUpdates: true,
|
||||
moveAll: true,
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: true,
|
||||
skipMetadataRefresh: true,
|
||||
setFavorite: true,
|
||||
unfavorite: true
|
||||
},
|
||||
recipes: {
|
||||
addTags: true,
|
||||
sendToWorkflow: false,
|
||||
@@ -120,6 +103,7 @@ export class BulkManager {
|
||||
skipMetadataRefresh: false,
|
||||
setFavorite: true,
|
||||
unfavorite: true,
|
||||
repairMetadata: true,
|
||||
reimportMetadata: true,
|
||||
rematchMetadata: true
|
||||
}
|
||||
@@ -874,74 +858,17 @@ export class BulkManager {
|
||||
`Re-importing recipe 1/${total}...`
|
||||
);
|
||||
|
||||
// Partition the selection: recipes sourced from a CivitAI image page
|
||||
// can be delegated to the companion browser extension (which scrapes
|
||||
// the full page metadata); everything else uses the native endpoint.
|
||||
const delegatable = [];
|
||||
const nativeFilePaths = [];
|
||||
for (const filePath of filePaths) {
|
||||
const recipeItem = recipeMap.get(filePath);
|
||||
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
|
||||
if (civitaiImage && recipeItem?.id) {
|
||||
delegatable.push({
|
||||
filePath,
|
||||
recipeId: recipeItem.id,
|
||||
imageId: civitaiImage.imageId,
|
||||
imageUrl: civitaiImage.imageUrl,
|
||||
title: recipeItem.title || '',
|
||||
});
|
||||
} else {
|
||||
nativeFilePaths.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
// Probe once; on any probe/delegate failure the delegatable recipes
|
||||
// fall back to the native sequential loop below.
|
||||
if (delegatable.length > 0) {
|
||||
try {
|
||||
const probe = await probeExtension();
|
||||
if (probe?.supported && probe?.licenseValid) {
|
||||
const batchResult = await delegateReimport(
|
||||
delegatable.map(({ recipeId, imageId, imageUrl, title }) => ({
|
||||
recipeId, imageId, imageUrl, title,
|
||||
})),
|
||||
{
|
||||
onProgress: (progress) => {
|
||||
progressUI.updateProgress(
|
||||
Math.floor(((progress.current || 0) / total) * 100),
|
||||
progress.title || '',
|
||||
translate('toast.recipes.reimportingViaExtension', {
|
||||
current: progress.current || 0,
|
||||
total,
|
||||
})
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
completed += batchResult.completed;
|
||||
failed += batchResult.failed;
|
||||
} else {
|
||||
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[reimportSelectedRecipes] extension delegation failed, using native path:', error);
|
||||
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const processedBeforeNative = completed + failed;
|
||||
for (let i = 0; i < nativeFilePaths.length; i++) {
|
||||
const filePath = nativeFilePaths[i];
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
const filePath = filePaths[i];
|
||||
const recipeItem = recipeMap.get(filePath);
|
||||
const recipeId = recipeItem?.id;
|
||||
const recipeName = recipeItem?.title || recipeId || 'Unknown';
|
||||
const processed = processedBeforeNative + i;
|
||||
|
||||
progressUI.updateProgress(
|
||||
Math.floor((processed / total) * 100),
|
||||
Math.floor((i / total) * 100),
|
||||
recipeName,
|
||||
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
|
||||
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
|
||||
);
|
||||
|
||||
if (!recipeId) {
|
||||
@@ -983,6 +910,76 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
async repairSelectedRecipes() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.currentPageType !== 'recipes') {
|
||||
showToast('This operation is only available for recipes', {}, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
if (typeof apiClient.repairBulkModels !== 'function') {
|
||||
showToast('Bulk repair is not supported for this model type', {}, 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
|
||||
|
||||
const result = await apiClient.repairBulkModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
const total = result.total || filePaths.length;
|
||||
const repaired = result.repaired || 0;
|
||||
const skipped = result.skipped || 0;
|
||||
|
||||
const recipes = result.recipes || [];
|
||||
for (const recipe of recipes) {
|
||||
if (recipe.file_path) {
|
||||
state.virtualScroller.updateSingleItem(
|
||||
recipe.file_path,
|
||||
recipe
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (repaired > 0) {
|
||||
showToast(
|
||||
'toast.recipes.repairBulkComplete',
|
||||
{ repaired, skipped, total },
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
'toast.recipes.repairBulkSkipped',
|
||||
{ total },
|
||||
'info'
|
||||
);
|
||||
}
|
||||
|
||||
if (state.bulkMode) this.toggleBulkMode();
|
||||
} else {
|
||||
throw new Error(result.error || 'Bulk repair failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during bulk recipe repair:', error);
|
||||
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
|
||||
} finally {
|
||||
if (state.loadingManager?.hide) {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
|
||||
state.loadingManager.restoreProgressBar();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async rematchSelectedRecipes() {
|
||||
if (state.selectedModels.size === 0) {
|
||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||
@@ -994,15 +991,6 @@ export class BulkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect options (relaxed matching) before starting anything; the
|
||||
// run only begins when the user confirms the dialog.
|
||||
rematchModalManager.showOptionsModal({
|
||||
recipeCount: state.selectedModels.size,
|
||||
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
|
||||
});
|
||||
}
|
||||
|
||||
async _startRematchSelectedRecipes(relaxed = false) {
|
||||
try {
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
@@ -1014,7 +1002,7 @@ export class BulkManager {
|
||||
|
||||
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
|
||||
|
||||
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
|
||||
const result = await apiClient.rematchBulkModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
const total = result.total || filePaths.length;
|
||||
@@ -1040,29 +1028,38 @@ export class BulkManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Complete no-op (nothing matched, nothing unresolved, no
|
||||
// errors) keeps the lightweight toast; anything else opens
|
||||
// the post-run summary modal.
|
||||
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||
if (isNoop) {
|
||||
if (matchedEntries > 0) {
|
||||
const hasFailures = failures > 0;
|
||||
const toastKey = hasFailures
|
||||
? 'toast.recipes.rematchCompleteErrors'
|
||||
: 'toast.recipes.rematchComplete';
|
||||
showToast(
|
||||
toastKey,
|
||||
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
|
||||
hasFailures ? 'warning' : 'success'
|
||||
);
|
||||
} else if (failures > 0) {
|
||||
// Nothing matched and at least one recipe errored —
|
||||
// "no rematch needed" would be actively misleading here.
|
||||
showToast(
|
||||
'toast.recipes.rematchAllFailed',
|
||||
{ total, failures },
|
||||
'error'
|
||||
);
|
||||
} else if (unresolvedEntries > 0) {
|
||||
// Entries existed but have no local model — expected for
|
||||
// models deleted from Civitai; informational, not an error.
|
||||
showToast(
|
||||
'toast.recipes.rematchUnmatched',
|
||||
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showToast(
|
||||
'toast.recipes.rematchSkipped',
|
||||
{ total },
|
||||
'info'
|
||||
);
|
||||
} else {
|
||||
showRematchSummary({
|
||||
scope: 'bulk',
|
||||
total,
|
||||
matchedRecipes,
|
||||
matchedEntries,
|
||||
unresolvedRecipes,
|
||||
unresolvedEntries,
|
||||
skipped,
|
||||
errors: failures,
|
||||
l4Matches,
|
||||
});
|
||||
}
|
||||
|
||||
if (state.bulkMode) this.toggleBulkMode();
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { extractRecipeId } from '../api/recipeApi.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
|
||||
@@ -15,7 +13,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||
this.pendingLoras = [];
|
||||
this.pendingRecipes = [];
|
||||
this.pendingMissingByRecipe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +136,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
// Execute download
|
||||
await this.executeDownload(this.pendingLoras);
|
||||
this.pendingLoras = [];
|
||||
this.pendingMissingByRecipe = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,9 +153,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
// Collect missing LoRAs with deduplication
|
||||
const stats = this.collectMissingLoras(selectedRecipes);
|
||||
// Kept so executeDownload can mark unresolvable failures back onto
|
||||
// every recipe occurrence (hashInvalid → reconnect candidacy).
|
||||
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
|
||||
|
||||
if (stats.uniqueCount === 0) {
|
||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||
@@ -203,7 +196,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
|
||||
let completedDownloads = 0;
|
||||
let failedDownloads = 0;
|
||||
let markedInvalidCount = 0;
|
||||
let currentLoraProgress = 0;
|
||||
let cancelled = false;
|
||||
|
||||
@@ -312,12 +304,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
if (!response.success) {
|
||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||
failedDownloads++;
|
||||
// An unresolvable failure (model gone on CivitAI) flips
|
||||
// every recipe occurrence to reconnect candidacy — same
|
||||
// rule as the single-LoRA download in RecipeModal.
|
||||
if (isUnresolvableDownloadError(response.error)) {
|
||||
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||
}
|
||||
} else {
|
||||
completedDownloads++;
|
||||
updateProgress(100, completedDownloads, '');
|
||||
@@ -326,9 +312,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
if (!cancelled) {
|
||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||
failedDownloads++;
|
||||
if (isUnresolvableDownloadError(error?.message)) {
|
||||
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,16 +335,9 @@ export class BulkMissingLoraDownloadManager {
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
// Unresolvable failures were marked hash-invalid during the loop;
|
||||
// tell the user those entries now offer reconnect instead of download.
|
||||
if (markedInvalidCount > 0) {
|
||||
showToast('toast.recipes.unresolvableMarkedForReconnect', {
|
||||
count: markedInvalidCount
|
||||
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
|
||||
}
|
||||
|
||||
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||
if (state.virtualScroller) {
|
||||
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||
for (const recipe of this.pendingRecipes) {
|
||||
const recipeId = extractRecipeId(recipe.file_path);
|
||||
if (!recipeId) continue;
|
||||
@@ -378,59 +354,6 @@ export class BulkMissingLoraDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
|
||||
*
|
||||
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
|
||||
* makes each occurrence an unresolved rematch candidate and swaps its
|
||||
* action from download to reconnect. Only called for unresolvable
|
||||
* failures — transient errors leave entries untouched.
|
||||
*
|
||||
* @param {Object} failedLora - The deduplicated LoRA that failed
|
||||
* @returns {Promise<number>} - How many recipe entries were marked
|
||||
*/
|
||||
async markLoraHashInvalidInRecipes(failedLora) {
|
||||
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
|
||||
if (!failedKey || !this.pendingMissingByRecipe) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let marked = 0;
|
||||
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
|
||||
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
|
||||
if (!recipeId || !Array.isArray(recipe.loras)) {
|
||||
continue;
|
||||
}
|
||||
for (const entry of missingLoras) {
|
||||
const entryKey = entry.hash || entry.id || entry.modelVersionId;
|
||||
if (entryKey !== failedKey) {
|
||||
continue;
|
||||
}
|
||||
const loraIndex = recipe.loras.indexOf(entry);
|
||||
if (loraIndex < 0) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipe_id: recipeId,
|
||||
lora_index: loraIndex,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
entry.hashInvalid = true;
|
||||
marked++;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to mark LoRA hash invalid:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
return marked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LoRA root directory from API
|
||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast, showActionToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
||||
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { LoadingManager } from './LoadingManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { DOWNLOAD_ENDPOINTS } from '../api/apiConfig.js';
|
||||
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { translate } from '../utils/i18nHelpers.js';
|
||||
import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
|
||||
import { formatFileSize } from '../utils/formatters.js';
|
||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||
import { openOtherModelsSettings } from '../utils/otherModels.js';
|
||||
|
||||
export class DownloadManager {
|
||||
constructor() {
|
||||
@@ -492,9 +489,8 @@ export class DownloadManager {
|
||||
return { type: 'civitai' };
|
||||
}
|
||||
|
||||
// Hugging Face resolve/blob URL → direct file
|
||||
// "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
|
||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
|
||||
// Hugging Face resolve URL → direct file
|
||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
||||
if (hfResolveMatch) {
|
||||
return {
|
||||
type: 'hf-resolve',
|
||||
@@ -957,18 +953,17 @@ export class DownloadManager {
|
||||
async proceedToLocationContent() {
|
||||
|
||||
try {
|
||||
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||
this._otherSubType = await this._resolveOtherSubType();
|
||||
const _isDiffusionModel = this.selectedFile
|
||||
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||
: (this.currentVersion?.files || []).some(
|
||||
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||
);
|
||||
this._isDiffusionModel = _isDiffusionModel;
|
||||
|
||||
let rootsData;
|
||||
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
|
||||
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
|
||||
} else {
|
||||
// An undecidable other sub_type (null) intentionally lands
|
||||
// here: fetchModelRoots() lists all other roots so the user
|
||||
// can pick manually.
|
||||
rootsData = await this.apiClient.fetchModelRoots();
|
||||
}
|
||||
const modelRoot = document.getElementById('modelRoot');
|
||||
@@ -976,29 +971,19 @@ export class DownloadManager {
|
||||
`<option value="${root}">${root}</option>`
|
||||
).join('');
|
||||
|
||||
let defaultRoot;
|
||||
let subtypeDisplay;
|
||||
if (this.apiClient.modelType === 'other') {
|
||||
const otherDefaultRoots = state.global.settings.default_other_roots || {};
|
||||
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
|
||||
subtypeDisplay = this._otherSubType
|
||||
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
|
||||
: this.apiClient.apiConfig.config.displayName;
|
||||
} else {
|
||||
const singularType = this._isDiffusionModel
|
||||
? 'unet'
|
||||
: this.apiClient.modelType.replace(/s$/, '');
|
||||
const defaultRootKey = `default_${singularType}_root`;
|
||||
defaultRoot = state.global.settings[defaultRootKey];
|
||||
subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||
}
|
||||
console.log('Default root:', defaultRoot);
|
||||
const singularType = this._isDiffusionModel
|
||||
? 'unet'
|
||||
: this.apiClient.modelType.replace(/s$/, '');
|
||||
const defaultRootKey = `default_${singularType}_root`;
|
||||
const defaultRoot = state.global.settings[defaultRootKey];
|
||||
console.log(`Default root for ${singularType}:`, defaultRoot);
|
||||
console.log('Available roots:', rootsData.roots);
|
||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||
console.log(`Setting default root: ${defaultRoot}`);
|
||||
modelRoot.value = defaultRoot;
|
||||
}
|
||||
|
||||
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||
document.getElementById('modelRootLabel').textContent =
|
||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||
|
||||
@@ -1034,109 +1019,6 @@ export class DownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether this download routes to the diffusion model (unet)
|
||||
* roots rather than the checkpoint roots. The backend owns the routing
|
||||
* rule (file type first, baseModel fallback), so the location step asks
|
||||
* it; if the endpoint is unavailable we degrade to the local file-type
|
||||
* signal, which matches the backend for well-annotated models.
|
||||
*/
|
||||
async _resolveIsDiffusionModel() {
|
||||
const localFileTypeCheck = this.selectedFile
|
||||
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||
: (this.currentVersion?.files || []).some(
|
||||
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||
);
|
||||
|
||||
// Only checkpoint downloads can route to the diffusion model roots;
|
||||
// without version metadata (e.g. Hugging Face downloads) the local
|
||||
// signal is all we have.
|
||||
if (this.apiClient.modelType !== 'checkpoints'
|
||||
|| (!this.selectedFile && !this.currentVersion)) {
|
||||
return localFileTypeCheck;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileTypes = this.selectedFile
|
||||
? [this.selectedFile.type]
|
||||
: (this.currentVersion?.files || []).map(f => f.type);
|
||||
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_type: 'checkpoint',
|
||||
base_model: this.currentVersion?.baseModel || '',
|
||||
file_types: fileTypes,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`routing endpoint returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (typeof data.is_diffusion_model === 'boolean') {
|
||||
return data.is_diffusion_model;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[download] routing endpoint unavailable, '
|
||||
+ 'falling back to local file-type check:', error);
|
||||
}
|
||||
return localFileTypeCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which other-page sub_type (vae/upscaler/text_encoder/
|
||||
* clip_vision/controlnet) this download routes to. The backend owns the
|
||||
* routing rule (explicit file pick first, model.type next, file.type
|
||||
* fallback), so the location step sends both the picked file's type
|
||||
* (selected_file_type) and the version's full file-type list and lets
|
||||
* the backend apply its priority chain. Returns null when the sub_type
|
||||
* cannot be decided; the location step then lists all other roots for
|
||||
* manual selection instead of guessing a folder.
|
||||
*/
|
||||
async _resolveOtherSubType() {
|
||||
// Only other-page downloads route by sub_type; without version
|
||||
// metadata (e.g. Hugging Face downloads) there is nothing to route on.
|
||||
if (this.apiClient.modelType !== 'other'
|
||||
|| (!this.selectedFile && !this.currentVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileTypes = (this.currentVersion?.files || []).map(f => f.type);
|
||||
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model_type: 'other',
|
||||
base_model: this.currentVersion?.baseModel || '',
|
||||
file_types: fileTypes,
|
||||
...(this.selectedFile
|
||||
? { selected_file_type: this.selectedFile.type }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`routing endpoint returned ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.disabled) {
|
||||
// The matching sub_type (or the whole Other Models feature) is
|
||||
// switched off: auto-routing is refused, so offer the settings
|
||||
// shortcut while the user's intent is clear.
|
||||
showActionToast('other.disabled.downloadBlocked', {}, 'warning', {
|
||||
actionText: translate('other.disabled.enableAction', {}, 'Enable Other Models'),
|
||||
onAction: () => openOtherModelsSettings(),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return data.sub_type || null;
|
||||
} catch (error) {
|
||||
console.warn('[download] other routing endpoint unavailable, '
|
||||
+ 'falling back to manual root selection:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
loadDefaultPathSetting() {
|
||||
const modelType = this.apiClient.modelType;
|
||||
const storageKey = `use_default_path_${modelType}`;
|
||||
@@ -2520,14 +2402,9 @@ export class DownloadManager {
|
||||
const singularType = this._isDiffusionModel
|
||||
? 'unet'
|
||||
: this.apiClient.modelType.replace(/s$/, '');
|
||||
const templates = state.global?.settings?.download_path_templates;
|
||||
const template = templates?.[singularType];
|
||||
// An empty or absent template means a flat layout: keep the
|
||||
// root as-is instead of appending "/undefined" or a
|
||||
// dangling slash.
|
||||
if (template) {
|
||||
fullPath += `/${template}`;
|
||||
}
|
||||
const templates = state.global.settings.download_path_templates;
|
||||
const template = templates[singularType];
|
||||
fullPath += `/${template}`;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch template:', error);
|
||||
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user