mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 19:44:08 -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
|
### 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.
|
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||||
- Request handlers in `py/routes/handlers/` implement route logic
|
- Request handlers in `py/routes/handlers/` implement route logic
|
||||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
- 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
|
### Recipe System
|
||||||
|
|
||||||
@@ -190,8 +186,6 @@ The system runs in two modes:
|
|||||||
|
|
||||||
- `py/config.py` manages folder paths for models and handles symlink mappings
|
- `py/config.py` manages folder paths for models and handles symlink mappings
|
||||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
- 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
|
### 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`
|
- 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`
|
- 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
|
## Key Integration Points
|
||||||
|
|
||||||
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
|
- **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
|
## Important Notes
|
||||||
|
|
||||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
- 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`
|
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||||
- Symlinks require normalized paths.
|
- Symlinks require normalized paths.
|
||||||
**Business paths vs real paths**: All stored paths and operation routing use the
|
**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
|
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||||
`locales/`.
|
`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).
|
same key structure).
|
||||||
|
|
||||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
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
|
> 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**,
|
> (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.
|
> 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 |
|
| 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 |
|
| 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 |
|
| 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" |
|
| 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`) |
|
| 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 |
|
| Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence |
|
||||||
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
|
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
|
||||||
| recipe counter | 件 or 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
||||||
|
|
||||||
### ko
|
### ko
|
||||||
|
|
||||||
@@ -242,56 +222,6 @@ and must be normalized. `en` = keep the English word as-is.
|
|||||||
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
||||||
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
|
| 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)
|
## 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:
|
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)`),
|
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
|
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
|
||||||
(`CivitAI → CivArchive → Archive DB`), model-type names (`settings.priorityTags.modelTypes.*`,
|
(`CivitAI → CivArchive → Archive DB`), and the external playlist title
|
||||||
`settings.folderSettings.subTypeVae` … `subTypeControlnet` — see §2), and the external playlist
|
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
||||||
title (`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
|
||||||
|
|
||||||
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
|
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
|
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",
|
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||||
"modelNameCopied": "Modellname kopiert",
|
|
||||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "Alle {typePlural} verfügen bereits über Lizenzmetadaten",
|
"none": "Alle {typePlural} verfügen bereits über Lizenzmetadaten",
|
||||||
"error": "Lizenzmetadaten für {typePlural} konnten nicht aktualisiert werden: {message}"
|
"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": {
|
"rematchRecipes": {
|
||||||
"label": "Rezepte lokalen Modellen neu zuordnen",
|
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||||
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
||||||
"success": "{entries} Einträge in {recipes} Rezepten 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)",
|
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
||||||
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "Rezepte",
|
"recipes": "Rezepte",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"other": "Andere",
|
|
||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Nach Modell gruppieren",
|
"groupByModel": "Nach Modell gruppieren",
|
||||||
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes CivitAI-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
|
"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",
|
"displayDensity": "Anzeige-Dichte",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Standard",
|
"default": "Standard",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"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",
|
"recipesPath": "Rezepte-Speicherpfad",
|
||||||
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
"setContentRating": "Inhaltsbewertung für alle festlegen",
|
||||||
"copyAll": "Alle Syntax kopieren",
|
"copyAll": "Alle Syntax kopieren",
|
||||||
"refreshAll": "Alle Metadaten aktualisieren",
|
"refreshAll": "Alle Metadaten aktualisieren",
|
||||||
|
"repairMetadata": "Metadaten der Auswahl reparieren",
|
||||||
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
|
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
|
||||||
"reimportMetadata": "Aus Quelle neu importieren",
|
"reimportMetadata": "Aus Quelle neu importieren",
|
||||||
"checkUpdates": "Auswahl auf Updates prüfen",
|
"checkUpdates": "Auswahl auf Updates prüfen",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "Vorschau ersetzen",
|
"replacePreview": "Vorschau ersetzen",
|
||||||
"setContentRating": "Inhaltsbewertung festlegen",
|
"setContentRating": "Inhaltsbewertung festlegen",
|
||||||
"moveToFolder": "In Ordner verschieben",
|
"moveToFolder": "In Ordner verschieben",
|
||||||
|
"repairMetadata": "Metadaten reparieren",
|
||||||
"rematchMetadata": "Mit lokalen Modellen abgleichen",
|
"rematchMetadata": "Mit lokalen Modellen abgleichen",
|
||||||
"reimportMetadata": "Aus Quelle neu importieren",
|
"reimportMetadata": "Aus Quelle neu importieren",
|
||||||
"excludeModel": "Modell ausschließen",
|
"excludeModel": "Modell ausschließen",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
"prepareError": "Fehler beim Vorbereiten der LoRAs für den Download: {message}"
|
"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": {
|
"reimport": {
|
||||||
"starting": "Rezept wird aus Quelle neu importiert...",
|
"starting": "Rezept wird aus Quelle neu importiert...",
|
||||||
"success": "Rezept erfolgreich neu importiert",
|
"success": "Rezept erfolgreich neu importiert",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding-Modelle"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Stammverzeichnis",
|
"modelRoot": "Stammverzeichnis",
|
||||||
"collapseAll": "Alle Ordner einklappen",
|
"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.",
|
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
||||||
"downloadButton": "{count} LoRA(s) herunterladen"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Lokale Beispielbilder",
|
"title": "Lokale Beispielbilder",
|
||||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "Embedding Manager wird initialisiert",
|
"title": "Embedding Manager wird initialisiert",
|
||||||
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
"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": {
|
"recipes": {
|
||||||
"title": "Rezept Manager wird initialisiert",
|
"title": "Rezept Manager wird initialisiert",
|
||||||
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||||
"created": "Rezept erfolgreich erstellt",
|
"created": "Rezept erfolgreich erstellt",
|
||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"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",
|
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
|
||||||
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
||||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
||||||
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
||||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
"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",
|
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||||
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
|
|
||||||
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
"reimportSuccess": "Rezept erfolgreich neu importiert",
|
||||||
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
|
||||||
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||||
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-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})",
|
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
||||||
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
||||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "Cache wird neu aufgebaut...",
|
"rebuilding": "Cache wird neu aufgebaut...",
|
||||||
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
|
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
|
||||||
"retry": "Wiederholen"
|
"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",
|
"copyCheckpointName": "Copy checkpoint name",
|
||||||
"copyEmbeddingName": "Copy embedding name",
|
"copyEmbeddingName": "Copy embedding name",
|
||||||
"embeddingNameCopied": "Embedding syntax copied",
|
"embeddingNameCopied": "Embedding syntax copied",
|
||||||
"modelNameCopied": "Model name copied",
|
|
||||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "All {typePlural} already have license metadata",
|
"none": "All {typePlural} already have license metadata",
|
||||||
"error": "Failed to refresh license metadata for {typePlural}: {message}"
|
"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": {
|
"rematchRecipes": {
|
||||||
"label": "Rematch recipes to local models",
|
"label": "Rematch recipes to local models",
|
||||||
"loading": "Rematching recipes to local models...",
|
"loading": "Rematching recipes to local models...",
|
||||||
"success": "Matched {entries} entries across {recipes} recipes",
|
"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).",
|
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
||||||
"error": "Recipe rematch failed: {message}"
|
"error": "Recipe rematch failed: {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"other": "Other",
|
|
||||||
"statistics": "Stats"
|
"statistics": "Stats"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Group by Model",
|
"groupByModel": "Group by Model",
|
||||||
"groupByModelHelp": "When enabled, only the latest version of each CivitAI model is shown as a single card. Older versions are hidden.",
|
"groupByModelHelp": "When enabled, only the latest version of each CivitAI model is shown as a single card. Older versions are hidden.",
|
||||||
"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",
|
"displayDensity": "Display Density",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||||
"defaultEmbeddingRoot": "Embedding Root",
|
"defaultEmbeddingRoot": "Embedding Root",
|
||||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
"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",
|
"recipesPath": "Recipes Storage Path",
|
||||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "Set Content Rating for Selected",
|
"setContentRating": "Set Content Rating for Selected",
|
||||||
"copyAll": "Copy Selected Syntax",
|
"copyAll": "Copy Selected Syntax",
|
||||||
"refreshAll": "Refresh Selected Metadata",
|
"refreshAll": "Refresh Selected Metadata",
|
||||||
|
"repairMetadata": "Repair Metadata for Selected",
|
||||||
"rematchMetadata": "Rematch Selected to Local Models",
|
"rematchMetadata": "Rematch Selected to Local Models",
|
||||||
"reimportMetadata": "Re-import from Source",
|
"reimportMetadata": "Re-import from Source",
|
||||||
"checkUpdates": "Check Updates for Selected",
|
"checkUpdates": "Check Updates for Selected",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "Replace Preview",
|
"replacePreview": "Replace Preview",
|
||||||
"setContentRating": "Set Content Rating",
|
"setContentRating": "Set Content Rating",
|
||||||
"moveToFolder": "Move to Folder",
|
"moveToFolder": "Move to Folder",
|
||||||
|
"repairMetadata": "Repair metadata",
|
||||||
"rematchMetadata": "Rematch to local models",
|
"rematchMetadata": "Rematch to local models",
|
||||||
"reimportMetadata": "Re-import from Source",
|
"reimportMetadata": "Re-import from Source",
|
||||||
"excludeModel": "Exclude Model",
|
"excludeModel": "Exclude Model",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "Failed to get information for missing LoRAs",
|
"getInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
"prepareError": "Error preparing LoRAs for download: {message}"
|
"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": {
|
"reimport": {
|
||||||
"starting": "Re-importing recipe from source...",
|
"starting": "Re-importing recipe from source...",
|
||||||
"success": "Recipe re-imported successfully",
|
"success": "Recipe re-imported successfully",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding Models"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Root",
|
"modelRoot": "Root",
|
||||||
"collapseAll": "Collapse All Folders",
|
"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.",
|
"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)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Local Example Images",
|
"title": "Local Example Images",
|
||||||
"message": "No local example images found for this model. View options:",
|
"message": "No local example images found for this model. View options:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "Initializing Embedding Manager",
|
"title": "Initializing Embedding Manager",
|
||||||
"message": "Scanning and building embedding cache. This may take a few minutes..."
|
"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": {
|
"recipes": {
|
||||||
"title": "Initializing Recipe Manager",
|
"title": "Initializing Recipe Manager",
|
||||||
"message": "Loading and processing recipes. This may take a few minutes..."
|
"message": "Loading and processing recipes. This may take a few minutes..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "Missing required data to create recipe",
|
"createMissingData": "Missing required data to create recipe",
|
||||||
"created": "Recipe created successfully",
|
"created": "Recipe created successfully",
|
||||||
"noMissingLoras": "No missing LoRAs to download",
|
"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",
|
"noPreviousRecipe": "No previous recipe available",
|
||||||
"noNextRecipe": "No next recipe available",
|
"noNextRecipe": "No next recipe available",
|
||||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No recipes selected",
|
"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",
|
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||||
"reimporting": "Re-importing recipe from source...",
|
"reimporting": "Re-importing recipe from source...",
|
||||||
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
|
|
||||||
"reimportSuccess": "Recipe re-imported successfully",
|
"reimportSuccess": "Recipe re-imported successfully",
|
||||||
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
|
||||||
"reimportBulkFailed": "Failed to re-import some recipes",
|
"reimportBulkFailed": "Failed to re-import some recipes",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||||
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||||
"embeddingRootsFailed": "Failed to load embedding 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})",
|
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||||
"mappingsCleared": "Base model path mappings cleared",
|
"mappingsCleared": "Base model path mappings cleared",
|
||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "Rebuilding cache...",
|
"rebuilding": "Rebuilding cache...",
|
||||||
"rebuildFailed": "Failed to rebuild cache: {error}",
|
"rebuildFailed": "Failed to rebuild cache: {error}",
|
||||||
"retry": "Retry"
|
"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",
|
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||||
"modelNameCopied": "Nombre del modelo copiado",
|
|
||||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "Todos los {typePlural} ya tienen metadatos de licencia",
|
"none": "Todos los {typePlural} ya tienen metadatos de licencia",
|
||||||
"error": "No se pudieron actualizar los metadatos de licencia de los {typePlural}: {message}"
|
"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": {
|
"rematchRecipes": {
|
||||||
"label": "Reasociar recetas con modelos locales",
|
"label": "Reasociar recetas con modelos locales",
|
||||||
"loading": "Reasociando recetas con modelos locales...",
|
"loading": "Reasociando recetas con modelos locales...",
|
||||||
"success": "{entries} entradas asociadas en {recipes} recetas",
|
"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)",
|
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
||||||
"error": "Falló la reasociación de recetas: {message}"
|
"error": "Falló la reasociación de recetas: {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "Recetas",
|
"recipes": "Recetas",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"other": "Otros",
|
|
||||||
"statistics": "Estadísticas"
|
"statistics": "Estadísticas"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Agrupar por modelo",
|
"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.",
|
"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",
|
"displayDensity": "Densidad de visualización",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Predeterminado",
|
"default": "Predeterminado",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"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",
|
"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.",
|
"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",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "Establecer clasificación de contenido para todos",
|
"setContentRating": "Establecer clasificación de contenido para todos",
|
||||||
"copyAll": "Copiar toda la sintaxis",
|
"copyAll": "Copiar toda la sintaxis",
|
||||||
"refreshAll": "Actualizar todos los metadatos",
|
"refreshAll": "Actualizar todos los metadatos",
|
||||||
|
"repairMetadata": "Reparar metadatos de la selección",
|
||||||
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
|
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
|
||||||
"reimportMetadata": "Reimportar desde origen",
|
"reimportMetadata": "Reimportar desde origen",
|
||||||
"checkUpdates": "Comprobar actualizaciones para la selección",
|
"checkUpdates": "Comprobar actualizaciones para la selección",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "Reemplazar vista previa",
|
"replacePreview": "Reemplazar vista previa",
|
||||||
"setContentRating": "Establecer clasificación de contenido",
|
"setContentRating": "Establecer clasificación de contenido",
|
||||||
"moveToFolder": "Mover a carpeta",
|
"moveToFolder": "Mover a carpeta",
|
||||||
|
"repairMetadata": "Reparar metadatos",
|
||||||
"rematchMetadata": "Reasociar con modelos locales",
|
"rematchMetadata": "Reasociar con modelos locales",
|
||||||
"reimportMetadata": "Reimportar desde origen",
|
"reimportMetadata": "Reimportar desde origen",
|
||||||
"excludeModel": "Excluir modelo",
|
"excludeModel": "Excluir modelo",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
"prepareError": "Error preparando LoRAs para descarga: {message}"
|
"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": {
|
"reimport": {
|
||||||
"starting": "Reimportando receta desde origen...",
|
"starting": "Reimportando receta desde origen...",
|
||||||
"success": "Receta reimportada exitosamente",
|
"success": "Receta reimportada exitosamente",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modelos embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Raíz",
|
"modelRoot": "Raíz",
|
||||||
"collapseAll": "Colapsar todas las carpetas",
|
"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.",
|
"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)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Imágenes de ejemplo locales",
|
"title": "Imágenes de ejemplo locales",
|
||||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
"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",
|
"title": "Inicializando gestor de embedding",
|
||||||
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
"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": {
|
"recipes": {
|
||||||
"title": "Inicializando gestor de recetas",
|
"title": "Inicializando gestor de recetas",
|
||||||
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "Faltan datos necesarios para crear la receta",
|
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||||
"created": "Receta creada exitosamente",
|
"created": "Receta creada exitosamente",
|
||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"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",
|
"noPreviousRecipe": "No hay receta anterior disponible",
|
||||||
"noNextRecipe": "No hay siguiente receta disponible",
|
"noNextRecipe": "No hay siguiente receta disponible",
|
||||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
||||||
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
||||||
"noRecipesSelected": "No se han seleccionado recetas",
|
"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",
|
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||||
"reimporting": "Reimportando receta desde origen...",
|
"reimporting": "Reimportando receta desde origen...",
|
||||||
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
|
|
||||||
"reimportSuccess": "Receta reimportada exitosamente",
|
"reimportSuccess": "Receta reimportada exitosamente",
|
||||||
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
|
||||||
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
"reimportBulkFailed": "Error al reimportar algunas recetas",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||||
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {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})",
|
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "Reconstruyendo caché...",
|
"rebuilding": "Reconstruyendo caché...",
|
||||||
"rebuildFailed": "Error al reconstruir la caché: {error}",
|
"rebuildFailed": "Error al reconstruir la caché: {error}",
|
||||||
"retry": "Reintentar"
|
"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",
|
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||||
"modelNameCopied": "Nom du modèle copié",
|
|
||||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "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",
|
"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}"
|
"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": {
|
"rematchRecipes": {
|
||||||
"label": "Réassocier les Recipes aux modèles locaux",
|
"label": "Réassocier les Recipes aux modèles locaux",
|
||||||
"loading": "Réassociation des Recipes aux modèles locaux...",
|
"loading": "Réassociation des Recipes aux modèles locaux...",
|
||||||
"success": "{entries} entrées associées dans {recipes} Recipes",
|
"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)",
|
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
||||||
"error": "Échec de la réassociation des Recipes : {message}"
|
"error": "Échec de la réassociation des Recipes : {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"other": "Autres",
|
|
||||||
"statistics": "Statistiques"
|
"statistics": "Statistiques"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Grouper par modèle",
|
"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.",
|
"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",
|
"displayDensity": "Densité d'affichage",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "Par défaut",
|
"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",
|
"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",
|
"defaultEmbeddingRoot": "Racine Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"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",
|
"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.",
|
"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",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "Définir la classification du contenu pour tous",
|
"setContentRating": "Définir la classification du contenu pour tous",
|
||||||
"copyAll": "Copier toute la syntaxe",
|
"copyAll": "Copier toute la syntaxe",
|
||||||
"refreshAll": "Actualiser toutes les métadonnées",
|
"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",
|
"rematchMetadata": "Réassocier la sélection aux modèles locaux",
|
||||||
"reimportMetadata": "Ré-importer depuis la source",
|
"reimportMetadata": "Ré-importer depuis la source",
|
||||||
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
"checkUpdates": "Vérifier les mises à jour pour la sélection",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "Remplacer l'aperçu",
|
"replacePreview": "Remplacer l'aperçu",
|
||||||
"setContentRating": "Définir la classification du contenu",
|
"setContentRating": "Définir la classification du contenu",
|
||||||
"moveToFolder": "Déplacer vers un dossier",
|
"moveToFolder": "Déplacer vers un dossier",
|
||||||
|
"repairMetadata": "Réparer les métadonnées",
|
||||||
"rematchMetadata": "Réassocier aux modèles locaux",
|
"rematchMetadata": "Réassocier aux modèles locaux",
|
||||||
"reimportMetadata": "Ré-importer depuis la source",
|
"reimportMetadata": "Ré-importer depuis la source",
|
||||||
"excludeModel": "Exclure le modèle",
|
"excludeModel": "Exclure le modèle",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"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}"
|
"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": {
|
"reimport": {
|
||||||
"starting": "Ré-import de la Recipe depuis la source...",
|
"starting": "Ré-import de la Recipe depuis la source...",
|
||||||
"success": "Recette ré-importée avec succès",
|
"success": "Recette ré-importée avec succès",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modèles Embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Racine",
|
"modelRoot": "Racine",
|
||||||
"collapseAll": "Réduire tous les dossiers",
|
"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.",
|
"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)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Images d'exemple locales",
|
"title": "Images d'exemple locales",
|
||||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "Initialisation du gestionnaire Embedding",
|
"title": "Initialisation du gestionnaire Embedding",
|
||||||
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
"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": {
|
"recipes": {
|
||||||
"title": "Initialisation du gestionnaire de recipes",
|
"title": "Initialisation du gestionnaire de recipes",
|
||||||
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
"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",
|
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||||
"created": "Recipe créé avec succès",
|
"created": "Recipe créé avec succès",
|
||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"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",
|
"noPreviousRecipe": "Aucune Recipe précédente",
|
||||||
"noNextRecipe": "Aucune Recipe suivante",
|
"noNextRecipe": "Aucune Recipe suivante",
|
||||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
||||||
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
||||||
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
"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",
|
"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}",
|
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
"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",
|
"reimportSuccess": "Recette ré-importée avec succès",
|
||||||
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
|
||||||
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
|
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||||
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {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})",
|
"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",
|
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "Reconstruction du cache...",
|
"rebuilding": "Reconstruction du cache...",
|
||||||
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
|
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
|
||||||
"retry": "Réessayer"
|
"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",
|
"copyCheckpointName": "העתק שם Checkpoint",
|
||||||
"copyEmbeddingName": "העתק שם Embedding",
|
"copyEmbeddingName": "העתק שם Embedding",
|
||||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||||
"modelNameCopied": "שם המודל הועתק",
|
|
||||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "לכל ה-{typePlural} כבר יש מטא-נתוני רישיון",
|
"none": "לכל ה-{typePlural} כבר יש מטא-נתוני רישיון",
|
||||||
"error": "לא ניתן היה לרענן את מטא-נתוני הרישיון עבור {typePlural}: {message}"
|
"error": "לא ניתן היה לרענן את מטא-נתוני הרישיון עבור {typePlural}: {message}"
|
||||||
},
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "תיקון נתוני מתכונים",
|
||||||
|
"loading": "מתקן נתוני מתכונים...",
|
||||||
|
"success": "תוקנו בהצלחה {count} מתכונים.",
|
||||||
|
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
|
||||||
|
"error": "תיקון המתכונים נכשל: {message}"
|
||||||
|
},
|
||||||
"rematchRecipes": {
|
"rematchRecipes": {
|
||||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
|
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||||
|
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
||||||
|
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "מתכונים",
|
"recipes": "מתכונים",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"other": "אחרים",
|
|
||||||
"statistics": "סטטיסטיקה"
|
"statistics": "סטטיסטיקה"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "קיבוץ לפי מודל",
|
"groupByModel": "קיבוץ לפי מודל",
|
||||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||||
"stickyControls": "השארת סרגל הפעולות גלוי",
|
|
||||||
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
|
|
||||||
"displayDensity": "צפיפות תצוגה",
|
"displayDensity": "צפיפות תצוגה",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "ברירת מחדל",
|
"default": "ברירת מחדל",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של 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": "נתיב אחסון מתכונים",
|
"recipesPath": "נתיב אחסון מתכונים",
|
||||||
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-נתונים",
|
"refreshAll": "רענן את כל המטא-נתונים",
|
||||||
|
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
|
||||||
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "החלף תצוגה מקדימה",
|
"replacePreview": "החלף תצוגה מקדימה",
|
||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
|
"repairMetadata": "תיקון מטא-נתונים",
|
||||||
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||||
},
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "מתקן מטא-נתונים של מתכון...",
|
||||||
|
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
|
||||||
|
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
||||||
|
"failed": "תיקון המתכון נכשל: {message}",
|
||||||
|
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
||||||
|
},
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "מייבא מתכון מחדש מהמקור...",
|
"starting": "מייבא מתכון מחדש מהמקור...",
|
||||||
"success": "המתכון יובא מחדש בהצלחה",
|
"success": "המתכון יובא מחדש בהצלחה",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "מודלי Embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "שורש",
|
"modelRoot": "שורש",
|
||||||
"collapseAll": "כווץ את כל התיקיות",
|
"collapseAll": "כווץ את כל התיקיות",
|
||||||
@@ -1539,41 +1515,6 @@
|
|||||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||||
"downloadButton": "הורד {count} LoRA(s)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "תמונות דוגמה מקומיות",
|
"title": "תמונות דוגמה מקומיות",
|
||||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "מאתחל מנהל Embedding",
|
"title": "מאתחל מנהל Embedding",
|
||||||
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
||||||
},
|
},
|
||||||
"other": {
|
|
||||||
"title": "מאתחל את מנהל המודלים האחרים",
|
|
||||||
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
|
|
||||||
},
|
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "מאתחל מנהל מתכונים",
|
"title": "מאתחל מנהל מתכונים",
|
||||||
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||||
"created": "המתכון נוצר בהצלחה",
|
"created": "המתכון נוצר בהצלחה",
|
||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
|
|
||||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
|
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
||||||
|
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
||||||
|
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
||||||
|
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
|
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||||
|
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||||
|
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
||||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||||
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
|
|
||||||
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
|
||||||
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
|
||||||
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||||
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||||
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
|
|
||||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
||||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "בונה מחדש את המטמון...",
|
"rebuilding": "בונה מחדש את המטמון...",
|
||||||
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
||||||
"retry": "נסה שוב"
|
"retry": "נסה שוב"
|
||||||
},
|
|
||||||
"otherModels": {
|
|
||||||
"title": "ניהול המודלים האחרים זמין",
|
|
||||||
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
|
||||||
"enable": "הפעל מודלים אחרים",
|
|
||||||
"openSettings": "פתח הגדרות"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-91
@@ -149,7 +149,6 @@
|
|||||||
"copyCheckpointName": "Checkpoint名をコピー",
|
"copyCheckpointName": "Checkpoint名をコピー",
|
||||||
"copyEmbeddingName": "embedding名をコピー",
|
"copyEmbeddingName": "embedding名をコピー",
|
||||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||||
"modelNameCopied": "モデル名をコピーしました",
|
|
||||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
|
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
|
||||||
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
|
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
|
||||||
},
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "レシピデータの修復",
|
||||||
|
"loading": "レシピデータを修復中...",
|
||||||
|
"success": "{count} 件のレシピを正常に修復しました。",
|
||||||
|
"cancelled": "修復がキャンセルされました。{count}件のレシピが修復されました。",
|
||||||
|
"error": "レシピの修復に失敗しました: {message}"
|
||||||
|
},
|
||||||
"rematchRecipes": {
|
"rematchRecipes": {
|
||||||
"label": "レシピをローカルモデルに再マッチング",
|
"label": "レシピをローカルモデルに再マッチング",
|
||||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
|
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||||
|
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||||
|
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "レシピ",
|
"recipes": "レシピ",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"other": "その他",
|
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "モデルでグループ化",
|
"groupByModel": "モデルでグループ化",
|
||||||
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||||
"stickyControls": "アクションバーを常に表示",
|
|
||||||
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
|
|
||||||
"displayDensity": "表示密度",
|
"displayDensity": "表示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||||
"defaultEmbeddingRoot": "Embeddingルート",
|
"defaultEmbeddingRoot": "Embeddingルート",
|
||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルト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": "レシピ保存先",
|
"recipesPath": "レシピ保存先",
|
||||||
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
|
"repairMetadata": "選択したレシピのメタデータを修復",
|
||||||
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "プレビューを置換",
|
"replacePreview": "プレビューを置換",
|
||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
|
"repairMetadata": "メタデータを修復",
|
||||||
"rematchMetadata": "ローカルモデルに再マッチング",
|
"rematchMetadata": "ローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||||
},
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "レシピのメタデータを修復中...",
|
||||||
|
"success": "レシピのメタデータが正常に修復されました",
|
||||||
|
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
||||||
|
"failed": "レシピの修復に失敗しました: {message}",
|
||||||
|
"missingId": "レシピを修復できません: レシピIDがありません"
|
||||||
|
},
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "ソースからレシピを再インポート中...",
|
"starting": "ソースからレシピを再インポート中...",
|
||||||
"success": "レシピの再インポートが完了しました",
|
"success": "レシピの再インポートが完了しました",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embeddingモデル"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "ルート",
|
"modelRoot": "ルート",
|
||||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||||
@@ -1539,41 +1515,6 @@
|
|||||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||||
"downloadButton": "{count} 個の 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": {
|
"exampleAccess": {
|
||||||
"title": "ローカル例画像",
|
"title": "ローカル例画像",
|
||||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "Embedding Managerを初期化中",
|
"title": "Embedding Managerを初期化中",
|
||||||
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||||
},
|
},
|
||||||
"other": {
|
|
||||||
"title": "その他のモデルマネージャーを初期化中",
|
|
||||||
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
|
|
||||||
},
|
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "レシピマネージャーを初期化中",
|
"title": "レシピマネージャーを初期化中",
|
||||||
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||||
"created": "レシピを作成しました",
|
"created": "レシピを作成しました",
|
||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
|
|
||||||
"noPreviousRecipe": "前のレシピがありません",
|
"noPreviousRecipe": "前のレシピがありません",
|
||||||
"noNextRecipe": "次のレシピがありません",
|
"noNextRecipe": "次のレシピがありません",
|
||||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
|
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
||||||
|
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
||||||
|
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
||||||
|
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
|
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||||
|
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||||
|
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
||||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||||
"reimporting": "ソースからレシピを再インポート中...",
|
"reimporting": "ソースからレシピを再インポート中...",
|
||||||
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
|
|
||||||
"reimportSuccess": "レシピの再インポートが完了しました",
|
"reimportSuccess": "レシピの再インポートが完了しました",
|
||||||
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
|
||||||
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
||||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||||
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
|
|
||||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
||||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "キャッシュを再構築中...",
|
"rebuilding": "キャッシュを再構築中...",
|
||||||
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
||||||
"retry": "再試行"
|
"retry": "再試行"
|
||||||
},
|
|
||||||
"otherModels": {
|
|
||||||
"title": "その他のモデル管理が利用可能になりました",
|
|
||||||
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
|
||||||
"enable": "その他のモデルを有効にする",
|
|
||||||
"openSettings": "設定を開く"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-91
@@ -149,7 +149,6 @@
|
|||||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||||
"copyEmbeddingName": "Embedding 이름 복사",
|
"copyEmbeddingName": "Embedding 이름 복사",
|
||||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||||
"modelNameCopied": "모델 이름 복사됨",
|
|
||||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
|
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
|
||||||
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
|
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
|
||||||
},
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "레시피 데이터 복구",
|
||||||
|
"loading": "레시피 데이터 복구 중...",
|
||||||
|
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
|
||||||
|
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
|
||||||
|
"error": "레시피 복구 실패: {message}"
|
||||||
|
},
|
||||||
"rematchRecipes": {
|
"rematchRecipes": {
|
||||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
|
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||||
|
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
||||||
|
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||||
"error": "레시피 재매칭 실패: {message}"
|
"error": "레시피 재매칭 실패: {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "레시피",
|
"recipes": "레시피",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"other": "기타",
|
|
||||||
"statistics": "통계"
|
"statistics": "통계"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "모델별 그룹화",
|
"groupByModel": "모델별 그룹화",
|
||||||
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||||
"stickyControls": "작업 표시줄 항상 표시",
|
|
||||||
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
|
|
||||||
"displayDensity": "표시 밀도",
|
"displayDensity": "표시 밀도",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "기본",
|
"default": "기본",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||||
"defaultEmbeddingRoot": "Embedding 루트",
|
"defaultEmbeddingRoot": "Embedding 루트",
|
||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 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": "레시피 저장 경로",
|
"recipesPath": "레시피 저장 경로",
|
||||||
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
|
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
||||||
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "미리보기 교체",
|
"replacePreview": "미리보기 교체",
|
||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
|
"repairMetadata": "메타데이터 복구",
|
||||||
"rematchMetadata": "로컬 모델에 다시 매칭",
|
"rematchMetadata": "로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||||
},
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "레시피 메타데이터 복구 중...",
|
||||||
|
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
|
||||||
|
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
||||||
|
"failed": "레시피 복구 실패: {message}",
|
||||||
|
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
||||||
|
},
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
||||||
"success": "레시피를 다시 가져왔습니다",
|
"success": "레시피를 다시 가져왔습니다",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 모델"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "루트",
|
"modelRoot": "루트",
|
||||||
"collapseAll": "모든 폴더 접기",
|
"collapseAll": "모든 폴더 접기",
|
||||||
@@ -1539,41 +1515,6 @@
|
|||||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||||
"downloadButton": "{count}개 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": {
|
"exampleAccess": {
|
||||||
"title": "로컬 예시 이미지",
|
"title": "로컬 예시 이미지",
|
||||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "Embedding Manager 초기화 중",
|
"title": "Embedding Manager 초기화 중",
|
||||||
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
},
|
},
|
||||||
"other": {
|
|
||||||
"title": "기타 모델 관리자 초기화 중",
|
|
||||||
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
|
||||||
},
|
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "레시피 매니저 초기화 중",
|
"title": "레시피 매니저 초기화 중",
|
||||||
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||||
"created": "레시피가 생성되었습니다",
|
"created": "레시피가 생성되었습니다",
|
||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
|
|
||||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||||
"noNextRecipe": "다음 레시피가 없습니다",
|
"noNextRecipe": "다음 레시피가 없습니다",
|
||||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
|
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
||||||
|
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
||||||
|
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
||||||
|
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
|
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||||
|
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||||
|
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
||||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||||
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
|
|
||||||
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
"reimportSuccess": "레시피를 다시 가져왔습니다",
|
||||||
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
|
||||||
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||||
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||||
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
|
|
||||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "캐시 재구축 중...",
|
"rebuilding": "캐시 재구축 중...",
|
||||||
"rebuildFailed": "캐시 재구축 실패: {error}",
|
"rebuildFailed": "캐시 재구축 실패: {error}",
|
||||||
"retry": "다시 시도"
|
"retry": "다시 시도"
|
||||||
},
|
|
||||||
"otherModels": {
|
|
||||||
"title": "기타 모델 관리를 사용할 수 있습니다",
|
|
||||||
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
|
||||||
"enable": "기타 모델 활성화",
|
|
||||||
"openSettings": "설정 열기"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-91
@@ -149,7 +149,6 @@
|
|||||||
"copyCheckpointName": "Копировать имя checkpoint",
|
"copyCheckpointName": "Копировать имя checkpoint",
|
||||||
"copyEmbeddingName": "Копировать имя embedding",
|
"copyEmbeddingName": "Копировать имя embedding",
|
||||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||||
"modelNameCopied": "Имя модели скопировано",
|
|
||||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "У всех {typePlural} уже есть метаданные лицензии",
|
"none": "У всех {typePlural} уже есть метаданные лицензии",
|
||||||
"error": "Не удалось обновить метаданные лицензии для {typePlural}: {message}"
|
"error": "Не удалось обновить метаданные лицензии для {typePlural}: {message}"
|
||||||
},
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "Восстановить данные рецептов",
|
||||||
|
"loading": "Восстановление данных рецептов...",
|
||||||
|
"success": "Успешно восстановлено {count} рецептов.",
|
||||||
|
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
|
||||||
|
"error": "Ошибка восстановления рецептов: {message}"
|
||||||
|
},
|
||||||
"rematchRecipes": {
|
"rematchRecipes": {
|
||||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
|
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||||
|
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
||||||
|
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "Рецепты",
|
"recipes": "Рецепты",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
"other": "Другое",
|
|
||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Группировать по модели",
|
"groupByModel": "Группировать по модели",
|
||||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
||||||
"stickyControls": "Держать панель действий видимой",
|
|
||||||
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
|
|
||||||
"displayDensity": "Плотность отображения",
|
"displayDensity": "Плотность отображения",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Установить корневую папку 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": "Путь хранения рецептов",
|
"recipesPath": "Путь хранения рецептов",
|
||||||
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "Установить рейтинг контента для всех",
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
|
"repairMetadata": "Восстановить метаданные для выбранных",
|
||||||
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "Заменить превью",
|
"replacePreview": "Заменить превью",
|
||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
|
"repairMetadata": "Восстановить метаданные",
|
||||||
"rematchMetadata": "Сопоставить с локальными моделями",
|
"rematchMetadata": "Сопоставить с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||||
},
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "Восстановление метаданных рецепта...",
|
||||||
|
"success": "Метаданные рецепта успешно восстановлены",
|
||||||
|
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
||||||
|
"failed": "Не удалось восстановить рецепт: {message}",
|
||||||
|
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
||||||
|
},
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "Переимпорт рецепта из источника...",
|
"starting": "Переимпорт рецепта из источника...",
|
||||||
"success": "Рецепт успешно переимпортирован",
|
"success": "Рецепт успешно переимпортирован",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Модели Embedding"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "Корень",
|
"modelRoot": "Корень",
|
||||||
"collapseAll": "Свернуть все папки",
|
"collapseAll": "Свернуть все папки",
|
||||||
@@ -1539,41 +1515,6 @@
|
|||||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||||
"downloadButton": "Скачать {count} LoRA(s)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "Локальные примеры изображений",
|
"title": "Локальные примеры изображений",
|
||||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "Инициализация Embedding Manager",
|
"title": "Инициализация Embedding Manager",
|
||||||
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
||||||
},
|
},
|
||||||
"other": {
|
|
||||||
"title": "Инициализация менеджера других моделей",
|
|
||||||
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
|
|
||||||
},
|
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Инициализация менеджера рецептов",
|
"title": "Инициализация менеджера рецептов",
|
||||||
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||||
"created": "Рецепт успешно создан",
|
"created": "Рецепт успешно создан",
|
||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
|
|
||||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
|
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
||||||
|
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
||||||
|
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
||||||
|
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
|
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||||
|
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||||
|
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
||||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||||
"reimporting": "Переимпорт рецепта из источника...",
|
"reimporting": "Переимпорт рецепта из источника...",
|
||||||
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
|
|
||||||
"reimportSuccess": "Рецепт успешно переимпортирован",
|
"reimportSuccess": "Рецепт успешно переимпортирован",
|
||||||
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
|
||||||
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||||
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||||
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
|
|
||||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
||||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "Перестроение кэша...",
|
"rebuilding": "Перестроение кэша...",
|
||||||
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
||||||
"retry": "Повторить"
|
"retry": "Повторить"
|
||||||
},
|
|
||||||
"otherModels": {
|
|
||||||
"title": "Управление другими моделями доступно",
|
|
||||||
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
|
||||||
"enable": "Включить другие модели",
|
|
||||||
"openSettings": "Открыть настройки"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-91
@@ -149,7 +149,6 @@
|
|||||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||||
"copyEmbeddingName": "复制 Embedding 名称",
|
"copyEmbeddingName": "复制 Embedding 名称",
|
||||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||||
"modelNameCopied": "模型名称已复制",
|
|
||||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "所有 {typePlural} 都已具备许可证元数据",
|
"none": "所有 {typePlural} 都已具备许可证元数据",
|
||||||
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
|
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
|
||||||
},
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修复配方数据",
|
||||||
|
"loading": "正在修复配方数据...",
|
||||||
|
"success": "成功修复了 {count} 个配方。",
|
||||||
|
"cancelled": "修复已取消。已修复 {count} 个配方。",
|
||||||
|
"error": "配方修复失败:{message}"
|
||||||
|
},
|
||||||
"rematchRecipes": {
|
"rematchRecipes": {
|
||||||
"label": "将配方重新匹配到本地模型",
|
"label": "将配方重新匹配到本地模型",
|
||||||
"loading": "正在将配方重新匹配到本地模型...",
|
"loading": "正在将配方重新匹配到本地模型...",
|
||||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||||
|
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||||
|
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
||||||
|
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||||
"error": "配方重新匹配失败:{message}"
|
"error": "配方重新匹配失败:{message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"other": "其他",
|
|
||||||
"statistics": "统计"
|
"statistics": "统计"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "按模型分组",
|
"groupByModel": "按模型分组",
|
||||||
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||||
"stickyControls": "保持操作栏可见",
|
|
||||||
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
|
|
||||||
"displayDensity": "显示密度",
|
"displayDensity": "显示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 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": "配方存储路径",
|
"recipesPath": "配方存储路径",
|
||||||
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "为所选中设置内容评级",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
|
"repairMetadata": "修复所选中元数据",
|
||||||
"rematchMetadata": "将所选中重新匹配到本地模型",
|
"rematchMetadata": "将所选中重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "替换预览",
|
"replacePreview": "替换预览",
|
||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
|
"repairMetadata": "修复元数据",
|
||||||
"rematchMetadata": "重新匹配到本地模型",
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||||
},
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "正在修复配方元数据...",
|
||||||
|
"success": "配方元数据修复成功",
|
||||||
|
"skipped": "配方已是最新版本,无需修复",
|
||||||
|
"failed": "修复配方失败:{message}",
|
||||||
|
"missingId": "无法修复配方:缺少配方 ID"
|
||||||
|
},
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "正在从源重新导入配方...",
|
"starting": "正在从源重新导入配方...",
|
||||||
"success": "配方已从源重新导入成功",
|
"success": "配方已从源重新导入成功",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "根目录",
|
"modelRoot": "根目录",
|
||||||
"collapseAll": "折叠所有文件夹",
|
"collapseAll": "折叠所有文件夹",
|
||||||
@@ -1539,41 +1515,6 @@
|
|||||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||||
"downloadButton": "下载 {count} 个 LoRA(s)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "本地示例图片",
|
"title": "本地示例图片",
|
||||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "初始化 Embedding 管理器",
|
"title": "初始化 Embedding 管理器",
|
||||||
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
||||||
},
|
},
|
||||||
"other": {
|
|
||||||
"title": "正在初始化其他模型管理器",
|
|
||||||
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
|
|
||||||
},
|
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "初始化配方管理器",
|
"title": "初始化配方管理器",
|
||||||
"message": "正在加载和处理配方。这可能需要几分钟..."
|
"message": "正在加载和处理配方。这可能需要几分钟..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "缺少创建配方所需的数据",
|
"createMissingData": "缺少创建配方所需的数据",
|
||||||
"created": "配方创建成功",
|
"created": "配方创建成功",
|
||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
|
|
||||||
"noPreviousRecipe": "没有上一个配方",
|
"noPreviousRecipe": "没有上一个配方",
|
||||||
"noNextRecipe": "没有下一个配方",
|
"noNextRecipe": "没有下一个配方",
|
||||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
|
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
||||||
|
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
||||||
|
"repairBulkFailed": "修复所选配方失败:{message}",
|
||||||
|
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||||
|
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||||
|
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
||||||
|
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
||||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||||
"reimporting": "正在从源重新导入配方...",
|
"reimporting": "正在从源重新导入配方...",
|
||||||
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
|
|
||||||
"reimportSuccess": "配方已从源重新导入成功",
|
"reimportSuccess": "配方已从源重新导入成功",
|
||||||
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
|
||||||
"reimportBulkFailed": "重新导入某些配方失败",
|
"reimportBulkFailed": "重新导入某些配方失败",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||||
"otherRootsFailed": "加载其他模型根目录失败:{message}",
|
|
||||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
||||||
"mappingsCleared": "基础模型路径映射已清除",
|
"mappingsCleared": "基础模型路径映射已清除",
|
||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "正在重建缓存...",
|
"rebuilding": "正在重建缓存...",
|
||||||
"rebuildFailed": "重建缓存失败:{error}",
|
"rebuildFailed": "重建缓存失败:{error}",
|
||||||
"retry": "重试"
|
"retry": "重试"
|
||||||
},
|
|
||||||
"otherModels": {
|
|
||||||
"title": "其他模型管理现已可用",
|
|
||||||
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
|
||||||
"enable": "启用其他模型",
|
|
||||||
"openSettings": "打开设置"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-91
@@ -149,7 +149,6 @@
|
|||||||
"copyCheckpointName": "複製 Checkpoint 名稱",
|
"copyCheckpointName": "複製 Checkpoint 名稱",
|
||||||
"copyEmbeddingName": "複製嵌入名稱",
|
"copyEmbeddingName": "複製嵌入名稱",
|
||||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||||
"modelNameCopied": "模型名稱已複製",
|
|
||||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -213,10 +212,20 @@
|
|||||||
"none": "所有 {typePlural} 已具備授權中繼資料",
|
"none": "所有 {typePlural} 已具備授權中繼資料",
|
||||||
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
|
||||||
},
|
},
|
||||||
|
"repairRecipes": {
|
||||||
|
"label": "修復配方資料",
|
||||||
|
"loading": "正在修復配方資料...",
|
||||||
|
"success": "成功修復 {count} 個配方。",
|
||||||
|
"cancelled": "修復已取消。已修復 {count} 個配方。",
|
||||||
|
"error": "配方修復失敗:{message}"
|
||||||
|
},
|
||||||
"rematchRecipes": {
|
"rematchRecipes": {
|
||||||
"label": "將配方重新匹配到本地模型",
|
"label": "將配方重新匹配到本地模型",
|
||||||
"loading": "正在將配方重新匹配到本地模型...",
|
"loading": "正在將配方重新匹配到本地模型...",
|
||||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||||
|
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||||
|
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
||||||
|
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||||
"error": "配方重新匹配失敗:{message}"
|
"error": "配方重新匹配失敗:{message}"
|
||||||
},
|
},
|
||||||
@@ -234,7 +243,6 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
"other": "其他",
|
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -476,8 +484,6 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "按模型分組",
|
"groupByModel": "按模型分組",
|
||||||
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||||
"stickyControls": "保持操作列可見",
|
|
||||||
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
|
|
||||||
"displayDensity": "顯示密度",
|
"displayDensity": "顯示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "預設",
|
"default": "預設",
|
||||||
@@ -535,25 +541,6 @@
|
|||||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 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": "配方儲存路徑",
|
"recipesPath": "配方儲存路徑",
|
||||||
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -832,6 +819,7 @@
|
|||||||
"setContentRating": "為全部設定內容分級",
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
|
"repairMetadata": "修復所選中元數據",
|
||||||
"rematchMetadata": "將所選中重新匹配到本地模型",
|
"rematchMetadata": "將所選中重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
@@ -887,6 +875,7 @@
|
|||||||
"replacePreview": "更換預覽圖",
|
"replacePreview": "更換預覽圖",
|
||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
|
"repairMetadata": "修復元數據",
|
||||||
"rematchMetadata": "重新匹配到本地模型",
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
@@ -1139,6 +1128,13 @@
|
|||||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||||
},
|
},
|
||||||
|
"repair": {
|
||||||
|
"starting": "正在修復配方元數據...",
|
||||||
|
"success": "配方元數據修復成功",
|
||||||
|
"skipped": "配方已是最新版本,無需修復",
|
||||||
|
"failed": "修復配方失敗:{message}",
|
||||||
|
"missingId": "無法修復配方:缺少配方 ID"
|
||||||
|
},
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "正在從來源重新匯入配方...",
|
"starting": "正在從來源重新匯入配方...",
|
||||||
"success": "配方已從來源重新匯入成功",
|
"success": "配方已從來源重新匯入成功",
|
||||||
@@ -1222,26 +1218,6 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"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": {
|
"sidebar": {
|
||||||
"modelRoot": "根目錄",
|
"modelRoot": "根目錄",
|
||||||
"collapseAll": "全部摺疊資料夾",
|
"collapseAll": "全部摺疊資料夾",
|
||||||
@@ -1539,41 +1515,6 @@
|
|||||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||||
"downloadButton": "下載 {count} 個 LoRA(s)"
|
"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": {
|
"exampleAccess": {
|
||||||
"title": "本機範例圖片",
|
"title": "本機範例圖片",
|
||||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||||
@@ -1919,10 +1860,6 @@
|
|||||||
"title": "初始化 Embedding 管理器",
|
"title": "初始化 Embedding 管理器",
|
||||||
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
||||||
},
|
},
|
||||||
"other": {
|
|
||||||
"title": "正在初始化其他模型管理器",
|
|
||||||
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
|
|
||||||
},
|
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "初始化配方管理器",
|
"title": "初始化配方管理器",
|
||||||
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
||||||
@@ -2245,7 +2182,6 @@
|
|||||||
"createMissingData": "缺少建立配方所需的資料",
|
"createMissingData": "缺少建立配方所需的資料",
|
||||||
"created": "配方建立成功",
|
"created": "配方建立成功",
|
||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
|
|
||||||
"noPreviousRecipe": "沒有上一個配方",
|
"noPreviousRecipe": "沒有上一個配方",
|
||||||
"noNextRecipe": "沒有下一個配方",
|
"noNextRecipe": "沒有下一個配方",
|
||||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
@@ -2300,10 +2236,16 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何配方",
|
"noRecipesSelected": "未選取任何配方",
|
||||||
|
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
||||||
|
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
||||||
|
"repairBulkFailed": "修復所選配方失敗:{message}",
|
||||||
|
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||||
|
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||||
|
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
||||||
|
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
||||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||||
"reimporting": "正在從來源重新匯入配方...",
|
"reimporting": "正在從來源重新匯入配方...",
|
||||||
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
|
|
||||||
"reimportSuccess": "配方已從來源重新匯入成功",
|
"reimportSuccess": "配方已從來源重新匯入成功",
|
||||||
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
|
||||||
"reimportBulkFailed": "重新匯入某些配方失敗",
|
"reimportBulkFailed": "重新匯入某些配方失敗",
|
||||||
@@ -2378,7 +2320,6 @@
|
|||||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||||
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||||
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
|
|
||||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
@@ -2641,12 +2582,6 @@
|
|||||||
"rebuilding": "重建快取中...",
|
"rebuilding": "重建快取中...",
|
||||||
"rebuildFailed": "重建快取失敗:{error}",
|
"rebuildFailed": "重建快取失敗:{error}",
|
||||||
"retry": "重試"
|
"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
|
import time
|
||||||
|
|
||||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
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 (
|
from .utils.settings_paths import (
|
||||||
ensure_settings_file,
|
ensure_settings_file,
|
||||||
get_settings_dir,
|
get_settings_dir,
|
||||||
@@ -175,13 +172,6 @@ class Config:
|
|||||||
self.embeddings_roots = None
|
self.embeddings_roots = None
|
||||||
self.base_models_roots = self._init_checkpoint_paths()
|
self.base_models_roots = self._init_checkpoint_paths()
|
||||||
self.embeddings_roots = self._init_embedding_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)
|
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
self.extra_loras_roots: List[str] = []
|
self.extra_loras_roots: List[str] = []
|
||||||
self.extra_checkpoints_roots: List[str] = []
|
self.extra_checkpoints_roots: List[str] = []
|
||||||
@@ -346,10 +336,6 @@ class Config:
|
|||||||
"unet": list(self.unet_roots or []),
|
"unet": list(self.unet_roots or []),
|
||||||
"embeddings": list(self.embeddings_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(
|
normalized_target_paths = _normalize_folder_paths_for_comparison(
|
||||||
target_folder_paths
|
target_folder_paths
|
||||||
@@ -536,7 +522,6 @@ class Config:
|
|||||||
roots.extend(self.loras_roots or [])
|
roots.extend(self.loras_roots or [])
|
||||||
roots.extend(self.base_models_roots or [])
|
roots.extend(self.base_models_roots or [])
|
||||||
roots.extend(self.embeddings_roots or [])
|
roots.extend(self.embeddings_roots or [])
|
||||||
roots.extend(self.other_roots or [])
|
|
||||||
# Include extra paths for scanning symlinks
|
# Include extra paths for scanning symlinks
|
||||||
roots.extend(self.extra_loras_roots or [])
|
roots.extend(self.extra_loras_roots or [])
|
||||||
roots.extend(self.extra_checkpoints_roots or [])
|
roots.extend(self.extra_checkpoints_roots or [])
|
||||||
@@ -877,8 +862,6 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
for root in self.embeddings_roots or []:
|
for root in self.embeddings_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
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
|
# Include extra paths for preview access
|
||||||
for root in self.extra_loras_roots or []:
|
for root in self.extra_loras_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
@@ -899,7 +882,7 @@ class Config:
|
|||||||
path for path in preview_roots if path.is_absolute()
|
path for path in preview_roots if path.is_absolute()
|
||||||
}
|
}
|
||||||
logger.debug(
|
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._preview_root_paths),
|
||||||
len(self.loras_roots or []),
|
len(self.loras_roots or []),
|
||||||
len(self.extra_loras_roots or []),
|
len(self.extra_loras_roots or []),
|
||||||
@@ -907,7 +890,6 @@ class Config:
|
|||||||
len(self.extra_checkpoints_roots or []),
|
len(self.extra_checkpoints_roots or []),
|
||||||
len(self.embeddings_roots or []),
|
len(self.embeddings_roots or []),
|
||||||
len(self.extra_embeddings_roots or []),
|
len(self.extra_embeddings_roots or []),
|
||||||
len(self.other_roots or []),
|
|
||||||
len(self._path_mappings),
|
len(self._path_mappings),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1146,155 +1128,6 @@ class Config:
|
|||||||
|
|
||||||
return unique_paths
|
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(
|
def _apply_library_paths(
|
||||||
self,
|
self,
|
||||||
folder_paths: Mapping[str, Any],
|
folder_paths: Mapping[str, Any],
|
||||||
@@ -1318,16 +1151,6 @@ class Config:
|
|||||||
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_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)
|
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
extra_paths = extra_folder_paths or {}
|
extra_paths = extra_folder_paths or {}
|
||||||
extra_lora_paths = extra_paths.get("loras", []) or []
|
extra_lora_paths = extra_paths.get("loras", []) or []
|
||||||
@@ -1444,104 +1267,6 @@ class Config:
|
|||||||
logger.warning(f"Error initializing embedding paths: {e}")
|
logger.warning(f"Error initializing embedding paths: {e}")
|
||||||
return []
|
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:
|
def get_preview_static_url(self, preview_path: str) -> str:
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+1
-7
@@ -219,7 +219,6 @@ class LoraManager:
|
|||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
|
||||||
|
|
||||||
# Initialize recipe scanner if needed
|
# Initialize recipe scanner if needed
|
||||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||||
@@ -237,10 +236,6 @@ class LoraManager:
|
|||||||
embedding_scanner.initialize_in_background(),
|
embedding_scanner.initialize_in_background(),
|
||||||
name="embedding_cache_init",
|
name="embedding_cache_init",
|
||||||
),
|
),
|
||||||
asyncio.create_task(
|
|
||||||
other_scanner.initialize_in_background(),
|
|
||||||
name="other_cache_init",
|
|
||||||
),
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
|
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.loras_roots)
|
||||||
all_roots.update(config.base_models_roots or [])
|
all_roots.update(config.base_models_roots or [])
|
||||||
all_roots.update(config.embeddings_roots or [])
|
all_roots.update(config.embeddings_roots or [])
|
||||||
all_roots.update(config.other_roots or [])
|
|
||||||
|
|
||||||
total_deleted = 0
|
total_deleted = 0
|
||||||
total_size_freed = 0
|
total_size_freed = 0
|
||||||
@@ -466,7 +460,7 @@ class LoraManager:
|
|||||||
# Cancel any in-flight scanner initialization tasks so thread-pool
|
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||||
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||||
# when the server shuts down (e.g. Ctrl+C on WSL).
|
# 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)
|
scanner = ServiceRegistry.get_service_sync(name)
|
||||||
if scanner is not None and hasattr(scanner, "cancel_task"):
|
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||||
scanner.cancel_task()
|
scanner.cancel_task()
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ SCANNER_TYPE_MAP: dict[str, str] = {
|
|||||||
"get_lora_scanner": "lora",
|
"get_lora_scanner": "lora",
|
||||||
"get_checkpoint_scanner": "checkpoint",
|
"get_checkpoint_scanner": "checkpoint",
|
||||||
"get_embedding_scanner": "embedding",
|
"get_embedding_scanner": "embedding",
|
||||||
"get_other_scanner": "other",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
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:
|
async def identify_model_type(model_path: str) -> str:
|
||||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
|
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
||||||
``\"embedding\"``, or ``\"other\"``) for *model_path*.
|
``\"embedding\"``) for *model_path*.
|
||||||
|
|
||||||
Falls back to ``\"lora\"`` when unknown.
|
Falls back to ``\"lora\"`` when unknown.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ class BaseModelRoutes(ABC):
|
|||||||
settings_service=self._settings,
|
settings_service=self._settings,
|
||||||
server_i18n=self._server_i18n,
|
server_i18n=self._server_i18n,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
page_context_provider=self._get_page_context_provider(),
|
|
||||||
)
|
)
|
||||||
listing = ModelListingHandler(
|
listing = ModelListingHandler(
|
||||||
service=service,
|
service=service,
|
||||||
@@ -251,10 +250,6 @@ class BaseModelRoutes(ABC):
|
|||||||
"""Get expected model types string for error messages - to be overridden by subclasses."""
|
"""Get expected model types string for error messages - to be overridden by subclasses."""
|
||||||
return "any model type"
|
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):
|
def _find_model_file(self, files):
|
||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""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)
|
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._unknown_fields["hf_url"] = hf_url
|
||||||
metadata.from_civitai = False # HF models are not from CivitAI
|
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
|
# 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)
|
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||||
|
|
||||||
# 4. Determine relative folder path for cache
|
# 4. Determine relative folder path for cache
|
||||||
@@ -240,10 +244,7 @@ class HfHandler:
|
|||||||
})
|
})
|
||||||
|
|
||||||
existing["hf_url"] = hf_url
|
existing["hf_url"] = hf_url
|
||||||
# NOTE: deliberately do NOT touch `from_civitai` here. It records
|
existing["from_civitai"] = False
|
||||||
# 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`.
|
|
||||||
await MetadataManager.save_metadata(file_path, existing)
|
await MetadataManager.save_metadata(file_path, existing)
|
||||||
|
|
||||||
await _add_to_scanner_cache(file_path, existing)
|
await _add_to_scanner_cache(file_path, existing)
|
||||||
|
|||||||
@@ -53,11 +53,9 @@ from ...utils.constants import (
|
|||||||
PREVIEW_EXTENSIONS,
|
PREVIEW_EXTENSIONS,
|
||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
VALID_OTHER_CIVITAI_TYPES,
|
|
||||||
)
|
)
|
||||||
from .hf_handlers import HfHandler
|
from .hf_handlers import HfHandler
|
||||||
from .agent_handlers import AgentHandler
|
from .agent_handlers import AgentHandler
|
||||||
from .download_routing_handlers import DownloadRoutingHandler
|
|
||||||
from .model_handlers import ModelCivitaiHandler
|
from .model_handlers import ModelCivitaiHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import (
|
from ...utils.example_images_paths import (
|
||||||
@@ -659,21 +657,9 @@ class HealthCheckHandler:
|
|||||||
"lora": ServiceRegistry.get_lora_scanner,
|
"lora": ServiceRegistry.get_lora_scanner,
|
||||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||||
"other": ServiceRegistry.get_other_scanner,
|
|
||||||
"recipe": ServiceRegistry.get_recipe_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:
|
async def health_check(self, request: web.Request) -> web.Response:
|
||||||
return web.json_response({"status": "ok"})
|
return web.json_response({"status": "ok"})
|
||||||
|
|
||||||
@@ -685,7 +671,7 @@ class HealthCheckHandler:
|
|||||||
page accepts the update and only reloads once all scanners are done.
|
page accepts the update and only reloads once all scanners are done.
|
||||||
"""
|
"""
|
||||||
pending: list[str] = []
|
pending: list[str] = []
|
||||||
for name, getter in self._active_scanner_getters().items():
|
for name, getter in self._scanner_getters.items():
|
||||||
try:
|
try:
|
||||||
scanner = await getter()
|
scanner = await getter()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -770,19 +756,10 @@ class DoctorHandler:
|
|||||||
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
||||||
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
||||||
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
||||||
("other", "Other Models", ServiceRegistry.get_other_scanner),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._app_version_getter = app_version_getter
|
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:
|
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
client_version = (request.query.get("clientVersion") or "").strip()
|
client_version = (request.query.get("clientVersion") or "").strip()
|
||||||
@@ -830,7 +807,7 @@ class DoctorHandler:
|
|||||||
repaired: list[dict[str, Any]] = []
|
repaired: list[dict[str, Any]] = []
|
||||||
failures: list[dict[str, str]] = []
|
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:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
||||||
@@ -862,7 +839,7 @@ class DoctorHandler:
|
|||||||
renamed: list[dict[str, Any]] = []
|
renamed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for model_type, label, factory in self._active_scanner_factories():
|
for model_type, label, factory in self._scanner_factories:
|
||||||
try:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
hash_index = getattr(scanner, "_hash_index", None)
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
@@ -1094,7 +1071,7 @@ class DoctorHandler:
|
|||||||
overall_status = "ok"
|
overall_status = "ok"
|
||||||
summary = "All model caches look healthy."
|
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:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
persisted = None
|
persisted = None
|
||||||
@@ -1179,7 +1156,7 @@ class DoctorHandler:
|
|||||||
total_conflict_groups = 0
|
total_conflict_groups = 0
|
||||||
total_conflict_files = 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
|
# Duplicate filename detection targets LoRAs which use basename-only
|
||||||
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||||
# models via relative paths with extensions, so conflicts there would
|
# 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)
|
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||||
raw_llm_key = self._settings.get("llm_api_key")
|
raw_llm_key = self._settings.get("llm_api_key")
|
||||||
response_data["llm_api_key_set"] = bool(raw_llm_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)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
@@ -2104,7 +2065,6 @@ class ServiceRegistryAdapter:
|
|||||||
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
||||||
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
||||||
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
||||||
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
|
|
||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
@@ -2129,8 +2089,6 @@ class ModelLibraryHandler:
|
|||||||
return "checkpoint"
|
return "checkpoint"
|
||||||
if normalized in {"embedding", "textualinversion"}:
|
if normalized in {"embedding", "textualinversion"}:
|
||||||
return "embedding"
|
return "embedding"
|
||||||
if normalized in VALID_OTHER_CIVITAI_TYPES:
|
|
||||||
return "other"
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_scanner_for_type(self, model_type: str | 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()
|
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
||||||
if normalized_type == "embedding":
|
if normalized_type == "embedding":
|
||||||
return normalized_type, await self._service_registry.get_embedding_scanner()
|
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
|
return None, None
|
||||||
|
|
||||||
async def _get_download_history_service(self):
|
async def _get_download_history_service(self):
|
||||||
@@ -2239,11 +2190,6 @@ class ModelLibraryHandler:
|
|||||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await self._service_registry.get_embedding_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:
|
if model_version_id_str:
|
||||||
try:
|
try:
|
||||||
@@ -2282,13 +2228,6 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
matched_scanner = embedding_scanner
|
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:
|
if exists:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2306,7 +2245,7 @@ class ModelLibraryHandler:
|
|||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
has_been_downloaded = False
|
has_been_downloaded = False
|
||||||
history_type = None
|
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(
|
if await history_service.has_been_downloaded(
|
||||||
candidate_type,
|
candidate_type,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
@@ -2328,7 +2267,6 @@ class ModelLibraryHandler:
|
|||||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
checkpoint_versions = []
|
checkpoint_versions = []
|
||||||
embedding_versions = []
|
embedding_versions = []
|
||||||
other_versions = []
|
|
||||||
if not lora_versions and checkpoint_scanner:
|
if not lora_versions and checkpoint_scanner:
|
||||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
||||||
model_id
|
model_id
|
||||||
@@ -2337,13 +2275,6 @@ class ModelLibraryHandler:
|
|||||||
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
||||||
model_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
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
@@ -2375,18 +2306,9 @@ class ModelLibraryHandler:
|
|||||||
"downloadedVersionIds": [],
|
"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()
|
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 = (
|
candidate_downloaded_version_ids = (
|
||||||
await history_service.get_downloaded_version_ids(
|
await history_service.get_downloaded_version_ids(
|
||||||
candidate_type,
|
candidate_type,
|
||||||
@@ -2441,11 +2363,6 @@ class ModelLibraryHandler:
|
|||||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await self._service_registry.get_embedding_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]] = []
|
results: list[dict[str, Any]] = []
|
||||||
for model_id in model_ids:
|
for model_id in model_ids:
|
||||||
@@ -2481,17 +2398,6 @@ class ModelLibraryHandler:
|
|||||||
})
|
})
|
||||||
continue
|
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({
|
results.append({
|
||||||
"modelId": model_id,
|
"modelId": model_id,
|
||||||
"modelType": None,
|
"modelType": None,
|
||||||
@@ -2880,32 +2786,12 @@ class ModelLibraryHandler:
|
|||||||
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
||||||
}
|
}
|
||||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_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] = {
|
type_scanner_map: Dict[str, Any] = {
|
||||||
**{alias: lora_scanner for alias in lora_type_aliases},
|
**{alias: lora_scanner for alias in lora_type_aliases},
|
||||||
"checkpoint": checkpoint_scanner,
|
"checkpoint": checkpoint_scanner,
|
||||||
"textualinversion": embedding_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]] = []
|
versions: list[dict[str, Any]] = []
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
@@ -2929,17 +2815,12 @@ class ModelLibraryHandler:
|
|||||||
"embedding",
|
"embedding",
|
||||||
model_ids,
|
model_ids,
|
||||||
)
|
)
|
||||||
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
|
||||||
"other",
|
|
||||||
model_ids,
|
|
||||||
)
|
|
||||||
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||||
"lora": lora_downloaded,
|
"lora": lora_downloaded,
|
||||||
"locon": lora_downloaded,
|
"locon": lora_downloaded,
|
||||||
"dora": lora_downloaded,
|
"dora": lora_downloaded,
|
||||||
"checkpoint": checkpoint_downloaded,
|
"checkpoint": checkpoint_downloaded,
|
||||||
"textualinversion": embedding_downloaded,
|
"textualinversion": embedding_downloaded,
|
||||||
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
|
|
||||||
}
|
}
|
||||||
for model in models:
|
for model in models:
|
||||||
if not isinstance(model, dict):
|
if not isinstance(model, dict):
|
||||||
@@ -4003,7 +3884,6 @@ class MiscHandlerSet:
|
|||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
hf_handler: Any = None,
|
hf_handler: Any = None,
|
||||||
agent_handler: Any = None,
|
agent_handler: Any = None,
|
||||||
download_routing: Any = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self.health = health
|
self.health = health
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -4024,7 +3904,6 @@ class MiscHandlerSet:
|
|||||||
self.base_model = base_model
|
self.base_model = base_model
|
||||||
self.hf_handler = hf_handler
|
self.hf_handler = hf_handler
|
||||||
self.agent_handler = agent_handler
|
self.agent_handler = agent_handler
|
||||||
self.download_routing = download_routing
|
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
self,
|
self,
|
||||||
@@ -4083,8 +3962,6 @@ class MiscHandlerSet:
|
|||||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||||
"cancel_agent_skill": self.agent_handler.cancel_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
|
# Base model handlers
|
||||||
"get_base_models": self.base_model.get_base_models,
|
"get_base_models": self.base_model.get_base_models,
|
||||||
"refresh_base_models": self.base_model.refresh_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_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||||
get_embedding_scanner=ServiceRegistry.get_embedding_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_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||||
get_backup_service=ServiceRegistry.get_backup_service,
|
get_backup_service=ServiceRegistry.get_backup_service,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ class ModelPageView:
|
|||||||
settings_service: SettingsManager,
|
settings_service: SettingsManager,
|
||||||
server_i18n,
|
server_i18n,
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
self._template_env = template_env
|
self._template_env = template_env
|
||||||
self._template_name = template_name
|
self._template_name = template_name
|
||||||
@@ -98,7 +97,6 @@ class ModelPageView:
|
|||||||
self._settings = settings_service
|
self._settings = settings_service
|
||||||
self._server_i18n = server_i18n
|
self._server_i18n = server_i18n
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
self._page_context_provider = page_context_provider
|
|
||||||
|
|
||||||
def _load_supporters(self) -> dict[str, Any]:
|
def _load_supporters(self) -> dict[str, Any]:
|
||||||
"""Load supporters data from JSON file."""
|
"""Load supporters data from JSON file."""
|
||||||
@@ -212,16 +210,6 @@ class ModelPageView:
|
|||||||
self._logger.error("Error loading cache data: %s", cache_error)
|
self._logger.error("Error loading cache data: %s", cache_error)
|
||||||
template_context["is_initializing"] = True
|
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(
|
rendered = self._template_env.get_template(self._template_name).render(
|
||||||
**template_context
|
**template_context
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
|||||||
"loras": "get_lora_scanner",
|
"loras": "get_lora_scanner",
|
||||||
"checkpoints": "get_checkpoint_scanner",
|
"checkpoints": "get_checkpoint_scanner",
|
||||||
"embeddings": "get_embedding_scanner",
|
"embeddings": "get_embedding_scanner",
|
||||||
"other": "get_other_scanner",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
# 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)
|
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)
|
@dataclass(frozen=True)
|
||||||
class RecipeHandlerSet:
|
class RecipeHandlerSet:
|
||||||
"""Group of handlers providing recipe route implementations."""
|
"""Group of handlers providing recipe route implementations."""
|
||||||
@@ -149,6 +129,11 @@ class RecipeHandlerSet:
|
|||||||
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
|
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
"move_recipe": self.management.move_recipe,
|
"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,
|
"rematch_recipes": self.management.rematch_recipes,
|
||||||
"cancel_rematch": self.management.cancel_rematch,
|
"cancel_rematch": self.management.cancel_rematch,
|
||||||
"rematch_recipe": self.management.rematch_recipe,
|
"rematch_recipe": self.management.rematch_recipe,
|
||||||
@@ -811,6 +796,157 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
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:
|
async def rematch_recipes(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -822,9 +958,12 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mutual exclusion: a global rematch cannot start while a rematch
|
# Mutual exclusion: a global rematch cannot start while a rematch
|
||||||
# is already running — both mutate recipes under the same
|
# OR a repair is already running — both mutate recipes under the
|
||||||
# mutation lock.
|
# same mutation lock.
|
||||||
if self._ws_manager.is_recipe_rematch_running():
|
if (
|
||||||
|
self._ws_manager.is_recipe_rematch_running()
|
||||||
|
or self._ws_manager.is_recipe_repair_running()
|
||||||
|
):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"success": False, "error": "Recipe rematch already in progress"},
|
{"success": False, "error": "Recipe rematch already in progress"},
|
||||||
status=409,
|
status=409,
|
||||||
@@ -832,8 +971,6 @@ class RecipeManagementHandler:
|
|||||||
|
|
||||||
recipe_scanner.reset_cancellation()
|
recipe_scanner.reset_cancellation()
|
||||||
|
|
||||||
relaxed = await _parse_relaxed_flag(request)
|
|
||||||
|
|
||||||
async def progress_callback(data):
|
async def progress_callback(data):
|
||||||
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
||||||
|
|
||||||
@@ -841,8 +978,7 @@ class RecipeManagementHandler:
|
|||||||
async def run_rematch():
|
async def run_rematch():
|
||||||
try:
|
try:
|
||||||
await recipe_scanner.rematch_all_recipes(
|
await recipe_scanner.rematch_all_recipes(
|
||||||
progress_callback=progress_callback,
|
progress_callback=progress_callback
|
||||||
relaxed=relaxed,
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -915,13 +1051,7 @@ class RecipeManagementHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
relaxed = bool(data.get("relaxed")) or (
|
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
||||||
request.query.get("relaxed", "").lower() == "true"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await recipe_scanner.rematch_recipes_bulk(
|
|
||||||
recipe_ids, relaxed=relaxed
|
|
||||||
)
|
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -950,10 +1080,7 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
recipe_id = request.match_info["recipe_id"]
|
recipe_id = request.match_info["recipe_id"]
|
||||||
relaxed = await _parse_relaxed_flag(request)
|
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
||||||
result = await recipe_scanner.rematch_recipe_by_id(
|
|
||||||
recipe_id, relaxed=relaxed
|
|
||||||
)
|
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except RecipeNotFoundError as exc:
|
except RecipeNotFoundError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
@@ -1052,55 +1179,12 @@ class RecipeManagementHandler:
|
|||||||
persisted_source_path=persisted_source_path,
|
persisted_source_path=persisted_source_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional caller-supplied metadata payload (companion browser
|
async with self._import_semaphore:
|
||||||
# extension re-import). Only honored for CivitAI image page
|
import_response = await self._do_import_from_url(
|
||||||
# sources; everything else uses the native URL import below.
|
source_path,
|
||||||
params = request.rel_url.query
|
recipe_scanner,
|
||||||
payload_image_url = params.get("image_url")
|
target_dir=old_folder,
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self._persistence_service.delete_recipe(
|
await self._persistence_service.delete_recipe(
|
||||||
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
recipe_scanner=recipe_scanner, recipe_id=recipe_id
|
||||||
@@ -1127,19 +1211,14 @@ class RecipeManagementHandler:
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
response_body: Dict[str, Any] = {
|
return web.json_response(
|
||||||
"success": True,
|
{
|
||||||
"old_recipe_id": recipe_id,
|
"success": True,
|
||||||
"recipe_id": new_recipe_id,
|
"old_recipe_id": recipe_id,
|
||||||
"source_path": source_path,
|
"recipe_id": new_recipe_id,
|
||||||
}
|
"source_path": source_path,
|
||||||
loras_count = await self._count_recipe_loras(
|
}
|
||||||
recipe_scanner, new_recipe_id
|
|
||||||
)
|
)
|
||||||
if loras_count is not None:
|
|
||||||
response_body["loras_count"] = loras_count
|
|
||||||
|
|
||||||
return web.json_response(response_body)
|
|
||||||
except RecipeNotFoundError as exc:
|
except RecipeNotFoundError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
except RecipeValidationError as exc:
|
except RecipeValidationError as exc:
|
||||||
@@ -1152,6 +1231,18 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
async def import_remote_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -1172,14 +1263,31 @@ class RecipeManagementHandler:
|
|||||||
if not resources_raw:
|
if not resources_raw:
|
||||||
raise RecipeValidationError("Missing required field: resources")
|
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
|
# Throttle concurrent imports to avoid starving ComfyUI's event loop
|
||||||
async with self._import_semaphore:
|
async with self._import_semaphore:
|
||||||
return await self._import_remote_recipe_impl(
|
return await self._do_import_remote_recipe(
|
||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
name=name,
|
name=name,
|
||||||
resources_raw=resources_raw,
|
lora_entries=lora_entries,
|
||||||
gen_params_raw=params.get("gen_params"),
|
checkpoint_entry=checkpoint_entry,
|
||||||
tags_raw=params.get("tags"),
|
gen_params_request=gen_params_request,
|
||||||
|
tags=self._parse_tags(params.get("tags")),
|
||||||
base_model=params.get("base_model", "") or "",
|
base_model=params.get("base_model", "") or "",
|
||||||
source_path=params.get("source_path") or image_url,
|
source_path=params.get("source_path") or image_url,
|
||||||
)
|
)
|
||||||
@@ -1193,52 +1301,6 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
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(
|
async def _do_import_remote_recipe(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -1250,7 +1312,6 @@ class RecipeManagementHandler:
|
|||||||
tags: list[Any],
|
tags: list[Any],
|
||||||
base_model: str,
|
base_model: str,
|
||||||
source_path: str,
|
source_path: str,
|
||||||
target_dir: str | None = None,
|
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
recipe_scanner = self._recipe_scanner_getter()
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
if recipe_scanner is None:
|
if recipe_scanner is None:
|
||||||
@@ -1414,7 +1475,6 @@ class RecipeManagementHandler:
|
|||||||
tags=tags,
|
tags=tags,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
extension=extension,
|
extension=extension,
|
||||||
target_dir=target_dir,
|
|
||||||
)
|
)
|
||||||
return web.json_response(result.payload, status=result.status)
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
|
||||||
@@ -1879,25 +1939,6 @@ class RecipeManagementHandler:
|
|||||||
return []
|
return []
|
||||||
return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
|
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]]:
|
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
if payload is None:
|
if payload is None:
|
||||||
return None
|
return None
|
||||||
@@ -3124,12 +3165,6 @@ class RecipeWorkflowHandler:
|
|||||||
class BatchImportHandler:
|
class BatchImportHandler:
|
||||||
"""Handle batch import operations for recipes."""
|
"""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__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -3303,27 +3338,31 @@ class BatchImportHandler:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
directory_path = data.get("path", "")
|
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:
|
if not directory_path:
|
||||||
path = Path.home()
|
return web.json_response(
|
||||||
else:
|
{"success": False, "error": "Directory path is required"},
|
||||||
path = Path(directory_path).expanduser().resolve()
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
# Access check: browsing intentionally covers the whole server
|
# Normalize the path
|
||||||
# filesystem (the server operator browses their own machine). On
|
path = Path(directory_path).expanduser().resolve()
|
||||||
# POSIX every absolute path is under "/", but Path("/") has no
|
|
||||||
# drive letter on Windows and can never anchor a drive-qualified
|
# Security check: ensure path is within allowed directories
|
||||||
# path in relative_to(), so test for a drive there instead.
|
# Allow common image/model directories
|
||||||
if os.name == "nt":
|
allowed_roots = [
|
||||||
is_allowed = bool(path.drive)
|
Path.home(),
|
||||||
else:
|
Path("/"), # Allow browsing from root for flexibility
|
||||||
is_allowed = path.is_absolute()
|
]
|
||||||
|
|
||||||
|
# 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:
|
if not is_allowed:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3390,24 +3429,15 @@ class BatchImportHandler:
|
|||||||
directories.sort(key=lambda x: x["name"].lower())
|
directories.sort(key=lambda x: x["name"].lower())
|
||||||
image_files.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
|
# Add parent directory if not at root
|
||||||
# (parent == path): POSIX "/" gets no parent, while a Windows
|
parent_path = path.parent
|
||||||
# drive root (C:\) links up to the virtual drive list so users
|
show_parent = str(path) != str(path.root)
|
||||||
# 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)
|
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"current_path": str(path),
|
"current_path": str(path),
|
||||||
"parent_path": parent_path,
|
"parent_path": str(parent_path) if show_parent else None,
|
||||||
"directories": directories,
|
"directories": directories,
|
||||||
"image_files": image_files,
|
"image_files": image_files,
|
||||||
"image_count": len(image_files),
|
"image_count": len(image_files),
|
||||||
@@ -3434,30 +3464,3 @@ class BatchImportHandler:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
"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(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
"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.base_model_handlers import BaseModelHandlerSet
|
||||||
from .handlers.hf_handlers import HfHandler
|
from .handlers.hf_handlers import HfHandler
|
||||||
from .handlers.agent_handlers import AgentHandler
|
from .handlers.agent_handlers import AgentHandler
|
||||||
from .handlers.download_routing_handlers import DownloadRoutingHandler
|
|
||||||
from .misc_route_registrar import MiscRouteRegistrar
|
from .misc_route_registrar import MiscRouteRegistrar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -141,7 +140,6 @@ class MiscRoutes:
|
|||||||
base_model = BaseModelHandlerSet()
|
base_model = BaseModelHandlerSet()
|
||||||
hf_handler = HfHandler()
|
hf_handler = HfHandler()
|
||||||
agent_handler = AgentHandler()
|
agent_handler = AgentHandler()
|
||||||
download_routing = DownloadRoutingHandler()
|
|
||||||
|
|
||||||
return self._handler_set_factory(
|
return self._handler_set_factory(
|
||||||
health=health,
|
health=health,
|
||||||
@@ -163,7 +161,6 @@ class MiscRoutes:
|
|||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
hf_handler=hf_handler,
|
hf_handler=hf_handler,
|
||||||
agent_handler=agent_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"
|
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
|
||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/scan", "scan_recipes"),
|
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", "rematch_recipes"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
|
RouteDefinition("POST", "/api/lm/recipes/rematch-bulk", "rematch_recipes_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
|
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
|
||||||
@@ -110,11 +115,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
|
"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(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
|
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -407,6 +407,7 @@ class AgentService:
|
|||||||
"base_model": metadata.get("base_model", ""),
|
"base_model": metadata.get("base_model", ""),
|
||||||
"tags": metadata.get("tags", []),
|
"tags": metadata.get("tags", []),
|
||||||
"modelDescription": metadata.get("modelDescription", ""),
|
"modelDescription": metadata.get("modelDescription", ""),
|
||||||
|
"trainedWords": metadata.get("trainedWords", []),
|
||||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||||
"size": metadata.get("size", 0),
|
"size": metadata.get("size", 0),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,10 +92,7 @@ class PostProcessor:
|
|||||||
preview_downloaded = False
|
preview_downloaded = False
|
||||||
|
|
||||||
# -- Determine whether this is an HF-sourced model -----------------
|
# -- Determine whether this is an HF-sourced model -----------------
|
||||||
# Key off `hf_url` directly: `from_civitai` records provenance and can
|
is_hf_model = not metadata.get("from_civitai", True)
|
||||||
# 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", ""))
|
|
||||||
|
|
||||||
# -- Collect updates -----------------------------------------------
|
# -- Collect updates -----------------------------------------------
|
||||||
updates: Dict[str, Any] = {}
|
updates: Dict[str, Any] = {}
|
||||||
|
|||||||
@@ -161,11 +161,6 @@ class Aria2Downloader:
|
|||||||
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
||||||
and the partial download continues. Recovery is bounded by
|
and the partial download continues. Recovery is bounded by
|
||||||
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
``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()
|
await self._ensure_process()
|
||||||
@@ -256,11 +251,7 @@ class Aria2Downloader:
|
|||||||
await asyncio.sleep(self._poll_interval)
|
await asyncio.sleep(self._poll_interval)
|
||||||
finally:
|
finally:
|
||||||
current = self._transfers.get(download_id)
|
current = self._transfers.get(download_id)
|
||||||
if (
|
if current is not None and current.gid == transfer.gid:
|
||||||
transfer is not None
|
|
||||||
and current is not None
|
|
||||||
and current.gid == transfer.gid
|
|
||||||
):
|
|
||||||
self._transfers.pop(download_id, None)
|
self._transfers.pop(download_id, None)
|
||||||
|
|
||||||
async def _get_status_with_retry(
|
async def _get_status_with_retry(
|
||||||
@@ -348,43 +339,21 @@ class Aria2Downloader:
|
|||||||
resolved_url != url,
|
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:
|
try:
|
||||||
gid = await asyncio.shield(add_task)
|
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||||
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
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
||||||
|
|
||||||
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
|
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
|
return gid
|
||||||
|
|
||||||
async def _register_transfer(
|
async def _register_transfer(
|
||||||
@@ -403,46 +372,7 @@ class Aria2Downloader:
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
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
|
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
|
return transfer
|
||||||
|
|
||||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
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.models import BaseModelMetadata
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
@@ -904,11 +904,6 @@ class BaseModelService(ABC):
|
|||||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||||
):
|
):
|
||||||
continue
|
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
|
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||||
|
|
||||||
|
|||||||
@@ -410,10 +410,6 @@ class CheckpointScanner(ModelScanner):
|
|||||||
|
|
||||||
return None
|
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):
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
"""Adjust metadata during scanning to set sub_type."""
|
"""Adjust metadata during scanning to set sub_type."""
|
||||||
sub_type = self._resolve_sub_type(root_path)
|
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]:
|
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."""
|
"""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:
|
if sub_type:
|
||||||
entry["sub_type"] = sub_type
|
entry["sub_type"] = sub_type
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
@@ -505,50 +505,6 @@ class CivitaiClient:
|
|||||||
logger.warning(f"Failed to fetch version by id {version_id}")
|
logger.warning(f"Failed to fetch version by id {version_id}")
|
||||||
return None
|
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]]:
|
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||||
if not model_hash:
|
if not model_hash:
|
||||||
return None
|
return None
|
||||||
|
|||||||
+26
-177
@@ -17,18 +17,13 @@ from dataclasses import dataclass, field
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from ..utils.models import (
|
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||||
LoraMetadata,
|
|
||||||
CheckpointMetadata,
|
|
||||||
EmbeddingMetadata,
|
|
||||||
OtherModelMetadata,
|
|
||||||
)
|
|
||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
|
DIFFUSION_MODEL_BASE_MODELS,
|
||||||
MODEL_WEIGHT_FILE_TYPES,
|
MODEL_WEIGHT_FILE_TYPES,
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
VALID_OTHER_CIVITAI_TYPES,
|
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
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.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .service_registry import ServiceRegistry
|
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 .settings_manager import get_settings_manager
|
||||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||||
from .errors import RateLimitError
|
|
||||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
from .download_queue_service import DownloadQueueService
|
from .download_queue_service import DownloadQueueService
|
||||||
@@ -234,21 +227,12 @@ class DownloadManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _get_scanner_for_model_type(self, model_type: str):
|
async def _get_scanner_for_model_type(self, model_type: str):
|
||||||
"""Return the scanner responsible for the given model type.
|
"""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).
|
|
||||||
"""
|
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
return await self._get_checkpoint_scanner()
|
return await self._get_checkpoint_scanner()
|
||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
return await ServiceRegistry.get_embedding_scanner()
|
return await ServiceRegistry.get_embedding_scanner()
|
||||||
if model_type == "other":
|
return await self._get_lora_scanner()
|
||||||
return await ServiceRegistry.get_other_scanner()
|
|
||||||
if model_type == "lora":
|
|
||||||
return await self._get_lora_scanner()
|
|
||||||
raise ValueError(f'Unknown model type "{model_type}"')
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_target_file(
|
def _resolve_target_file(
|
||||||
@@ -945,42 +929,6 @@ class DownloadManager:
|
|||||||
|
|
||||||
return download_urls
|
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(
|
def _build_metadata_for_resume(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -993,8 +941,6 @@ class DownloadManager:
|
|||||||
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
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)
|
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]:
|
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()
|
lora_scanner = await self._get_lora_scanner()
|
||||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
other_scanner = await ServiceRegistry.get_other_scanner()
|
|
||||||
|
|
||||||
# Check lora scanner first
|
# Check lora scanner first
|
||||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
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",
|
"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'
|
# Use CivArchive provider directly when source is 'civarchive'
|
||||||
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
||||||
if source == "civarchive":
|
if source == "civarchive":
|
||||||
@@ -1525,20 +1463,6 @@ class DownloadManager:
|
|||||||
model_type = "lora"
|
model_type = "lora"
|
||||||
elif model_type_from_info == "textualinversion":
|
elif model_type_from_info == "textualinversion":
|
||||||
model_type = "embedding"
|
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:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -1660,13 +1584,27 @@ class DownloadManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check if this checkpoint should be treated as a diffusion model
|
# Check if this checkpoint should be treated as a diffusion model
|
||||||
# (shared with the download routing endpoint so the UI location
|
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
||||||
# step and the actual download agree on the target roots).
|
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
||||||
is_diffusion_model = is_diffusion_model_download(
|
is_diffusion_model = False
|
||||||
model_type,
|
if model_type == "checkpoint":
|
||||||
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
# Check file types first (more direct signal from CivitAI)
|
||||||
base_model=base_model_value,
|
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):
|
# Existence check after the metadata fetch (#1058):
|
||||||
# - An explicit file selection only blocks when THIS file is
|
# - An explicit file selection only blocks when THIS file is
|
||||||
@@ -1725,13 +1663,6 @@ class DownloadManager:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": "Model version already exists in embedding library",
|
"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
|
# Handle use_default_paths
|
||||||
if use_default_paths:
|
if use_default_paths:
|
||||||
@@ -1771,60 +1702,6 @@ class DownloadManager:
|
|||||||
"error": "Default embedding root path not set in settings",
|
"error": "Default embedding root path not set in settings",
|
||||||
}
|
}
|
||||||
save_dir = default_path
|
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
|
# Calculate relative path using template
|
||||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||||
@@ -1981,24 +1858,6 @@ class DownloadManager:
|
|||||||
if not download_urls:
|
if not download_urls:
|
||||||
return {"success": False, "error": "No mirror URL found"}
|
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
|
# 3. Prepare download
|
||||||
file_name = file_info.get("name", "")
|
file_name = file_info.get("name", "")
|
||||||
if not file_name:
|
if not file_name:
|
||||||
@@ -2021,11 +1880,6 @@ class DownloadManager:
|
|||||||
version_info, file_info, save_path
|
version_info, file_info, save_path
|
||||||
)
|
)
|
||||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
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:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -2238,8 +2092,6 @@ class DownloadManager:
|
|||||||
scanner = await self._get_checkpoint_scanner()
|
scanner = await self._get_checkpoint_scanner()
|
||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
elif model_type == "other":
|
|
||||||
scanner = await ServiceRegistry.get_other_scanner()
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||||
|
|
||||||
@@ -2736,9 +2588,6 @@ class DownloadManager:
|
|||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
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 = (
|
adjust_cached_entry = (
|
||||||
getattr(scanner, "adjust_cached_entry", None)
|
getattr(scanner, "adjust_cached_entry", None)
|
||||||
@@ -2828,7 +2677,7 @@ class DownloadManager:
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||||
if model_type in ("checkpoint", "other"):
|
if model_type == "checkpoint":
|
||||||
return {
|
return {
|
||||||
".ckpt",
|
".ckpt",
|
||||||
".pt",
|
".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 asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -33,26 +32,8 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
|
|||||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
_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)
|
_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]]:
|
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||||
"""Fetch and parse the model catalog.
|
"""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
|
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
||||||
in memory after the first successful fetch.
|
in memory after the first successful fetch.
|
||||||
Subsequent calls return the cached data immediately.
|
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:
|
if _catalog_cache is not None:
|
||||||
return _catalog_cache
|
return _catalog_cache
|
||||||
|
|
||||||
async with _catalog_lock:
|
try:
|
||||||
# Re-check under the lock: another caller may have fetched (or
|
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||||
# failed) while we were waiting.
|
async with session.get(_MODEL_CATALOG_URL) as resp:
|
||||||
if _catalog_cache is not None:
|
if resp.status != 200:
|
||||||
return _catalog_cache
|
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||||
if (
|
return _catalog_cache or {}
|
||||||
_catalog_last_failure is not None
|
data = await resp.json()
|
||||||
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
||||||
):
|
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||||
logger.debug(
|
return _catalog_cache or {}
|
||||||
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
|
||||||
time.monotonic() - _catalog_last_failure,
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
if not isinstance(data, dict):
|
||||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||||
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
|
return _catalog_cache or {}
|
||||||
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):
|
result: Dict[str, List[str]] = {}
|
||||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
output_limits: Dict[str, Dict[str, int]] = {}
|
||||||
_catalog_last_failure = time.monotonic()
|
for provider_id, provider_info in data.items():
|
||||||
return {}
|
if not isinstance(provider_info, dict):
|
||||||
|
continue
|
||||||
result: Dict[str, List[str]] = {}
|
models_dict = provider_info.get("models")
|
||||||
output_limits: Dict[str, Dict[str, int]] = {}
|
if not isinstance(models_dict, dict):
|
||||||
for provider_id, provider_info in data.items():
|
continue
|
||||||
if not isinstance(provider_info, dict):
|
model_ids: List[str] = []
|
||||||
|
provider_limits: Dict[str, int] = {}
|
||||||
|
for mid, model_info in models_dict.items():
|
||||||
|
if not isinstance(mid, str):
|
||||||
continue
|
continue
|
||||||
models_dict = provider_info.get("models")
|
model_ids.append(mid)
|
||||||
if not isinstance(models_dict, dict):
|
if isinstance(model_info, dict):
|
||||||
continue
|
limit = model_info.get("limit")
|
||||||
model_ids: List[str] = []
|
if isinstance(limit, dict):
|
||||||
provider_limits: Dict[str, int] = {}
|
output = limit.get("output")
|
||||||
for mid, model_info in models_dict.items():
|
if isinstance(output, (int, float)) and output > 0:
|
||||||
if not isinstance(mid, str):
|
provider_limits[mid] = int(output)
|
||||||
continue
|
if model_ids:
|
||||||
model_ids.append(mid)
|
result[provider_id] = model_ids
|
||||||
if isinstance(model_info, dict):
|
if provider_limits:
|
||||||
limit = model_info.get("limit")
|
output_limits[provider_id] = provider_limits
|
||||||
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
|
_catalog_cache = result
|
||||||
_model_output_limits = output_limits
|
_model_output_limits = output_limits
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Loaded model catalog: %d providers, %d total models "
|
"Loaded model catalog: %d providers, %d total models "
|
||||||
"(%d providers have output limits)",
|
"(%d providers have output limits)",
|
||||||
len(result),
|
len(result),
|
||||||
sum(len(m) for m in result.values()),
|
sum(len(m) for m in result.values()),
|
||||||
len(output_limits),
|
len(output_limits),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
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"
|
url = f"{api_base.rstrip('/')}/models"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
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:
|
if resp.status != 200:
|
||||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||||
return []
|
return []
|
||||||
data = await resp.json()
|
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)
|
logger.debug("Ollama not reachable at %s: %s", api_base, exc)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
@@ -33,11 +33,6 @@ class ModelCache:
|
|||||||
|
|
||||||
raw_data: List[Dict[str, Any]]
|
raw_data: List[Dict[str, Any]]
|
||||||
folders: List[str]
|
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)
|
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||||
model_id_index: Dict[int, List[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
|
# 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."""
|
"""Published model count for the user; None when unsupported."""
|
||||||
return None
|
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):
|
class CivitaiModelMetadataProvider(ModelMetadataProvider):
|
||||||
"""Provider that uses Civitai API for metadata"""
|
"""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]:
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
return await self.client.get_creator_model_count(username)
|
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):
|
class CivArchiveModelMetadataProvider(ModelMetadataProvider):
|
||||||
"""Provider that uses CivArchive API for metadata"""
|
"""Provider that uses CivArchive API for metadata"""
|
||||||
|
|
||||||
@@ -716,37 +700,6 @@ class FallbackMetadataProvider(ModelMetadataProvider):
|
|||||||
continue
|
continue
|
||||||
return None
|
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):
|
def _iter_providers(self):
|
||||||
return zip(self.providers, self._provider_labels)
|
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]:
|
async def get_creator_model_count(self, username: str) -> Optional[int]:
|
||||||
return await self._provider.get_creator_model_count(username)
|
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:
|
class ModelMetadataProviderManager:
|
||||||
"""Manager for selecting and using model metadata providers"""
|
"""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("/"))
|
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
|
# Maps a scanner model type to the manager page type used in progress
|
||||||
# broadcasts (e.g. 'lora' -> 'loras').
|
# broadcasts (e.g. 'lora' -> 'loras').
|
||||||
PAGE_TYPE_MAP = {
|
PAGE_TYPE_MAP = {
|
||||||
'lora': 'loras',
|
'lora': 'loras',
|
||||||
'checkpoint': 'checkpoints',
|
'checkpoint': 'checkpoints',
|
||||||
'embedding': 'embeddings',
|
'embedding': 'embeddings',
|
||||||
'other': 'other',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -86,10 +89,6 @@ class CacheBuildResult:
|
|||||||
hash_index: ModelHashIndex
|
hash_index: ModelHashIndex
|
||||||
tags_count: Dict[str, int]
|
tags_count: Dict[str, int]
|
||||||
excluded_models: List[str]
|
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:
|
class ModelScanner:
|
||||||
"""Base service for scanning and managing model files"""
|
"""Base service for scanning and managing model files"""
|
||||||
@@ -145,9 +144,8 @@ class ModelScanner:
|
|||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
self._cancel_requested = False # Flag for cancellation
|
self._cancel_requested = False # Flag for cancellation
|
||||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||||
# Guard against concurrent all-folders backfill walks (cold fallback
|
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
||||||
# for persisted snapshots that predate folder recording).
|
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
||||||
self._all_folders_backfill_running = False
|
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -210,14 +208,8 @@ class ModelScanner:
|
|||||||
"""
|
"""
|
||||||
self._cache_version += 1
|
self._cache_version += 1
|
||||||
|
|
||||||
def on_library_changed(self, reconcile: bool = False) -> None:
|
def on_library_changed(self) -> None:
|
||||||
"""Reset caches when the active library changes.
|
"""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.
|
|
||||||
"""
|
|
||||||
self._persistent_cache = get_persistent_cache()
|
self._persistent_cache = get_persistent_cache()
|
||||||
self._cache = None
|
self._cache = None
|
||||||
self._hash_index = ModelHashIndex()
|
self._hash_index = ModelHashIndex()
|
||||||
@@ -225,6 +217,7 @@ class ModelScanner:
|
|||||||
self._excluded_models = []
|
self._excluded_models = []
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
|
self.invalidate_all_folders_cache()
|
||||||
self.bump_cache_version()
|
self.bump_cache_version()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -235,7 +228,7 @@ class ModelScanner:
|
|||||||
if loop and not loop.is_closed():
|
if loop and not loop.is_closed():
|
||||||
self._loop = loop
|
self._loop = loop
|
||||||
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:
|
def _resolve_name_display_mode(self) -> str:
|
||||||
"""Return the configured display mode for name sorting."""
|
"""Return the configured display mode for name sorting."""
|
||||||
@@ -466,14 +459,8 @@ class ModelScanner:
|
|||||||
_, license_flags = resolve_license_info(license_source)
|
_, license_flags = resolve_license_info(license_source)
|
||||||
entry['license_flags'] = license_flags
|
entry['license_flags'] = license_flags
|
||||||
|
|
||||||
async def initialize_in_background(self, reconcile: bool = False) -> None:
|
async def initialize_in_background(self) -> None:
|
||||||
"""Initialize cache in background using thread pool
|
"""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.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Set initial empty cache to avoid None reference errors
|
# Set initial empty cache to avoid None reference errors
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
@@ -513,11 +500,6 @@ class ModelScanner:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
|
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
|
return
|
||||||
|
|
||||||
# Persistent load failed; fall back to a full scan
|
# Persistent load failed; fall back to a full scan
|
||||||
@@ -680,33 +662,21 @@ class ModelScanner:
|
|||||||
if not persisted or not persisted.raw_data:
|
if not persisted or not persisted.raw_data:
|
||||||
return None
|
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()
|
hash_index = ModelHashIndex()
|
||||||
for sha_value, path in persisted.hash_rows:
|
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)
|
hash_index.add_entry(sha_value.lower(), path)
|
||||||
|
|
||||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||||
# cover every known autov3 -> path mapping regardless of whether a
|
# cover every known autov3 -> path mapping regardless of whether a
|
||||||
# sha256 row also exists for the same file.
|
# sha256 row also exists for the same file.
|
||||||
for autov3_value, path in persisted.autov3_hash_rows:
|
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)
|
hash_index.add_autov3(autov3_value.lower(), path)
|
||||||
|
|
||||||
tags_count: Dict[str, int] = {}
|
tags_count: Dict[str, int] = {}
|
||||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
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
|
# 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
|
# works on its own per-entry copy when auto_repair=True, so no
|
||||||
# additional dict copy is needed here.
|
# additional dict copy is needed here.
|
||||||
@@ -732,8 +702,7 @@ class ModelScanner:
|
|||||||
raw_data=valid_entries,
|
raw_data=valid_entries,
|
||||||
hash_index=hash_index,
|
hash_index=hash_index,
|
||||||
tags_count=tags_count,
|
tags_count=tags_count,
|
||||||
excluded_models=list(persisted.excluded_models),
|
excluded_models=list(persisted.excluded_models)
|
||||||
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
|
|
||||||
)
|
)
|
||||||
return scan_result, invalid_entries
|
return scan_result, invalid_entries
|
||||||
|
|
||||||
@@ -768,7 +737,6 @@ class ModelScanner:
|
|||||||
hash_snapshot,
|
hash_snapshot,
|
||||||
list(scan_result.excluded_models),
|
list(scan_result.excluded_models),
|
||||||
autov3_snapshot,
|
autov3_snapshot,
|
||||||
scan_result.all_folders,
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), 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),
|
raw_data=list(self._cache.raw_data),
|
||||||
hash_index=self._hash_index,
|
hash_index=self._hash_index,
|
||||||
tags_count=dict(self._tags_count),
|
tags_count=dict(self._tags_count),
|
||||||
excluded_models=list(self._excluded_models),
|
excluded_models=list(self._excluded_models)
|
||||||
all_folders=(
|
|
||||||
list(self._cache.all_folders)
|
|
||||||
if self._cache.all_folders is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
await self._save_persistent_cache(snapshot)
|
await self._save_persistent_cache(snapshot)
|
||||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
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)
|
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
||||||
|
|
||||||
# Get current cached file paths
|
# 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}
|
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}
|
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
|
||||||
|
cached_real_paths = {}
|
||||||
# physical path -> cached business path, for the alias case where the
|
for cached_path in cached_paths:
|
||||||
# same file is reachable under a different path than the cached one
|
try:
|
||||||
# (overlapping roots / symlink layout changes): keep the existing
|
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||||
# entry instead of delete + re-add (which would re-read metadata and
|
except Exception:
|
||||||
# re-hash every file). Built lazily on the first miss, because a
|
continue
|
||||||
# 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)
|
|
||||||
|
|
||||||
# Track found files and new files
|
# Track found files and new files
|
||||||
found_paths = set()
|
found_paths = set()
|
||||||
new_files = []
|
new_files = []
|
||||||
visited_real_paths = set()
|
visited_real_paths = set()
|
||||||
discovered_real_files = set()
|
discovered_real_files = set()
|
||||||
discovered_folders: Set[str] = set()
|
|
||||||
|
|
||||||
# Scan all model roots
|
# Scan all model roots
|
||||||
for root_path in self.get_model_roots():
|
for root_path in self.get_model_roots():
|
||||||
@@ -1086,31 +1033,19 @@ class ModelScanner:
|
|||||||
continue
|
continue
|
||||||
visited_real_paths.add(real_root)
|
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:
|
for file in files:
|
||||||
ext = os.path.splitext(file)[1].lower()
|
ext = os.path.splitext(file)[1].lower()
|
||||||
if ext in self.file_extensions:
|
if ext in self.file_extensions:
|
||||||
# Construct paths exactly as they would be in cache
|
# Construct paths exactly as they would be in cache
|
||||||
file_path = os.path.join(root, file).replace(os.sep, '/')
|
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
|
# Check if this file is already in cache
|
||||||
if file_path in cached_paths:
|
if file_path in cached_paths:
|
||||||
found_paths.add(file_path)
|
found_paths.add(file_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Only a cache miss needs the physical path, so the
|
cached_real_match = cached_real_paths.get(real_file_path)
|
||||||
# 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)
|
|
||||||
if cached_real_match:
|
if cached_real_match:
|
||||||
found_paths.add(cached_real_match)
|
found_paths.add(cached_real_match)
|
||||||
continue
|
continue
|
||||||
@@ -1155,9 +1090,6 @@ class ModelScanner:
|
|||||||
total_new = len(new_files)
|
total_new = len(new_files)
|
||||||
processed_new = 0
|
processed_new = 0
|
||||||
last_progress_time = time.time()
|
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):
|
for i in range(0, total_new, batch_size):
|
||||||
batch = new_files[i:i+batch_size]
|
batch = new_files[i:i+batch_size]
|
||||||
for path in batch:
|
for path in batch:
|
||||||
@@ -1166,10 +1098,12 @@ class ModelScanner:
|
|||||||
try:
|
try:
|
||||||
# Find the appropriate root path for this file
|
# Find the appropriate root path for this file
|
||||||
root_path = None
|
root_path = None
|
||||||
normalized_path = os.path.normpath(path)
|
model_roots = self.get_model_roots()
|
||||||
for potential_root in model_roots:
|
for potential_root in model_roots:
|
||||||
# Normalize both paths for comparison
|
# 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
|
root_path = potential_root
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1266,41 +1200,25 @@ class ModelScanner:
|
|||||||
# Update cache data
|
# Update cache data
|
||||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
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.
|
dedup_removed = 0
|
||||||
# Duplicates can only be introduced by external code rewriting
|
seen_paths: set[str] = set()
|
||||||
# raw_data directly or by this pass's own appends, so an unchanged
|
deduped: list[Dict[str, Any]] = []
|
||||||
# filesystem walk over a clean cache has nothing to clean. The size
|
for item in reversed(self._cache.raw_data):
|
||||||
# mismatch is an O(1) tell that the snapshot already contained
|
path = item.get('file_path', '')
|
||||||
# duplicates; skipping the O(N) pass when it is provably clean is
|
if path not in seen_paths:
|
||||||
# what keeps a no-change Refresh cheap.
|
seen_paths.add(path)
|
||||||
if cached_size_before != len(cached_paths) or total_added > 0:
|
deduped.append(item)
|
||||||
dedup_removed = 0
|
else:
|
||||||
seen_paths: set[str] = set()
|
for tag in item.get('tags', []):
|
||||||
deduped: list[Dict[str, Any]] = []
|
if tag in self._tags_count:
|
||||||
for item in reversed(self._cache.raw_data):
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
path = item.get('file_path', '')
|
if self._tags_count[tag] == 0:
|
||||||
if path not in seen_paths:
|
del self._tags_count[tag]
|
||||||
seen_paths.add(path)
|
dedup_removed += 1
|
||||||
deduped.append(item)
|
if dedup_removed > 0:
|
||||||
else:
|
self._cache.raw_data = list(reversed(deduped))
|
||||||
for tag in item.get('tags', []):
|
total_removed += dedup_removed
|
||||||
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
|
# Resort cache if changes were made
|
||||||
if total_added > 0 or total_removed > 0:
|
if total_added > 0 or total_removed > 0:
|
||||||
# Update folders list
|
# Update folders list
|
||||||
@@ -1313,8 +1231,6 @@ class ModelScanner:
|
|||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
await self._persist_current_cache()
|
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.")
|
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(
|
await self._broadcast_scan_progress(
|
||||||
@@ -1354,73 +1270,22 @@ class ModelScanner:
|
|||||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||||
|
|
||||||
async def get_all_folders(self) -> List[str]:
|
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.
|
"""Enumerate every directory under the model roots, live from disk.
|
||||||
|
|
||||||
Runs in a worker thread. Hidden directories (any segment starting
|
Unlike the models-only ``cache.folders``, this includes empty
|
||||||
with '.') and the pending-delete staging dir are excluded.
|
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()
|
discovered: Set[str] = set()
|
||||||
visited_real_paths: 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):
|
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||||
discovered.add(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]:
|
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||||
"""Get model file info and metadata (extensible for different model types)"""
|
"""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."""
|
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
||||||
return entry
|
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
|
@staticmethod
|
||||||
def _normalize_path_value(path: Optional[str]) -> str:
|
def _normalize_path_value(path: Optional[str]) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
@@ -1675,9 +1533,6 @@ class ModelScanner:
|
|||||||
else:
|
else:
|
||||||
self._cache.raw_data = list(scan_result.raw_data)
|
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
|
# resort() rebuilds folders and the version index on every path, so a
|
||||||
# separate rebuild_version_index() call here would be redundant.
|
# separate rebuild_version_index() call here would be redundant.
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
@@ -1775,7 +1630,6 @@ class ModelScanner:
|
|||||||
processed_files = 0
|
processed_files = 0
|
||||||
processed_real_files: Set[str] = set()
|
processed_real_files: Set[str] = set()
|
||||||
visited_real_dirs: Set[str] = set()
|
visited_real_dirs: Set[str] = set()
|
||||||
discovered_folders: Set[str] = set()
|
|
||||||
|
|
||||||
async def handle_progress(current_name: str = '') -> None:
|
async def handle_progress(current_name: str = '') -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
@@ -1854,13 +1708,6 @@ class ModelScanner:
|
|||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
if _is_excluded_dir(entry.name):
|
if _is_excluded_dir(entry.name):
|
||||||
continue
|
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)
|
await scan_recursive(entry.path, root_path, visited_paths)
|
||||||
except Exception as entry_error:
|
except Exception as entry_error:
|
||||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||||
@@ -1880,8 +1727,7 @@ class ModelScanner:
|
|||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
hash_index=hash_index,
|
hash_index=hash_index,
|
||||||
tags_count=tags_count,
|
tags_count=tags_count,
|
||||||
excluded_models=excluded_models,
|
excluded_models=excluded_models
|
||||||
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error moving metadata file: {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)
|
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2138,16 +1970,6 @@ class ModelScanner:
|
|||||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
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', []):
|
for tag in cache_entry.get('tags', []):
|
||||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
@@ -2155,6 +1977,10 @@ class ModelScanner:
|
|||||||
|
|
||||||
await cache.resort()
|
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:
|
if cache_modified:
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
self.bump_cache_version()
|
self.bump_cache_version()
|
||||||
@@ -2238,11 +2064,6 @@ class ModelScanner:
|
|||||||
file_path_override=file_path,
|
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)
|
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||||
if (
|
if (
|
||||||
not desired_entry.get("sha256")
|
not desired_entry.get("sha256")
|
||||||
|
|||||||
@@ -118,24 +118,19 @@ class ModelServiceFactory:
|
|||||||
|
|
||||||
|
|
||||||
def register_default_model_types():
|
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.lora_service import LoraService
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.embedding_service import EmbeddingService
|
from ..services.embedding_service import EmbeddingService
|
||||||
from ..services.other_model_service import OtherModelService
|
|
||||||
from ..routes.lora_routes import LoraRoutes
|
from ..routes.lora_routes import LoraRoutes
|
||||||
from ..routes.checkpoint_routes import CheckpointRoutes
|
from ..routes.checkpoint_routes import CheckpointRoutes
|
||||||
from ..routes.embedding_routes import EmbeddingRoutes
|
from ..routes.embedding_routes import EmbeddingRoutes
|
||||||
from ..routes.other_routes import OtherRoutes
|
|
||||||
|
|
||||||
# Register LoRA model type
|
# Register LoRA model type
|
||||||
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
||||||
|
|
||||||
# Register Checkpoint model type
|
# Register Checkpoint model type
|
||||||
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
|
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
|
||||||
|
|
||||||
# Register Embedding model type
|
# Register Embedding model type
|
||||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||||
|
|
||||||
# Register Other model type (VAE, upscaler, text encoder, ...)
|
|
||||||
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
|
|
||||||
@@ -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",
|
"lora": "loras",
|
||||||
"checkpoint": "checkpoints",
|
"checkpoint": "checkpoints",
|
||||||
"embedding": "embeddings",
|
"embedding": "embeddings",
|
||||||
"other": "other",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Module-level alias so tests can spy on timer task creation without patching
|
# Module-level alias so tests can spy on timer task creation without patching
|
||||||
@@ -984,7 +983,6 @@ class PendingDeleteService:
|
|||||||
"get_lora_scanner",
|
"get_lora_scanner",
|
||||||
"get_checkpoint_scanner",
|
"get_checkpoint_scanner",
|
||||||
"get_embedding_scanner",
|
"get_embedding_scanner",
|
||||||
"get_other_scanner",
|
|
||||||
):
|
):
|
||||||
getter = getattr(ServiceRegistry, getter_name, None)
|
getter = getattr(ServiceRegistry, getter_name, None)
|
||||||
if not callable(getter):
|
if not callable(getter):
|
||||||
|
|||||||
@@ -19,9 +19,6 @@ class PersistedCacheData:
|
|||||||
hash_rows: List[Tuple[str, str]]
|
hash_rows: List[Tuple[str, str]]
|
||||||
excluded_models: List[str]
|
excluded_models: List[str]
|
||||||
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
|
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.
|
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 = ?",
|
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||||
(model_type,),
|
(model_type,),
|
||||||
).fetchall()
|
).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:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -227,20 +216,14 @@ class PersistentModelCache:
|
|||||||
]
|
]
|
||||||
|
|
||||||
excluded_paths = [row["file_path"] for row in excluded]
|
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(
|
return PersistedCacheData(
|
||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
hash_rows=hash_pairs,
|
hash_rows=hash_pairs,
|
||||||
excluded_models=excluded_paths,
|
excluded_models=excluded_paths,
|
||||||
autov3_hash_rows=autov3_pairs,
|
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():
|
if not self.is_enabled():
|
||||||
return
|
return
|
||||||
if not self._schema_initialized:
|
if not self._schema_initialized:
|
||||||
@@ -486,27 +469,6 @@ class PersistentModelCache:
|
|||||||
excluded_inserts,
|
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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -592,17 +554,6 @@ class PersistentModelCache:
|
|||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
PRIMARY KEY (model_type, file_path)
|
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)
|
self._ensure_additional_model_columns(conn)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class PersistentRecipeCache:
|
|||||||
"file_mtime",
|
"file_mtime",
|
||||||
"file_size",
|
"file_size",
|
||||||
"favorite",
|
"favorite",
|
||||||
|
"repair_version",
|
||||||
"preview_nsfw_level",
|
"preview_nsfw_level",
|
||||||
"loras_json",
|
"loras_json",
|
||||||
"checkpoint_json",
|
"checkpoint_json",
|
||||||
@@ -441,6 +442,7 @@ class PersistentRecipeCache:
|
|||||||
file_mtime REAL,
|
file_mtime REAL,
|
||||||
file_size INTEGER,
|
file_size INTEGER,
|
||||||
favorite INTEGER DEFAULT 0,
|
favorite INTEGER DEFAULT 0,
|
||||||
|
repair_version INTEGER DEFAULT 0,
|
||||||
preview_nsfw_level INTEGER DEFAULT 0,
|
preview_nsfw_level INTEGER DEFAULT 0,
|
||||||
loras_json TEXT,
|
loras_json TEXT,
|
||||||
checkpoint_json TEXT,
|
checkpoint_json TEXT,
|
||||||
@@ -539,6 +541,7 @@ class PersistentRecipeCache:
|
|||||||
file_mtime,
|
file_mtime,
|
||||||
file_size,
|
file_size,
|
||||||
1 if recipe.get("favorite") else 0,
|
1 if recipe.get("favorite") else 0,
|
||||||
|
int(recipe.get("repair_version") or 0),
|
||||||
int(recipe.get("preview_nsfw_level") or 0),
|
int(recipe.get("preview_nsfw_level") or 0),
|
||||||
loras_json,
|
loras_json,
|
||||||
checkpoint_json,
|
checkpoint_json,
|
||||||
@@ -596,6 +599,7 @@ class PersistentRecipeCache:
|
|||||||
"created_date": row["created_date"] or 0.0,
|
"created_date": row["created_date"] or 0.0,
|
||||||
"modified": row["modified"] or 0.0,
|
"modified": row["modified"] or 0.0,
|
||||||
"favorite": bool(row["favorite"]),
|
"favorite": bool(row["favorite"]),
|
||||||
|
"repair_version": row["repair_version"] or 0,
|
||||||
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
|
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
|
||||||
"has_workflow": bool(row["has_workflow"]),
|
"has_workflow": bool(row["has_workflow"]),
|
||||||
"loras": loras,
|
"loras": loras,
|
||||||
|
|||||||
+233
-158
@@ -94,6 +94,8 @@ class RecipeScanner:
|
|||||||
cls._instance._civitai_client = None # Will be lazily initialized
|
cls._instance._civitai_client = None # Will be lazily initialized
|
||||||
return cls._instance
|
return cls._instance
|
||||||
|
|
||||||
|
REPAIR_VERSION = 4
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
lora_scanner: Optional[LoraScanner] = None,
|
lora_scanner: Optional[LoraScanner] = None,
|
||||||
@@ -483,43 +485,32 @@ class RecipeScanner:
|
|||||||
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
||||||
return suggestions[:limit]
|
return suggestions[:limit]
|
||||||
|
|
||||||
def _is_rematch_candidate(
|
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
||||||
self, entry: dict[str, Any], relaxed: bool = False
|
|
||||||
) -> bool:
|
|
||||||
"""Return True when a recipe entry is eligible for local re-matching.
|
"""Return True when a recipe entry is eligible for local re-matching.
|
||||||
|
|
||||||
An entry counts as unresolved when its identity is known to be
|
An entry counts as unresolved when its identity is known to be
|
||||||
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
||||||
identity fields (``hash``/``file_name``). A healthy entry whose
|
identity fields (``hash``/``file_name``). A healthy entry whose
|
||||||
hash is simply not present in the local library is NOT a candidate
|
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
|
it may be a recipe imported without downloading the model yet, and
|
||||||
downloading the model yet, and its CivitAI-valid hash must never be
|
its CivitAI-valid hash must never be overwritten by the imprecise
|
||||||
overwritten by the imprecise filename fallback.
|
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.
|
|
||||||
"""
|
"""
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return False
|
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 = (
|
unresolved = (
|
||||||
entry.get("isDeleted")
|
entry.get("isDeleted")
|
||||||
or entry.get("hashInvalid")
|
or entry.get("hashInvalid")
|
||||||
or not entry.get("hash")
|
or not entry.get("hash")
|
||||||
or not entry.get("file_name")
|
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]]:
|
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
|
||||||
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
||||||
@@ -820,9 +811,208 @@ class RecipeScanner:
|
|||||||
"""Check if cancellation has been requested."""
|
"""Check if cancellation has been requested."""
|
||||||
return self._cancel_requested
|
return self._cancel_requested
|
||||||
|
|
||||||
async def rematch_recipe_by_id(
|
async def repair_all_recipes(
|
||||||
self, recipe_id: str, *, relaxed: bool = False
|
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
||||||
) -> Dict[str, Any]:
|
) -> 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.
|
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
Logs one INFO summary line for this run and delegates the per-recipe
|
Logs one INFO summary line for this run and delegates the per-recipe
|
||||||
@@ -830,14 +1020,12 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_id: ID of the recipe to rematch
|
recipe_id: ID of the recipe to rematch
|
||||||
relaxed: When True, healthy entries are rematch candidates too
|
|
||||||
(see ``_rematch_single_recipe``).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
||||||
Raises RecipeNotFoundError when the recipe is missing.
|
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
|
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
|
||||||
logger.info(
|
logger.info(
|
||||||
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
||||||
@@ -850,9 +1038,7 @@ class RecipeScanner:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _rematch_recipe_by_id(
|
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||||
self, recipe_id: str, *, relaxed: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||||
@@ -863,16 +1049,12 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_id: ID of the recipe to rematch
|
recipe_id: ID of the recipe to rematch
|
||||||
relaxed: When True, healthy entries are rematch candidates too
|
|
||||||
(see ``_rematch_single_recipe``).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the rematch result with unified counters
|
Dict summary of the rematch result with unified counters
|
||||||
(matched_recipes, matched_entries, unresolved_recipes,
|
(matched_recipes, matched_entries, unresolved_recipes,
|
||||||
unresolved_entries plus the legacy rematched/skipped/errors
|
unresolved_entries plus the legacy rematched/skipped/errors
|
||||||
fields) and a per-entry ``details`` report plus a flattened
|
fields) and a per-entry ``details`` report. The legacy ``skipped``
|
||||||
``l4_matches`` list (filename-level matches for review/undo,
|
|
||||||
consistent with the bulk/global paths). The legacy ``skipped``
|
|
||||||
field means "recipe not updated" and overlaps
|
field means "recipe not updated" and overlaps
|
||||||
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
||||||
as both). Raises RecipeNotFoundError when the recipe is missing.
|
as both). Raises RecipeNotFoundError when the recipe is missing.
|
||||||
@@ -893,8 +1075,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
rematched, _errors, details = await self._rematch_single_recipe(
|
rematched, _errors, details = await self._rematch_single_recipe(
|
||||||
recipe, local_cache, autov3_cache, filename_cache,
|
recipe, local_cache, autov3_cache, filename_cache
|
||||||
relaxed=relaxed,
|
|
||||||
)
|
)
|
||||||
except RecipePersistenceError as exc:
|
except RecipePersistenceError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -913,16 +1094,12 @@ class RecipeScanner:
|
|||||||
"unresolved_recipes": 0,
|
"unresolved_recipes": 0,
|
||||||
"unresolved_entries": 0,
|
"unresolved_entries": 0,
|
||||||
"details": {"matched": [], "unresolved": []},
|
"details": {"matched": [], "unresolved": []},
|
||||||
"l4_matches": [],
|
|
||||||
"recipe": recipe,
|
"recipe": recipe,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
unresolved_entries = len(details["unresolved"])
|
unresolved_entries = len(details["unresolved"])
|
||||||
unresolved_recipes = 1 if unresolved_entries > 0 else 0
|
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:
|
if rematched == 0:
|
||||||
return {
|
return {
|
||||||
@@ -934,7 +1111,6 @@ class RecipeScanner:
|
|||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"details": details,
|
"details": details,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
"recipe": recipe,
|
"recipe": recipe,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -948,7 +1124,6 @@ class RecipeScanner:
|
|||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"details": details,
|
"details": details,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
"recipe": await self.get_recipe_by_id(recipe_id),
|
"recipe": await self.get_recipe_by_id(recipe_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,8 +1133,6 @@ class RecipeScanner:
|
|||||||
local_cache: dict[str, dict[str, Any]],
|
local_cache: dict[str, dict[str, Any]],
|
||||||
autov3_cache: dict[str, dict[str, Any]],
|
autov3_cache: dict[str, dict[str, Any]],
|
||||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||||
*,
|
|
||||||
relaxed: bool = False,
|
|
||||||
) -> Tuple[int, int, Dict[str, Any]]:
|
) -> Tuple[int, int, Dict[str, Any]]:
|
||||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||||
|
|
||||||
@@ -975,24 +1148,16 @@ class RecipeScanner:
|
|||||||
autov3_cache: L3 computed-autov3 cache snapshot
|
autov3_cache: L3 computed-autov3 cache snapshot
|
||||||
filename_cache: L4 filename cache snapshot, or None to disable
|
filename_cache: L4 filename cache snapshot, or None to disable
|
||||||
the filename fallback
|
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:
|
Returns:
|
||||||
Tuple of (rematched_entries, errors, details). The errors element
|
Tuple of (rematched_entries, errors, details). The errors element
|
||||||
is always 0 on a normal return — a persistence failure RAISES
|
is always 0 on a normal return — a persistence failure RAISES
|
||||||
``RecipePersistenceError`` so callers can count it. ``details``
|
``RecipePersistenceError`` so callers can count it. ``details``
|
||||||
carries the per-entry outcome:
|
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
|
"unresolved": [{type, entry}]}`` where an unresolved entry is a
|
||||||
rematch candidate that found no local match — an expected outcome
|
rematch candidate that found no local match — an expected outcome
|
||||||
(the model may simply not exist locally), not an error.
|
(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:
|
Raises:
|
||||||
RecipePersistenceError: when the recipe changed but
|
RecipePersistenceError: when the recipe changed but
|
||||||
@@ -1001,23 +1166,11 @@ class RecipeScanner:
|
|||||||
rematched = 0
|
rematched = 0
|
||||||
details: Dict[str, Any] = {"matched": [], "unresolved": []}
|
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
|
# Lora entries
|
||||||
loras = recipe.get("loras", [])
|
loras = recipe.get("loras", [])
|
||||||
if isinstance(loras, list):
|
if isinstance(loras, list):
|
||||||
for lora_index, entry in enumerate(loras):
|
for entry in loras:
|
||||||
if not is_actionable_candidate(entry):
|
if not self._is_rematch_candidate(entry):
|
||||||
continue
|
continue
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
item, level = await self._match_rematch_entry_with_level(
|
||||||
entry,
|
entry,
|
||||||
@@ -1041,7 +1194,6 @@ class RecipeScanner:
|
|||||||
"entry": self._entry_identifier(entry),
|
"entry": self._entry_identifier(entry),
|
||||||
"file_name": item.get("file_name") or "",
|
"file_name": item.get("file_name") or "",
|
||||||
"match_level": level,
|
"match_level": level,
|
||||||
"lora_index": lora_index,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self._write_rematch_lora_entry(entry, item)
|
self._write_rematch_lora_entry(entry, item)
|
||||||
@@ -1051,7 +1203,7 @@ class RecipeScanner:
|
|||||||
# silently since ``entry.get`` on a str would raise AttributeError).
|
# silently since ``entry.get`` on a str would raise AttributeError).
|
||||||
checkpoint = recipe.get("checkpoint")
|
checkpoint = recipe.get("checkpoint")
|
||||||
if isinstance(checkpoint, dict):
|
if isinstance(checkpoint, dict):
|
||||||
if is_actionable_candidate(checkpoint):
|
if self._is_rematch_candidate(checkpoint):
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
item, level = await self._match_rematch_entry_with_level(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
local_cache,
|
local_cache,
|
||||||
@@ -1116,36 +1268,8 @@ class RecipeScanner:
|
|||||||
self._update_fts_index_for_recipe(recipe, "update")
|
self._update_fts_index_for_recipe(recipe, "update")
|
||||||
return (rematched, 0, details)
|
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(
|
async def rematch_all_recipes(
|
||||||
self,
|
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
||||||
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
|
||||||
*,
|
|
||||||
relaxed: bool = False,
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
@@ -1159,19 +1283,14 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_callback: Optional callback for progress updates
|
progress_callback: Optional callback for progress updates
|
||||||
(started/processing/cancelled/completed events). The
|
(started/processing/cancelled/completed events).
|
||||||
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``).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the rematch run with unified counters
|
Dict summary of the rematch run with unified counters
|
||||||
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
||||||
entries plus the legacy success/status/rematched/skipped/errors/
|
entries plus the legacy success/status/rematched/skipped/errors/
|
||||||
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
|
total fields). ``rematched`` (legacy) counts updated recipes —
|
||||||
updated recipes — use ``matched_entries`` for the entry-level
|
use ``matched_entries`` for the entry-level total.
|
||||||
total.
|
|
||||||
"""
|
"""
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
@@ -1193,7 +1312,6 @@ class RecipeScanner:
|
|||||||
unresolved_entries = 0
|
unresolved_entries = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
errors_count = 0
|
errors_count = 0
|
||||||
l4_matches: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
for i, recipe in enumerate(all_recipes):
|
for i, recipe in enumerate(all_recipes):
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
@@ -1222,7 +1340,6 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -1236,7 +1353,6 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1252,15 +1368,11 @@ class RecipeScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
rematched, _errors, details = await self._rematch_single_recipe(
|
rematched, _errors, details = await self._rematch_single_recipe(
|
||||||
recipe, local_cache, autov3_cache, filename_cache,
|
recipe, local_cache, autov3_cache, filename_cache
|
||||||
relaxed=relaxed,
|
|
||||||
)
|
)
|
||||||
if rematched > 0:
|
if rematched > 0:
|
||||||
matched_recipes += 1
|
matched_recipes += 1
|
||||||
matched_entries += rematched
|
matched_entries += rematched
|
||||||
l4_matches.extend(
|
|
||||||
self._collect_l4_matches(recipe.get("id"), details)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
|
|
||||||
@@ -1306,7 +1418,6 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1320,12 +1431,9 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def rematch_recipes_bulk(
|
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
||||||
self, recipe_ids: List[str], *, relaxed: bool = False
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Rematch a set of recipes by their IDs.
|
"""Rematch a set of recipes by their IDs.
|
||||||
|
|
||||||
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
||||||
@@ -1336,18 +1444,14 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_ids: List of recipe ids to rematch.
|
recipe_ids: List of recipe ids to rematch.
|
||||||
relaxed: When True, healthy entries are rematch candidates too
|
|
||||||
(see ``_rematch_single_recipe``).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the bulk run with unified counters
|
Dict summary of the bulk run with unified counters
|
||||||
(matched_recipes, matched_entries, unresolved_recipes,
|
(matched_recipes, matched_entries, unresolved_recipes,
|
||||||
unresolved_entries plus the legacy total/rematched/skipped/errors
|
unresolved_entries plus the legacy total/rematched/skipped/errors
|
||||||
fields), a per-recipe ``details`` list, and ``l4_matches`` — a
|
fields) and a per-recipe ``details`` list. The legacy ``rematched``
|
||||||
flattened list of filename-level matches for review/undo. The
|
field is the total entry count (same as ``matched_entries``) —
|
||||||
legacy ``rematched`` field is the total entry count (same as
|
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
||||||
``matched_entries``) — unlike ``rematch_all_recipes`` where it
|
|
||||||
counts updated recipes.
|
|
||||||
"""
|
"""
|
||||||
total = len(recipe_ids)
|
total = len(recipe_ids)
|
||||||
matched_recipes = 0
|
matched_recipes = 0
|
||||||
@@ -1358,13 +1462,10 @@ class RecipeScanner:
|
|||||||
errors = 0
|
errors = 0
|
||||||
recipes: List[Dict[str, Any]] = []
|
recipes: List[Dict[str, Any]] = []
|
||||||
details_list: List[Dict[str, Any]] = []
|
details_list: List[Dict[str, Any]] = []
|
||||||
l4_matches: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
try:
|
try:
|
||||||
result = await self._rematch_recipe_by_id(
|
result = await self._rematch_recipe_by_id(recipe_id)
|
||||||
recipe_id, relaxed=relaxed
|
|
||||||
)
|
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
matched_recipes += result.get("matched_recipes", 0)
|
matched_recipes += result.get("matched_recipes", 0)
|
||||||
matched_entries += result.get("matched_entries", 0)
|
matched_entries += result.get("matched_entries", 0)
|
||||||
@@ -1377,9 +1478,6 @@ class RecipeScanner:
|
|||||||
details_list.append(
|
details_list.append(
|
||||||
{"recipe_id": recipe_id, **result["details"]}
|
{"recipe_id": recipe_id, **result["details"]}
|
||||||
)
|
)
|
||||||
l4_matches.extend(
|
|
||||||
self._collect_l4_matches(recipe_id, result["details"])
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
errors += result.get("errors", 0)
|
errors += result.get("errors", 0)
|
||||||
except RecipeNotFoundError:
|
except RecipeNotFoundError:
|
||||||
@@ -1414,22 +1512,12 @@ class RecipeScanner:
|
|||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
"details": details_list,
|
"details": details_list,
|
||||||
"l4_matches": l4_matches,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def _write_rematch_lora_entry(
|
def _write_rematch_lora_entry(
|
||||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write back a matched local model to a lora recipe entry."""
|
"""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["isDeleted"] = False
|
||||||
entry["hashInvalid"] = False
|
entry["hashInvalid"] = False
|
||||||
|
|
||||||
@@ -1453,8 +1541,6 @@ class RecipeScanner:
|
|||||||
if civitai.get("name"):
|
if civitai.get("name"):
|
||||||
entry["modelVersionName"] = civitai["name"]
|
entry["modelVersionName"] = civitai["name"]
|
||||||
|
|
||||||
entry["reconnectSnapshot"] = snapshot
|
|
||||||
|
|
||||||
def _write_rematch_checkpoint_entry(
|
def _write_rematch_checkpoint_entry(
|
||||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1466,15 +1552,6 @@ class RecipeScanner:
|
|||||||
when they already exist on the entry (or written fresh for the
|
when they already exist on the entry (or written fresh for the
|
||||||
identifier key when neither identifier form exists).
|
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["isDeleted"] = False
|
||||||
entry["hashInvalid"] = False
|
entry["hashInvalid"] = False
|
||||||
|
|
||||||
@@ -1515,8 +1592,6 @@ class RecipeScanner:
|
|||||||
else:
|
else:
|
||||||
entry["modelVersionId"] = civ_id
|
entry["modelVersionId"] = civ_id
|
||||||
|
|
||||||
entry["reconnectSnapshot"] = snapshot
|
|
||||||
|
|
||||||
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||||
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||||
recipe_id = recipe.get("id")
|
recipe_id = recipe.get("id")
|
||||||
|
|||||||
@@ -297,44 +297,23 @@ class ServiceRegistry:
|
|||||||
async def get_embedding_scanner(cls):
|
async def get_embedding_scanner(cls):
|
||||||
"""Get or create Embedding scanner instance"""
|
"""Get or create Embedding scanner instance"""
|
||||||
service_name = "embedding_scanner"
|
service_name = "embedding_scanner"
|
||||||
|
|
||||||
if service_name in cls._services:
|
if service_name in cls._services:
|
||||||
return cls._services[service_name]
|
return cls._services[service_name]
|
||||||
|
|
||||||
async with cls._get_lock(service_name):
|
async with cls._get_lock(service_name):
|
||||||
# Double-check after acquiring lock
|
# Double-check after acquiring lock
|
||||||
if service_name in cls._services:
|
if service_name in cls._services:
|
||||||
return cls._services[service_name]
|
return cls._services[service_name]
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from .embedding_scanner import EmbeddingScanner
|
from .embedding_scanner import EmbeddingScanner
|
||||||
|
|
||||||
scanner = await EmbeddingScanner.get_instance()
|
scanner = await EmbeddingScanner.get_instance()
|
||||||
cls._services[service_name] = scanner
|
cls._services[service_name] = scanner
|
||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return scanner
|
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
|
@classmethod
|
||||||
def clear_services(cls):
|
def clear_services(cls):
|
||||||
"""Clear all registered services - mainly for testing"""
|
"""Clear all registered services - mainly for testing"""
|
||||||
|
|||||||
+15
-175
@@ -25,14 +25,9 @@ from typing import (
|
|||||||
from platformdirs import user_config_dir
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
DEFAULT_DOWNLOAD_PATH_TEMPLATES,
|
|
||||||
DEFAULT_ENABLED_OTHER_SUB_TYPES,
|
|
||||||
DEFAULT_HASH_CHUNK_SIZE_MB,
|
DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||||
OTHER_SUB_TYPE_FOLDER_KEYS,
|
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
VALID_OTHER_SUB_TYPES,
|
|
||||||
normalize_other_sub_types,
|
|
||||||
)
|
)
|
||||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||||
from ..utils.settings_paths import (
|
from ..utils.settings_paths import (
|
||||||
@@ -88,11 +83,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_unet_root": "",
|
"default_unet_root": "",
|
||||||
"default_embedding_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": "",
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
@@ -126,7 +116,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"backup_retention_count": 5,
|
"backup_retention_count": 5,
|
||||||
"use_new_license_icons": True,
|
"use_new_license_icons": True,
|
||||||
"group_by_model": False,
|
"group_by_model": False,
|
||||||
"sticky_controls": False,
|
|
||||||
# AI / LLM provider configuration (BYOK)
|
# AI / LLM provider configuration (BYOK)
|
||||||
"llm_provider": "openai", # "openai" | "ollama" | "custom"
|
"llm_provider": "openai", # "openai" | "ollama" | "custom"
|
||||||
"llm_api_key": "",
|
"llm_api_key": "",
|
||||||
@@ -319,7 +308,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||||
default_unet_root=merged.get("default_unet_root"),
|
default_unet_root=merged.get("default_unet_root"),
|
||||||
default_embedding_root=merged.get("default_embedding_root"),
|
default_embedding_root=merged.get("default_embedding_root"),
|
||||||
default_other_roots=merged.get("default_other_roots"),
|
|
||||||
recipes_path=merged.get("recipes_path"),
|
recipes_path=merged.get("recipes_path"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -454,7 +442,6 @@ class SettingsManager:
|
|||||||
),
|
),
|
||||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||||
default_embedding_root=self.settings.get("default_embedding_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", ""),
|
recipes_path=self.settings.get("recipes_path", ""),
|
||||||
)
|
)
|
||||||
libraries = {library_name: library_payload}
|
libraries = {library_name: library_payload}
|
||||||
@@ -506,7 +493,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||||
default_unet_root=data.get("default_unet_root"),
|
default_unet_root=data.get("default_unet_root"),
|
||||||
default_embedding_root=data.get("default_embedding_root"),
|
default_embedding_root=data.get("default_embedding_root"),
|
||||||
default_other_roots=data.get("default_other_roots"),
|
|
||||||
recipes_path=data.get("recipes_path"),
|
recipes_path=data.get("recipes_path"),
|
||||||
metadata=data.get("metadata"),
|
metadata=data.get("metadata"),
|
||||||
base=data,
|
base=data,
|
||||||
@@ -554,9 +540,6 @@ class SettingsManager:
|
|||||||
self.settings["default_embedding_root"] = active_library.get(
|
self.settings["default_embedding_root"] = active_library.get(
|
||||||
"default_embedding_root", ""
|
"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", "")
|
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
@@ -574,7 +557,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
base: Optional[Mapping[str, Any]] = None,
|
base: Optional[Mapping[str, Any]] = None,
|
||||||
@@ -614,15 +596,6 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
payload.setdefault("default_embedding_root", "")
|
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:
|
if recipes_path is not None:
|
||||||
payload["recipes_path"] = recipes_path
|
payload["recipes_path"] = recipes_path
|
||||||
else:
|
else:
|
||||||
@@ -658,71 +631,6 @@ class SettingsManager:
|
|||||||
normalized[key] = cleaned
|
normalized[key] = cleaned
|
||||||
return normalized
|
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:
|
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
||||||
if not isinstance(folder_paths, Mapping):
|
if not isinstance(folder_paths, Mapping):
|
||||||
return False
|
return False
|
||||||
@@ -835,7 +743,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
@@ -886,14 +793,6 @@ class SettingsManager:
|
|||||||
library["default_embedding_root"] = default_embedding_root
|
library["default_embedding_root"] = default_embedding_root
|
||||||
changed = True
|
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:
|
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||||
library["recipes_path"] = recipes_path
|
library["recipes_path"] = recipes_path
|
||||||
changed = True
|
changed = True
|
||||||
@@ -994,53 +893,12 @@ class SettingsManager:
|
|||||||
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
||||||
updated = _check_and_auto_set("embeddings", "default_embedding_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:
|
if updated:
|
||||||
self.settings["default_other_roots"] = other_roots
|
|
||||||
self._update_active_library_entry(
|
self._update_active_library_entry(
|
||||||
default_lora_root=self.settings.get("default_lora_root"),
|
default_lora_root=self.settings.get("default_lora_root"),
|
||||||
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
||||||
default_unet_root=self.settings.get("default_unet_root"),
|
default_unet_root=self.settings.get("default_unet_root"),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root"),
|
default_embedding_root=self.settings.get("default_embedding_root"),
|
||||||
default_other_roots=other_roots,
|
|
||||||
)
|
)
|
||||||
if self._bootstrap_reason == "missing":
|
if self._bootstrap_reason == "missing":
|
||||||
self._needs_initial_save = True
|
self._needs_initial_save = True
|
||||||
@@ -1740,12 +1598,6 @@ class SettingsManager:
|
|||||||
value = self.normalize_download_skip_base_models(value)
|
value = self.normalize_download_skip_base_models(value)
|
||||||
elif key == "mature_blur_level":
|
elif key == "mature_blur_level":
|
||||||
value = self.normalize_mature_blur_level(value)
|
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":
|
elif key == "recipes_path":
|
||||||
current_recipes_dir = self._get_effective_recipes_dir()
|
current_recipes_dir = self._get_effective_recipes_dir()
|
||||||
value = self._normalize_recipes_path_value(value)
|
value = self._normalize_recipes_path_value(value)
|
||||||
@@ -1773,8 +1625,6 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(default_unet_root=str(value))
|
self._update_active_library_entry(default_unet_root=str(value))
|
||||||
elif key == "default_embedding_root":
|
elif key == "default_embedding_root":
|
||||||
self._update_active_library_entry(default_embedding_root=str(value))
|
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":
|
elif key == "recipes_path":
|
||||||
self._update_active_library_entry(recipes_path=str(value))
|
self._update_active_library_entry(recipes_path=str(value))
|
||||||
elif key == "model_name_display":
|
elif key == "model_name_display":
|
||||||
@@ -1782,8 +1632,6 @@ class SettingsManager:
|
|||||||
self._save_settings()
|
self._save_settings()
|
||||||
if key == "recipes_path":
|
if key == "recipes_path":
|
||||||
self._notify_library_change(self.get_active_library_name())
|
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:
|
if portable_switch_pending:
|
||||||
self._finalize_portable_switch()
|
self._finalize_portable_switch()
|
||||||
|
|
||||||
@@ -1947,7 +1795,6 @@ class SettingsManager:
|
|||||||
"lora_scanner",
|
"lora_scanner",
|
||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
"other_scanner",
|
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
):
|
):
|
||||||
service = ServiceRegistry.get_service_sync(service_name)
|
service = ServiceRegistry.get_service_sync(service_name)
|
||||||
@@ -2112,7 +1959,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -2157,11 +2003,6 @@ class SettingsManager:
|
|||||||
if default_embedding_root is not None
|
if default_embedding_root is not None
|
||||||
else existing.get("default_embedding_root")
|
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=(
|
||||||
recipes_path
|
recipes_path
|
||||||
if recipes_path is not None
|
if recipes_path is not None
|
||||||
@@ -2194,7 +2035,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: str = "",
|
default_checkpoint_root: str = "",
|
||||||
default_unet_root: str = "",
|
default_unet_root: str = "",
|
||||||
default_embedding_root: str = "",
|
default_embedding_root: str = "",
|
||||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
|
||||||
recipes_path: str = "",
|
recipes_path: str = "",
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -2213,7 +2053,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
default_other_roots=default_other_roots,
|
|
||||||
recipes_path=recipes_path,
|
recipes_path=recipes_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=activate,
|
activate=activate,
|
||||||
@@ -2274,7 +2113,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
default_other_roots: Optional[Mapping[str, str]] = None,
|
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update folder paths for the active library."""
|
"""Update folder paths for the active library."""
|
||||||
@@ -2288,7 +2126,6 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
default_other_roots=default_other_roots,
|
|
||||||
recipes_path=recipes_path,
|
recipes_path=recipes_path,
|
||||||
activate=True,
|
activate=True,
|
||||||
)
|
)
|
||||||
@@ -2313,7 +2150,6 @@ class SettingsManager:
|
|||||||
"lora_scanner",
|
"lora_scanner",
|
||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
"other_scanner",
|
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
"model_update_service",
|
"model_update_service",
|
||||||
):
|
):
|
||||||
@@ -2336,14 +2172,10 @@ class SettingsManager:
|
|||||||
"""Get download path template for specific model type
|
"""Get download path template for specific model type
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
model_type: The type of model ('lora', 'checkpoint', 'embedding')
|
||||||
'other')
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Template string for the model type. Falls back to the per-type
|
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
templates = self.settings.get("download_path_templates", {})
|
templates = self.settings.get("download_path_templates", {})
|
||||||
|
|
||||||
@@ -2367,19 +2199,27 @@ class SettingsManager:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
|
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.settings["download_path_templates"] = templates
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
# Ensure templates is a dictionary
|
# Ensure templates is a dictionary
|
||||||
if not isinstance(templates, dict):
|
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.settings["download_path_templates"] = templates
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
return templates.get(
|
return templates.get(model_type, "{base_model}/{first_tag}")
|
||||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
|||||||
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
||||||
# Add auto-organize progress tracking
|
# Add auto-organize progress tracking
|
||||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
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
|
# Add recipe rematch progress tracking
|
||||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||||
self._auto_organize_lock = asyncio.Lock()
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
@@ -191,6 +193,14 @@ class WebSocketManager:
|
|||||||
# Broadcast via WebSocket
|
# Broadcast via WebSocket
|
||||||
await self.broadcast(data)
|
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]]:
|
def get_auto_organize_progress(self) -> Optional[Dict[str, Any]]:
|
||||||
"""Get current auto-organize progress"""
|
"""Get current auto-organize progress"""
|
||||||
return self._auto_organize_progress
|
return self._auto_organize_progress
|
||||||
@@ -199,6 +209,22 @@ class WebSocketManager:
|
|||||||
"""Clear auto-organize progress data"""
|
"""Clear auto-organize progress data"""
|
||||||
self._auto_organize_progress = None
|
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]):
|
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||||
"""Broadcast recipe rematch progress to connected clients"""
|
"""Broadcast recipe rematch progress to connected clients"""
|
||||||
# Store progress data in memory
|
# Store progress data in memory
|
||||||
|
|||||||
+1
-112
@@ -1,4 +1,4 @@
|
|||||||
from typing import Any, Dict, List
|
from typing import Any
|
||||||
|
|
||||||
NSFW_LEVELS = {
|
NSFW_LEVELS = {
|
||||||
"PG": 1,
|
"PG": 1,
|
||||||
@@ -83,103 +83,6 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
|||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
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
|
# Backward compatibility alias
|
||||||
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
||||||
|
|
||||||
@@ -188,7 +91,6 @@ CIVITAI_USER_MODEL_TYPES = [
|
|||||||
*VALID_LORA_TYPES,
|
*VALID_LORA_TYPES,
|
||||||
"textualinversion",
|
"textualinversion",
|
||||||
"checkpoint",
|
"checkpoint",
|
||||||
*sorted(VALID_OTHER_CIVITAI_TYPES),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default chunk size in megabytes used for hashing large files.
|
# Default chunk size in megabytes used for hashing large files.
|
||||||
@@ -257,19 +159,6 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
"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)
|
# 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
|
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
|
|||||||
@@ -420,10 +420,6 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", 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)
|
# Load progress file to check processed models (async to avoid blocking)
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
active_library = settings_manager.get_active_library_name()
|
active_library = settings_manager.get_active_library_name()
|
||||||
@@ -604,10 +600,6 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", 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
|
# Get all models
|
||||||
all_models = []
|
all_models = []
|
||||||
for scanner_type, scanner in scanners:
|
for scanner_type, scanner in scanners:
|
||||||
@@ -1106,10 +1098,6 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", 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
|
# Find the specified models
|
||||||
models_to_process = []
|
models_to_process = []
|
||||||
for scanner_type, scanner in scanners:
|
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 typing import Callable, Dict, Optional, List, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
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
|
from .model_utils import determine_base_model
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +77,9 @@ class BaseModelMetadata:
|
|||||||
last_checked_at: float = 0 # Last checked timestamp
|
last_checked_at: float = 0 # Last checked timestamp
|
||||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
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
|
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(
|
_unknown_fields: Dict[str, Any] = field(
|
||||||
default_factory=dict, repr=False, compare=False
|
default_factory=dict, repr=False, compare=False
|
||||||
) # Store unknown fields
|
) # Store unknown fields
|
||||||
@@ -89,6 +92,9 @@ class BaseModelMetadata:
|
|||||||
if self.tags is None:
|
if self.tags is None:
|
||||||
self.tags = []
|
self.tags = []
|
||||||
|
|
||||||
|
if self.trainedWords is None:
|
||||||
|
self.trainedWords = []
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
||||||
"""Create instance from dictionary"""
|
"""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
|
@dataclass
|
||||||
class EmbeddingMetadata(BaseModelMetadata):
|
class EmbeddingMetadata(BaseModelMetadata):
|
||||||
"""Represents the metadata structure for an Embedding model"""
|
"""Represents the metadata structure for an Embedding model"""
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.2.2"
|
version = "1.2.1"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -18,5 +18,6 @@
|
|||||||
"C:/path/to/your/embeddings_folder",
|
"C:/path/to/your/embeddings_folder",
|
||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
"auto_organize_exclusions": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
max-width: none;
|
max-width: 1400px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -38,6 +38,19 @@
|
|||||||
flex-shrink: 0;
|
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 */
|
/* Logo and title styling */
|
||||||
.header-branding {
|
.header-branding {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -83,12 +96,6 @@
|
|||||||
white-space: nowrap;
|
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:hover,
|
||||||
.nav-item:focus-visible {
|
.nav-item:focus-visible {
|
||||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
@@ -113,9 +120,6 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 600px;
|
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;
|
margin: 0 auto;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -124,7 +128,6 @@
|
|||||||
.header-search .search-container {
|
.header-search .search-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
min-width: 0;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -146,12 +149,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
padding-left: 2.25rem !important;
|
padding-left: 2.25rem !important;
|
||||||
/* Reserve exactly the inline chrome so typed text never runs under it:
|
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
||||||
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;
|
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -699,20 +697,6 @@
|
|||||||
margin: 0.25rem 0;
|
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 */
|
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -732,6 +716,11 @@
|
|||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-controls > div {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive: Hide nav icons at 1100px to save space */
|
/* Responsive: Hide nav icons at 1100px to save space */
|
||||||
@@ -808,12 +797,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For narrower screens - switch nav to icons only.
|
/* For very small screens - switch nav to icons only */
|
||||||
A labelled nav needs ~383px and a readable search field needs ~300px, so the
|
@media (max-width: 600px) {
|
||||||
two cannot coexist below ~700px: at 601-700px the search input was previously
|
.header-container {
|
||||||
squeezed to 200px, leaving only ~96px of text room and overlapping the
|
padding: 0 8px;
|
||||||
placeholder with the inline toggles. Labels therefore collapse here. */
|
gap: 0.4rem;
|
||||||
@media (max-width: 700px) {
|
}
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -821,7 +811,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 0.25rem 0.4rem;
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item span {
|
.nav-item span {
|
||||||
@@ -830,22 +821,6 @@
|
|||||||
|
|
||||||
.nav-item i {
|
.nav-item i {
|
||||||
display: block;
|
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;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -592,145 +592,3 @@ button:disabled,
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
flex-shrink: 0;
|
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;
|
font-style: italic;
|
||||||
user-select: none;
|
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);
|
z-index: var(--z-toast);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
/* No align-items (defaults to stretch) so every toast shares one equal width */
|
align-items: flex-end;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 20px 0; /* Small breathing room below the header */
|
padding: 8px 20px 0; /* Small breathing room below the header */
|
||||||
pointer-events: none; /* Allow clicking through the container */
|
pointer-events: none; /* Allow clicking through the container */
|
||||||
|
|||||||
+37
-68
@@ -25,23 +25,6 @@
|
|||||||
box-shadow: var(--shadow-xs);
|
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 */
|
/* Responsive container for larger screens */
|
||||||
@media (min-width: 2150px) {
|
@media (min-width: 2150px) {
|
||||||
.container {
|
.container {
|
||||||
@@ -59,10 +42,7 @@ body.sticky-controls .sticky-topbar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
/* Push to the right of the row. Because it is also the flex item that is
|
margin-left: auto; /* Push to the right */
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -70,11 +50,7 @@ body.sticky-controls .sticky-topbar {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
|
flex-wrap: nowrap;
|
||||||
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;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,11 +58,7 @@ body.sticky-controls .sticky-topbar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
/* Let the group shrink rather than overflow so .controls-right only wraps
|
flex-wrap: nowrap;
|
||||||
when it really has to. */
|
|
||||||
flex-wrap: wrap;
|
|
||||||
flex-shrink: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action button styling */
|
/* Action button styling */
|
||||||
@@ -95,9 +67,7 @@ body.sticky-controls .sticky-topbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-group button {
|
.control-group button {
|
||||||
/* Keeps the toolbar visually even without forcing the row to overflow (the
|
min-width: 100px;
|
||||||
old 100px floor pushed the total past the container on wide screens). */
|
|
||||||
min-width: 90px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -231,17 +201,20 @@ body.sticky-controls .sticky-topbar {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
min-width: 16px;
|
margin-left: 6px;
|
||||||
height: 16px;
|
min-width: 18px;
|
||||||
padding: 0 4px;
|
height: 18px;
|
||||||
font-size: 10px;
|
padding: 0 5px;
|
||||||
font-weight: 500;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
background-color: var(--shortcut-bg);
|
background-color: var(--shortcut-bg);
|
||||||
border: 1px solid var(--shortcut-border);
|
border: 1px solid var(--shortcut-border);
|
||||||
|
box-shadow: var(--shortcut-shadow);
|
||||||
color: var(--shortcut-text);
|
color: var(--shortcut-text);
|
||||||
|
vertical-align: middle;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: var(--transition-base);
|
transition: var(--transition-base);
|
||||||
}
|
}
|
||||||
@@ -252,21 +225,10 @@ body.sticky-controls .sticky-topbar {
|
|||||||
border-color: var(--shortcut-border-hover);
|
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 */
|
/* Ensure correct vertical alignment for text+shortcut */
|
||||||
.control-group button span {
|
.control-group button span {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Select dropdown styling */
|
/* Select dropdown styling */
|
||||||
@@ -640,42 +602,49 @@ body.sticky-controls .sticky-topbar {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
|
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
||||||
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. */
|
|
||||||
@media (max-width: 1500px) {
|
@media (max-width: 1500px) {
|
||||||
|
.actions {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group button {
|
.controls-right {
|
||||||
min-width: 0;
|
width: 100%;
|
||||||
padding: 4px 8px;
|
justify-content: flex-end;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group select {
|
/* Reduce button sizes to fit better */
|
||||||
min-width: 0;
|
.control-group button {
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.8em;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
gap: var(--space-2);
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Narrow screens: let the right-hand group wrap below the buttons, still
|
|
||||||
right-aligned. */
|
|
||||||
.controls-right {
|
.controls-right {
|
||||||
flex-wrap: wrap;
|
width: 100%;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--space-1);
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group button:hover {
|
.control-group button:hover {
|
||||||
|
|||||||
@@ -44,13 +44,6 @@
|
|||||||
pointer-events: auto !important;
|
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 {
|
.onboarding-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import { state } from '../state/index.js';
|
|||||||
export const MODEL_TYPES = {
|
export const MODEL_TYPES = {
|
||||||
LORA: 'loras',
|
LORA: 'loras',
|
||||||
CHECKPOINT: 'checkpoints',
|
CHECKPOINT: 'checkpoints',
|
||||||
EMBEDDING: 'embeddings',
|
EMBEDDING: 'embeddings' // Future model type
|
||||||
OTHER: 'other'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base API configuration for each model type
|
// Base API configuration for each model type
|
||||||
@@ -41,15 +40,6 @@ export const MODEL_CONFIG = {
|
|||||||
supportsBulkOperations: true,
|
supportsBulkOperations: true,
|
||||||
supportsMove: true,
|
supportsMove: true,
|
||||||
templateName: 'embeddings.html'
|
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]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
|
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',
|
downloadGet: '/api/lm/download-model-get',
|
||||||
cancelGet: '/api/lm/cancel-download-get',
|
cancelGet: '/api/lm/cancel-download-get',
|
||||||
progress: '/api/lm/download-progress',
|
progress: '/api/lm/download-progress',
|
||||||
routing: '/api/lm/download/routing',
|
|
||||||
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
|
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
|
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { LoraApiClient } from './loraApi.js';
|
import { LoraApiClient } from './loraApi.js';
|
||||||
import { CheckpointApiClient } from './checkpointApi.js';
|
import { CheckpointApiClient } from './checkpointApi.js';
|
||||||
import { EmbeddingApiClient } from './embeddingApi.js';
|
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||||
import { OtherApiClient } from './otherApi.js';
|
|
||||||
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
@@ -13,8 +12,6 @@ export function createModelApiClient(modelType) {
|
|||||||
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
||||||
case MODEL_TYPES.EMBEDDING:
|
case MODEL_TYPES.EMBEDDING:
|
||||||
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
||||||
case MODEL_TYPES.OTHER:
|
|
||||||
return new OtherApiClient(MODEL_TYPES.OTHER);
|
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported model type: ${modelType}`);
|
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',
|
move: '/api/lm/recipe/move',
|
||||||
moveBulk: '/api/lm/recipes/move-bulk',
|
moveBulk: '/api/lm/recipes/move-bulk',
|
||||||
bulkDelete: '/api/lm/recipes/bulk-delete',
|
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||||
|
repairBulk: '/api/lm/recipes/repair-bulk',
|
||||||
rematchBulk: '/api/lm/recipes/rematch-bulk',
|
rematchBulk: '/api/lm/recipes/rematch-bulk',
|
||||||
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
|
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) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
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');
|
throw new Error('No recipe IDs could be derived from file paths');
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = { recipe_ids: recipeIds };
|
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
|
||||||
// Only sent when opted in — the strict body stays exactly
|
method: 'POST',
|
||||||
// {recipe_ids} for backward compatibility.
|
headers: {
|
||||||
if (options.relaxed === true) {
|
'Content-Type': 'application/json',
|
||||||
body.relaxed = true;
|
},
|
||||||
|
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, {
|
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
|
||||||
@@ -702,7 +728,9 @@ export class RecipeSidebarApiClient {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify({
|
||||||
|
recipe_ids: recipeIds,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|||||||
@@ -41,9 +41,13 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
|
||||||
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
|
||||||
const downloadMissingLorasItem = this.menu.querySelector('[data-action="download-missing-loras"]');
|
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 reimportMetadataItem = this.menu.querySelector('[data-action="reimport-metadata"]');
|
||||||
const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
|
const rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
|
||||||
|
|
||||||
|
if (repairMetadataItem) {
|
||||||
|
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
|
||||||
|
}
|
||||||
if (reimportMetadataItem) {
|
if (reimportMetadataItem) {
|
||||||
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
|
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"]');
|
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
|
||||||
if (downloadExampleImagesSubmenu) {
|
if (downloadExampleImagesSubmenu) {
|
||||||
// Show on model pages (loras, checkpoints, embeddings, other), hide on recipes
|
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
||||||
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none';
|
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||||
@@ -279,6 +283,9 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
case 'delete-all':
|
case 'delete-all':
|
||||||
bulkManager.showBulkDeleteModal();
|
bulkManager.showBulkDeleteModal();
|
||||||
break;
|
break;
|
||||||
|
case 'repair-metadata':
|
||||||
|
bulkManager.repairSelectedRecipes();
|
||||||
|
break;
|
||||||
case 'rematch-metadata':
|
case 'rematch-metadata':
|
||||||
bulkManager.rematchSelectedRecipes();
|
bulkManager.rematchSelectedRecipes();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
super.showMenu(x, y, card);
|
super.showMenu(x, y, card);
|
||||||
this.updateExcludeMenuItem();
|
this.updateExcludeMenuItem();
|
||||||
this.updateEnrichMenuItem(card);
|
|
||||||
|
|
||||||
// Update the "Move to other root" label based on current model type
|
// Update the "Move to other root" label based on current model type
|
||||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
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 { state } from '../../state/index.js';
|
||||||
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
|
||||||
import { showRematchSummary } from '../RematchSummaryModal.js';
|
|
||||||
|
|
||||||
export class GlobalContextMenu extends BaseContextMenu {
|
export class GlobalContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -25,6 +23,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
|
||||||
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
|
||||||
const excludedModelsItem = this.menu.querySelector('[data-action="manage-excluded-models"]');
|
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 rematchRecipesItem = this.menu.querySelector('[data-action="rematch-recipes"]');
|
||||||
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
const groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
|
||||||
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
|
||||||
@@ -42,6 +41,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
cleanupExamplesItem?.classList.add('hidden');
|
cleanupExamplesItem?.classList.add('hidden');
|
||||||
excludedModelsItem?.classList.add('hidden');
|
excludedModelsItem?.classList.add('hidden');
|
||||||
groupByModelItem?.classList.add('hidden');
|
groupByModelItem?.classList.add('hidden');
|
||||||
|
repairRecipesItem?.classList.remove('hidden');
|
||||||
rematchRecipesItem?.classList.remove('hidden');
|
rematchRecipesItem?.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
modelUpdateItem?.classList.remove('hidden');
|
modelUpdateItem?.classList.remove('hidden');
|
||||||
@@ -50,6 +50,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
cleanupExamplesItem?.classList.remove('hidden');
|
cleanupExamplesItem?.classList.remove('hidden');
|
||||||
excludedModelsItem?.classList.remove('hidden');
|
excludedModelsItem?.classList.remove('hidden');
|
||||||
groupByModelItem?.classList.remove('hidden');
|
groupByModelItem?.classList.remove('hidden');
|
||||||
|
repairRecipesItem?.classList.add('hidden');
|
||||||
rematchRecipesItem?.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);
|
console.error('Failed to refresh missing license metadata:', error);
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case 'repair-recipes':
|
||||||
|
this.repairRecipes(menuItem).catch((error) => {
|
||||||
|
console.error('Failed to repair recipes:', error);
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'rematch-recipes':
|
case 'rematch-recipes':
|
||||||
this.rematchRecipes(menuItem).catch((error) => {
|
this.rematchRecipes(menuItem).catch((error) => {
|
||||||
console.error('Failed to rematch recipes:', error);
|
console.error('Failed to rematch recipes:', error);
|
||||||
@@ -365,19 +371,100 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
return `${displayName}s`;
|
return `${displayName}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async rematchRecipes(menuItem) {
|
async repairRecipes(menuItem) {
|
||||||
if (this._rematchInProgress) {
|
if (this._repairInProgress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect options (relaxed matching) before starting anything; the
|
this._repairInProgress = true;
|
||||||
// run only begins when the user confirms the dialog.
|
menuItem?.classList.add('disabled');
|
||||||
rematchModalManager.showOptionsModal({
|
|
||||||
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
|
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) {
|
if (this._rematchInProgress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -398,7 +485,6 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const response = await fetch('/api/lm/recipes/rematch', {
|
const response = await fetch('/api/lm/recipes/rematch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ relaxed: !!relaxed }),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -426,32 +512,48 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||||
const failures = p.errors || 0;
|
const failures = p.errors || 0;
|
||||||
const unresolved = p.unresolved_entries ?? 0;
|
const unresolved = p.unresolved_entries ?? 0;
|
||||||
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
|
if (entries > 0) {
|
||||||
// Complete no-op (nothing matched, nothing
|
const successKey = failures > 0
|
||||||
// unresolved, no errors) keeps the lightweight
|
? 'globalContextMenu.rematchRecipes.successErrors'
|
||||||
// toast; anything else opens the post-run summary
|
: 'globalContextMenu.rematchRecipes.success';
|
||||||
// modal.
|
const successText = failures > 0
|
||||||
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
|
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
||||||
if (isNoop) {
|
: `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(
|
progressUI?.complete(translate(
|
||||||
'globalContextMenu.rematchRecipes.success',
|
'globalContextMenu.rematchRecipes.success',
|
||||||
{ count: recipes, recipes, entries, failures },
|
{ count: recipes, recipes, entries, failures },
|
||||||
`Matched ${entries} entries across ${recipes} recipes.`
|
`Matched ${entries} entries across ${recipes} recipes.`
|
||||||
));
|
));
|
||||||
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
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
|
// Refresh recipes page if active
|
||||||
if (window.recipesPage) {
|
if (window.recipesPage) {
|
||||||
@@ -468,23 +570,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||||
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
||||||
));
|
));
|
||||||
// A cancelled run still reports partial results
|
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
||||||
// 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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (progressResponse.status === 404) {
|
} else if (progressResponse.status === 404) {
|
||||||
// Progress might have finished quickly and been cleaned up
|
// Progress might have finished quickly and been cleaned up
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
import { state } from '../../state/index.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.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 { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
@@ -26,6 +27,16 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
this.updateEnrichMenuItem(card);
|
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) {
|
handleMenuAction(action, menuItem) {
|
||||||
// First try to handle with common actions
|
// First try to handle with common actions
|
||||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
@@ -64,6 +75,9 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
case 'refresh-metadata':
|
case 'refresh-metadata':
|
||||||
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
|
case 'enrich-hf-llm':
|
||||||
|
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
break;
|
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) {
|
sendLoraToWorkflow(replaceMode) {
|
||||||
const card = this.currentCard;
|
const card = this.currentCard;
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
|
|||||||
@@ -112,8 +112,7 @@ export const ModelContextMenuMixin = {
|
|||||||
const prefixMap = {
|
const prefixMap = {
|
||||||
lora: 'loras',
|
lora: 'loras',
|
||||||
checkpoint: 'checkpoints',
|
checkpoint: 'checkpoints',
|
||||||
embedding: 'embeddings',
|
embedding: 'embeddings'
|
||||||
other: 'other'
|
|
||||||
};
|
};
|
||||||
return prefixMap[this.modelType] || 'loras';
|
return prefixMap[this.modelType] || 'loras';
|
||||||
},
|
},
|
||||||
@@ -279,79 +278,6 @@ export const ModelContextMenuMixin = {
|
|||||||
setTimeout(() => urlInput.focus(), 50);
|
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) {
|
parseModelId(value) {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -446,10 +372,7 @@ export const ModelContextMenuMixin = {
|
|||||||
this.downloadExampleImages(true);
|
this.downloadExampleImages(true);
|
||||||
return true;
|
return true;
|
||||||
case 'civitai':
|
case 'civitai':
|
||||||
// Gate on actual CivitAI data (not the `from_civitai` flag) so
|
if (this.currentCard.dataset.from_civitai === 'true') {
|
||||||
// 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.querySelector('.fa-globe')) {
|
if (this.currentCard.querySelector('.fa-globe')) {
|
||||||
this.currentCard.querySelector('.fa-globe').click();
|
this.currentCard.querySelector('.fa-globe').click();
|
||||||
} else {
|
} else {
|
||||||
@@ -465,9 +388,6 @@ export const ModelContextMenuMixin = {
|
|||||||
case 'link-hf':
|
case 'link-hf':
|
||||||
this.showLinkHfModal();
|
this.showLinkHfModal();
|
||||||
return true;
|
return true;
|
||||||
case 'enrich-hf-llm':
|
|
||||||
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
|
||||||
return true;
|
|
||||||
case 'set-nsfw':
|
case 'set-nsfw':
|
||||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||||
return true;
|
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 { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.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 {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -96,6 +93,10 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Download missing LoRAs
|
// Download missing LoRAs
|
||||||
this.downloadMissingLoRAs(recipeId);
|
this.downloadMissingLoRAs(recipeId);
|
||||||
break;
|
break;
|
||||||
|
case 'repair':
|
||||||
|
// Repair recipe metadata
|
||||||
|
this.repairRecipe(recipeId);
|
||||||
|
break;
|
||||||
case 'rematch':
|
case 'rematch':
|
||||||
// Rematch recipe resources to local models
|
// Rematch recipe resources to local models
|
||||||
this.rematchRecipe(recipeId);
|
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) {
|
async rematchRecipe(recipeId) {
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
|
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
|
// Capture before any await: the menu's click handler nulls currentCard
|
||||||
const filePath = this.currentCard?.dataset?.filepath;
|
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 {
|
try {
|
||||||
showToast('Rematching recipe to local models...', {}, 'info');
|
showToast('Rematching recipe to local models...', {}, 'info');
|
||||||
|
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
|
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
|
||||||
method: 'POST',
|
method: 'POST'
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ relaxed: !!relaxed }),
|
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const matchedEntries = result.matched_entries || result.rematched || 0;
|
const matchedEntries = result.matched_entries || result.rematched || 0;
|
||||||
const failures = result.errors || 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) {
|
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}`);
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
if (detailResponse.ok) {
|
if (detailResponse.ok) {
|
||||||
const updatedRecipe = await detailResponse.json();
|
const updatedRecipe = await detailResponse.json();
|
||||||
@@ -342,22 +371,16 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else if (result.unresolved_entries > 0) {
|
||||||
|
// Entries existed but have no local model — expected for
|
||||||
if (isNoop) {
|
// models deleted from Civitai; informational, not an error.
|
||||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
showToast(
|
||||||
|
'toast.recipes.rematchUnmatched',
|
||||||
|
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
||||||
|
'info'
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
showRematchSummary({
|
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Rematch failed');
|
throw new Error(result.error || 'Rematch failed');
|
||||||
@@ -374,24 +397,6 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
return;
|
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...');
|
state.loadingManager.showSimpleLoading('Re-importing recipe from source...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -414,34 +419,6 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error');
|
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
|
// Mix in shared methods from ModelContextMenuMixin
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ export { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
export { OtherContextMenu } from './OtherContextMenu.js';
|
|
||||||
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ import { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
import { OtherContextMenu } from './OtherContextMenu.js';
|
|
||||||
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
|
|
||||||
// Factory method to create page-specific context menu instances
|
// Factory method to create page-specific context menu instances
|
||||||
@@ -24,8 +22,6 @@ export function createPageContextMenu(pageType) {
|
|||||||
return new CheckpointContextMenu();
|
return new CheckpointContextMenu();
|
||||||
case 'embeddings':
|
case 'embeddings':
|
||||||
return new EmbeddingContextMenu();
|
return new EmbeddingContextMenu();
|
||||||
case 'other':
|
|
||||||
return new OtherContextMenu();
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export class HeaderManager {
|
|||||||
if (path.includes('/loras/recipes')) return 'recipes';
|
if (path.includes('/loras/recipes')) return 'recipes';
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
if (path.includes('/other')) return 'other';
|
|
||||||
if (path.includes('/statistics')) return 'statistics';
|
if (path.includes('/statistics')) return 'statistics';
|
||||||
if (path.includes('/loras')) return 'loras';
|
if (path.includes('/loras')) return 'loras';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
@@ -50,18 +49,6 @@ export class HeaderManager {
|
|||||||
initializeCommonElements() {
|
initializeCommonElements() {
|
||||||
this.initializeThemePopover();
|
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');
|
const settingsToggle = document.querySelector('.settings-toggle');
|
||||||
if (settingsToggle) {
|
if (settingsToggle) {
|
||||||
settingsToggle.addEventListener('click', () => {
|
settingsToggle.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Modal Component
|
// 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 { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||||
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -1078,9 +1078,8 @@ class RecipeModal {
|
|||||||
|
|
||||||
// Mirror the checkpoint "broken" rule: deleted, an
|
// Mirror the checkpoint "broken" rule: deleted, an
|
||||||
// unresolvable hash, or a name-only remnant with no CivitAI
|
// unresolvable hash, or a name-only remnant with no CivitAI
|
||||||
// identifiers at all cannot be fixed by downloading, so no
|
// identifiers at all cannot be fixed by downloading —
|
||||||
// download button is offered. Reconnect is always available
|
// reconnecting a local LoRA is the only remediation.
|
||||||
// for missing entries (see renderLoraItemActions).
|
|
||||||
const needsReconnect = !existsLocally
|
const needsReconnect = !existsLocally
|
||||||
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
||||||
|
|
||||||
@@ -1181,7 +1180,7 @@ class RecipeModal {
|
|||||||
</div>
|
</div>
|
||||||
${actionsRow}
|
${actionsRow}
|
||||||
</div>
|
</div>
|
||||||
${!existsLocally ? `
|
${needsReconnect ? `
|
||||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||||
<div class="reconnect-instructions">
|
<div class="reconnect-instructions">
|
||||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
<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.
|
* the model cannot be resolved — never for transient transport errors.
|
||||||
*/
|
*/
|
||||||
_isUnresolvableDownloadError(message) {
|
_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) {
|
getResourceCivitaiUrl(resource) {
|
||||||
@@ -2874,14 +2877,12 @@ class RecipeModal {
|
|||||||
|
|
||||||
canDownloadLora(lora) {
|
canDownloadLora(lora) {
|
||||||
if (!lora) return false;
|
if (!lora) return false;
|
||||||
|
const modelId = lora.modelId || lora.modelID || lora.model_id;
|
||||||
const versionId = lora.id || lora.modelVersionId;
|
const versionId = lora.id || lora.modelVersionId;
|
||||||
// A bare CivitAI version id is enough: it uniquely pins the exact
|
// Direct download needs both identifiers; a hash alone is enough
|
||||||
// file, and downloadRecipeLora resolves the owning model id from the
|
// because downloadRecipeLora resolves it to a version on demand —
|
||||||
// version on demand (the same fallback the bulk "download missing"
|
// the same fallback the bulk "download missing" flow uses.
|
||||||
// flow uses). A hash alone is likewise sufficient. A model id without
|
return !!((modelId && versionId) || lora.hash);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCivitaiLink(url) {
|
renderCivitaiLink(url) {
|
||||||
@@ -2912,9 +2913,19 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const controls = [];
|
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
|
// 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 downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||||
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||||
controls.push(`
|
controls.push(`
|
||||||
@@ -2925,18 +2936,6 @@ class RecipeModal {
|
|||||||
</button>
|
</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>`;
|
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
|
||||||
}
|
}
|
||||||
@@ -2992,9 +2991,6 @@ class RecipeModal {
|
|||||||
* Resolve the Civitai model/version identifiers needed for download.
|
* Resolve the Civitai model/version identifiers needed for download.
|
||||||
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
|
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
|
||||||
* it through the same endpoint the bulk "download missing" flow uses.
|
* 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) {
|
async resolveLoraDownloadIdentifiers(lora) {
|
||||||
let modelId = lora.modelId || lora.modelID || lora.model_id;
|
let modelId = lora.modelId || lora.modelID || lora.model_id;
|
||||||
@@ -3005,41 +3001,21 @@ class RecipeModal {
|
|||||||
return { modelId, versionId, versionName };
|
return { modelId, versionId, versionName };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
|
if (!lora.hash) {
|
||||||
// model/version through the same endpoint the bulk "download
|
return null;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version-only entries (page-imported recipes whose CivitAI versions
|
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
|
||||||
// expose no sha256): the version id still pins the exact file, so
|
const versionInfo = await response.json();
|
||||||
// resolve the owning model id from the version endpoint on demand.
|
if (versionInfo?.error) {
|
||||||
if (versionId) {
|
return null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.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 {
|
export class SidebarManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pageControls = null;
|
this.pageControls = null;
|
||||||
@@ -1133,7 +1126,6 @@ export class SidebarManager {
|
|||||||
recipes: 'Recipes',
|
recipes: 'Recipes',
|
||||||
checkpoints: 'Checkpoints',
|
checkpoints: 'Checkpoints',
|
||||||
embeddings: 'Embeddings',
|
embeddings: 'Embeddings',
|
||||||
other: 'Other Models',
|
|
||||||
};
|
};
|
||||||
return names[this.pageType] || this.pageType;
|
return names[this.pageType] || this.pageType;
|
||||||
}
|
}
|
||||||
@@ -1792,10 +1784,7 @@ export class SidebarManager {
|
|||||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||||
this.isDisabledByPage = getStorageItem(
|
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
||||||
`${this.pageType}_sidebarDisabled`,
|
|
||||||
SIDEBAR_DEFAULT_HIDDEN_PAGES.has(this.pageType)
|
|
||||||
);
|
|
||||||
|
|
||||||
this.expandedNodes = new Set(expandedPaths);
|
this.expandedNodes = new Set(expandedPaths);
|
||||||
this.displayMode = displayMode;
|
this.displayMode = displayMode;
|
||||||
@@ -1815,7 +1804,7 @@ export class SidebarManager {
|
|||||||
_migrateOldSettings() {
|
_migrateOldSettings() {
|
||||||
if (getStorageItem('_sidebar_migration_done')) return;
|
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
|
// 1. Migrate global hide setting to per-page
|
||||||
if (state?.global?.settings?.show_folder_sidebar === false) {
|
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 { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
||||||
import { OtherControls } from './OtherControls.js';
|
|
||||||
|
|
||||||
// Export the classes
|
// 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
|
* 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
|
* @returns {PageControls} - The appropriate controls instance
|
||||||
*/
|
*/
|
||||||
export function createPageControls(pageType) {
|
export function createPageControls(pageType) {
|
||||||
@@ -20,10 +19,8 @@ export function createPageControls(pageType) {
|
|||||||
return new CheckpointsControls();
|
return new CheckpointsControls();
|
||||||
} else if (pageType === 'embeddings') {
|
} else if (pageType === 'embeddings') {
|
||||||
return new EmbeddingsControls();
|
return new EmbeddingsControls();
|
||||||
} else if (pageType === 'other') {
|
|
||||||
return new OtherControls();
|
|
||||||
} else {
|
} else {
|
||||||
console.error(`Unknown page type: ${pageType}`);
|
console.error(`Unknown page type: ${pageType}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,8 +58,6 @@ class InitializationManager {
|
|||||||
this.pageType = 'recipes';
|
this.pageType = 'recipes';
|
||||||
} else if (path.includes('/checkpoints')) {
|
} else if (path.includes('/checkpoints')) {
|
||||||
this.pageType = 'checkpoints';
|
this.pageType = 'checkpoints';
|
||||||
} else if (path.includes('/other')) {
|
|
||||||
this.pageType = 'other';
|
|
||||||
} else if (path.includes('/loras')) {
|
} else if (path.includes('/loras')) {
|
||||||
this.pageType = 'loras';
|
this.pageType = 'loras';
|
||||||
} else if (path.includes('/embeddings')) {
|
} else if (path.includes('/embeddings')) {
|
||||||
@@ -223,7 +221,6 @@ class InitializationManager {
|
|||||||
'lora': 'loras',
|
'lora': 'loras',
|
||||||
'checkpoint': 'checkpoints',
|
'checkpoint': 'checkpoints',
|
||||||
'embedding': 'embeddings',
|
'embedding': 'embeddings',
|
||||||
'other': 'other',
|
|
||||||
'recipe': 'recipes'
|
'recipe': 'recipes'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
import { hasCivitaiSource } from './utils.js';
|
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.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';
|
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')) {
|
if (event.target.closest('.fa-globe')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
// CivitAI wins when the model actually has CivitAI data; otherwise fall
|
if (card.dataset.from_civitai === 'true') {
|
||||||
// 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') {
|
|
||||||
openCivitai(card.dataset.filepath);
|
openCivitai(card.dataset.filepath);
|
||||||
} else if (card.dataset.hf_url) {
|
} else if (card.dataset.hf_url) {
|
||||||
openHuggingFace(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 embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
||||||
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
||||||
copyToClipboard(embeddingCode, message);
|
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.modified = model.modified;
|
||||||
card.dataset.file_size = model.file_size;
|
card.dataset.file_size = model.file_size;
|
||||||
card.dataset.from_civitai = model.from_civitai;
|
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.usage_count = String(model.usage_count);
|
||||||
card.dataset.notes = model.notes || '';
|
card.dataset.notes = model.notes || '';
|
||||||
card.dataset.base_model = model.base_model || 'Unknown';
|
card.dataset.base_model = model.base_model || 'Unknown';
|
||||||
@@ -607,13 +595,12 @@ export function createModelCard(model, modelType) {
|
|||||||
const favoriteTitle = isFavorite ?
|
const favoriteTitle = isFavorite ?
|
||||||
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||||
const hasCivitai = hasCivitaiSource(model.civitai);
|
const globeTitle = model.from_civitai ?
|
||||||
const globeTitle = hasCivitai ?
|
|
||||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||||
model.hf_url ?
|
model.hf_url ?
|
||||||
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
||||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
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 sendTitle;
|
||||||
let copyTitle;
|
let copyTitle;
|
||||||
if (modelType === MODEL_TYPES.LORA) {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from './ModelMetadata.js';
|
} from './ModelMetadata.js';
|
||||||
import { setupTagEditMode } from './ModelTags.js';
|
import { setupTagEditMode } from './ModelTags.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.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 { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||||
@@ -389,11 +389,7 @@ export async function showModelModal(model, modelType) {
|
|||||||
const licenseIcons = useNewIcons
|
const licenseIcons = useNewIcons
|
||||||
? renderNewLicenseIcons(modelWithFullData)
|
? renderNewLicenseIcons(modelWithFullData)
|
||||||
: renderLicenseIcons(modelWithFullData);
|
: renderLicenseIcons(modelWithFullData);
|
||||||
// Gate the CivitAI link on actual CivitAI data, not the `from_civitai`
|
const viewOnCivitaiAction = modelWithFullData.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 ? `
|
|
||||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
<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')}
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||||
</div>`.trim() : '';
|
</div>`.trim() : '';
|
||||||
|
|||||||
@@ -1390,22 +1390,8 @@ export function initVersionsTab({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const client = ensureClient();
|
const client = ensureClient();
|
||||||
// On the checkpoints page a diffusion model lives under the unet
|
const rootsData = await client.fetchModelRoots();
|
||||||
// roots, so both root sets are needed to locate the current file.
|
const roots = rootsData?.roots;
|
||||||
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;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(roots) || roots.length === 0) {
|
if (!Array.isArray(roots) || roots.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,24 +36,6 @@ export function formatFileSize(bytes) {
|
|||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
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
|
* Render compact tags
|
||||||
* @param {Array} tags - Array of 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 { settingsManager } from './managers/SettingsManager.js';
|
||||||
import { moveManager } from './managers/MoveManager.js';
|
import { moveManager } from './managers/MoveManager.js';
|
||||||
import { bulkManager } from './managers/BulkManager.js';
|
import { bulkManager } from './managers/BulkManager.js';
|
||||||
import { rematchModalManager } from './managers/RematchModalManager.js';
|
|
||||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from './managers/HelpManager.js';
|
import { helpManager } from './managers/HelpManager.js';
|
||||||
import { doctorManager } from './managers/DoctorManager.js';
|
import { doctorManager } from './managers/DoctorManager.js';
|
||||||
@@ -69,7 +68,6 @@ export class AppCore {
|
|||||||
window.doctorManager = doctorManager;
|
window.doctorManager = doctorManager;
|
||||||
window.moveManager = moveManager;
|
window.moveManager = moveManager;
|
||||||
window.bulkManager = bulkManager;
|
window.bulkManager = bulkManager;
|
||||||
window.rematchModalManager = rematchModalManager;
|
|
||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
@@ -116,7 +114,7 @@ export class AppCore {
|
|||||||
initializePageFeatures() {
|
initializePageFeatures() {
|
||||||
const pageType = this.getPageType();
|
const pageType = this.getPageType();
|
||||||
|
|
||||||
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
|
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
||||||
this.initializeContextMenus(pageType);
|
this.initializeContextMenus(pageType);
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import {
|
|||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { enableOtherModels, openOtherModelsSettings } from '../utils/otherModels.js';
|
|
||||||
|
|
||||||
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
||||||
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
|
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_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_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
|
||||||
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
||||||
@@ -82,7 +80,6 @@ class BannerService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.prepareCommunitySupportBanner();
|
this.prepareCommunitySupportBanner();
|
||||||
this.prepareOtherModelsBanner();
|
|
||||||
|
|
||||||
await this.showActiveBanners();
|
await this.showActiveBanners();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@@ -427,13 +424,12 @@ class BannerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current page type from the URL
|
* 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() {
|
getCurrentPageType() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
if (path.includes('/other')) return 'other';
|
|
||||||
if (path.includes('/recipes')) return 'recipes';
|
if (path.includes('/recipes')) return 'recipes';
|
||||||
return 'loras';
|
return 'loras';
|
||||||
}
|
}
|
||||||
@@ -447,8 +443,7 @@ class BannerService {
|
|||||||
const endpoints = {
|
const endpoints = {
|
||||||
'loras': '/api/lm/loras/reload?rebuild=true',
|
'loras': '/api/lm/loras/reload?rebuild=true',
|
||||||
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
|
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
|
||||||
'embeddings': '/api/lm/embeddings/reload?rebuild=true',
|
'embeddings': '/api/lm/embeddings/reload?rebuild=true'
|
||||||
'other': '/api/lm/other/reload?rebuild=true'
|
|
||||||
};
|
};
|
||||||
return endpoints[pageType] || endpoints['loras'];
|
return endpoints[pageType] || endpoints['loras'];
|
||||||
}
|
}
|
||||||
@@ -544,99 +539,6 @@ class BannerService {
|
|||||||
this.updateContainerVisibility();
|
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() {
|
initializeCommunitySupportState() {
|
||||||
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
|
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ export class BatchImportManager {
|
|||||||
this.results = null;
|
this.results = null;
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
this.isImporting = false;
|
this.isImporting = false;
|
||||||
this.currentParentPath = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -719,10 +718,9 @@ export class BatchImportManager {
|
|||||||
browser.style.display = isVisible ? 'none' : 'block';
|
browser.style.display = isVisible ? 'none' : 'block';
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
// Load initial directory when opening. An empty path lets the
|
// Load initial directory when opening
|
||||||
// server pick its default (user home); "/" would be POSIX-only.
|
|
||||||
const currentPath = document.getElementById('batchDirectoryInput').value;
|
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 directoryCount = document.getElementById('batchDirectoryCount');
|
||||||
const imageCount = document.getElementById('batchImageCount');
|
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) {
|
if (currentPathEl) {
|
||||||
currentPathEl.textContent = data.current_path;
|
currentPathEl.textContent = data.current_path;
|
||||||
}
|
}
|
||||||
@@ -817,9 +811,11 @@ export class BatchImportManager {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
// The parent entry uses the server-provided parent_path (or the
|
if (isParent) {
|
||||||
// Windows drive-list token) directly — both are plain load targets.
|
this.navigateToParentDirectory();
|
||||||
this.loadDirectory(path);
|
} else {
|
||||||
|
this.loadDirectory(path);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -843,12 +839,15 @@ export class BatchImportManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to parent directory using the path reported by the server.
|
* Navigate to parent directory
|
||||||
* Deriving it client-side by splitting on "/" breaks Windows paths.
|
|
||||||
*/
|
*/
|
||||||
navigateToParentDirectory() {
|
navigateToParentDirectory() {
|
||||||
if (this.currentParentPath) {
|
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||||
this.loadDirectory(this.currentParentPath);
|
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() {
|
selectCurrentDirectory() {
|
||||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||||
|
|
||||||
if (!currentPath) {
|
if (currentPath && directoryInput) {
|
||||||
// Virtual levels (e.g. the Windows drive list) have no path.
|
|
||||||
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (directoryInput) {
|
|
||||||
directoryInput.value = currentPath;
|
directoryInput.value = currentPath;
|
||||||
this.toggleDirectoryBrowser(); // Close browser
|
this.toggleDirectoryBrowser(); // Close browser
|
||||||
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
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 { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.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 { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.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 { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
import { eventManager } from '../utils/EventManager.js';
|
import { eventManager } from '../utils/EventManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
|
|
||||||
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
|
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
|
||||||
|
|
||||||
export class BulkManager {
|
export class BulkManager {
|
||||||
@@ -93,20 +90,6 @@ export class BulkManager {
|
|||||||
setFavorite: true,
|
setFavorite: true,
|
||||||
unfavorite: 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: {
|
recipes: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
sendToWorkflow: false,
|
sendToWorkflow: false,
|
||||||
@@ -120,6 +103,7 @@ export class BulkManager {
|
|||||||
skipMetadataRefresh: false,
|
skipMetadataRefresh: false,
|
||||||
setFavorite: true,
|
setFavorite: true,
|
||||||
unfavorite: true,
|
unfavorite: true,
|
||||||
|
repairMetadata: true,
|
||||||
reimportMetadata: true,
|
reimportMetadata: true,
|
||||||
rematchMetadata: true
|
rematchMetadata: true
|
||||||
}
|
}
|
||||||
@@ -874,74 +858,17 @@ export class BulkManager {
|
|||||||
`Re-importing recipe 1/${total}...`
|
`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 {
|
try {
|
||||||
const processedBeforeNative = completed + failed;
|
for (let i = 0; i < filePaths.length; i++) {
|
||||||
for (let i = 0; i < nativeFilePaths.length; i++) {
|
const filePath = filePaths[i];
|
||||||
const filePath = nativeFilePaths[i];
|
|
||||||
const recipeItem = recipeMap.get(filePath);
|
const recipeItem = recipeMap.get(filePath);
|
||||||
const recipeId = recipeItem?.id;
|
const recipeId = recipeItem?.id;
|
||||||
const recipeName = recipeItem?.title || recipeId || 'Unknown';
|
const recipeName = recipeItem?.title || recipeId || 'Unknown';
|
||||||
const processed = processedBeforeNative + i;
|
|
||||||
|
|
||||||
progressUI.updateProgress(
|
progressUI.updateProgress(
|
||||||
Math.floor((processed / total) * 100),
|
Math.floor((i / total) * 100),
|
||||||
recipeName,
|
recipeName,
|
||||||
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
|
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!recipeId) {
|
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() {
|
async rematchSelectedRecipes() {
|
||||||
if (state.selectedModels.size === 0) {
|
if (state.selectedModels.size === 0) {
|
||||||
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
|
||||||
@@ -994,15 +991,6 @@ export class BulkManager {
|
|||||||
return;
|
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 {
|
try {
|
||||||
const apiClient = this.getActiveApiClient();
|
const apiClient = this.getActiveApiClient();
|
||||||
const filePaths = Array.from(state.selectedModels);
|
const filePaths = Array.from(state.selectedModels);
|
||||||
@@ -1014,7 +1002,7 @@ export class BulkManager {
|
|||||||
|
|
||||||
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
|
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) {
|
if (result.success) {
|
||||||
const total = result.total || filePaths.length;
|
const total = result.total || filePaths.length;
|
||||||
@@ -1040,29 +1028,38 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete no-op (nothing matched, nothing unresolved, no
|
if (matchedEntries > 0) {
|
||||||
// errors) keeps the lightweight toast; anything else opens
|
const hasFailures = failures > 0;
|
||||||
// the post-run summary modal.
|
const toastKey = hasFailures
|
||||||
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
? 'toast.recipes.rematchCompleteErrors'
|
||||||
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
: 'toast.recipes.rematchComplete';
|
||||||
if (isNoop) {
|
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(
|
showToast(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
{ total },
|
{ total },
|
||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
showRematchSummary({
|
|
||||||
scope: 'bulk',
|
|
||||||
total,
|
|
||||||
matchedRecipes,
|
|
||||||
matchedEntries,
|
|
||||||
unresolvedRecipes,
|
|
||||||
unresolvedEntries,
|
|
||||||
skipped,
|
|
||||||
errors: failures,
|
|
||||||
l4Matches,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.bulkMode) this.toggleBulkMode();
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { extractRecipeId } from '../api/recipeApi.js';
|
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
|
||||||
@@ -15,7 +13,6 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||||
this.pendingLoras = [];
|
this.pendingLoras = [];
|
||||||
this.pendingRecipes = [];
|
this.pendingRecipes = [];
|
||||||
this.pendingMissingByRecipe = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,7 +136,6 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
// Execute download
|
// Execute download
|
||||||
await this.executeDownload(this.pendingLoras);
|
await this.executeDownload(this.pendingLoras);
|
||||||
this.pendingLoras = [];
|
this.pendingLoras = [];
|
||||||
this.pendingMissingByRecipe = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,9 +153,6 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
// Collect missing LoRAs with deduplication
|
// Collect missing LoRAs with deduplication
|
||||||
const stats = this.collectMissingLoras(selectedRecipes);
|
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) {
|
if (stats.uniqueCount === 0) {
|
||||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||||
@@ -203,7 +196,6 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
let markedInvalidCount = 0;
|
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -312,12 +304,6 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||||
failedDownloads++;
|
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 {
|
} else {
|
||||||
completedDownloads++;
|
completedDownloads++;
|
||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
@@ -326,9 +312,6 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
if (isUnresolvableDownloadError(error?.message)) {
|
|
||||||
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -352,16 +335,9 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}, 'warning');
|
}, '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)
|
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
|
const { extractRecipeId } = await import('../api/recipeApi.js');
|
||||||
for (const recipe of this.pendingRecipes) {
|
for (const recipe of this.pendingRecipes) {
|
||||||
const recipeId = extractRecipeId(recipe.file_path);
|
const recipeId = extractRecipeId(recipe.file_path);
|
||||||
if (!recipeId) continue;
|
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
|
* Get LoRA root directory from API
|
||||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
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 { state } from '../state/index.js';
|
||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { DOWNLOAD_ENDPOINTS } from '../api/apiConfig.js';
|
|
||||||
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.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 { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
|
||||||
import { formatFileSize } from '../utils/formatters.js';
|
import { formatFileSize } from '../utils/formatters.js';
|
||||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||||
import { openOtherModelsSettings } from '../utils/otherModels.js';
|
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -492,9 +489,8 @@ export class DownloadManager {
|
|||||||
return { type: 'civitai' };
|
return { type: 'civitai' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hugging Face resolve/blob URL → direct file
|
// Hugging Face resolve 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\/([^/\s]+)\/(.+)/i);
|
||||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
|
|
||||||
if (hfResolveMatch) {
|
if (hfResolveMatch) {
|
||||||
return {
|
return {
|
||||||
type: 'hf-resolve',
|
type: 'hf-resolve',
|
||||||
@@ -957,18 +953,17 @@ export class DownloadManager {
|
|||||||
async proceedToLocationContent() {
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
const _isDiffusionModel = this.selectedFile
|
||||||
this._otherSubType = await this._resolveOtherSubType();
|
? (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;
|
let rootsData;
|
||||||
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||||
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||||
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
|
|
||||||
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
|
|
||||||
} else {
|
} 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();
|
rootsData = await this.apiClient.fetchModelRoots();
|
||||||
}
|
}
|
||||||
const modelRoot = document.getElementById('modelRoot');
|
const modelRoot = document.getElementById('modelRoot');
|
||||||
@@ -976,29 +971,19 @@ export class DownloadManager {
|
|||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
let defaultRoot;
|
const singularType = this._isDiffusionModel
|
||||||
let subtypeDisplay;
|
? 'unet'
|
||||||
if (this.apiClient.modelType === 'other') {
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const otherDefaultRoots = state.global.settings.default_other_roots || {};
|
const defaultRootKey = `default_${singularType}_root`;
|
||||||
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
|
const defaultRoot = state.global.settings[defaultRootKey];
|
||||||
subtypeDisplay = this._otherSubType
|
console.log(`Default root for ${singularType}:`, defaultRoot);
|
||||||
? (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);
|
|
||||||
console.log('Available roots:', rootsData.roots);
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
console.log(`Setting default root: ${defaultRoot}`);
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||||
document.getElementById('modelRootLabel').textContent =
|
document.getElementById('modelRootLabel').textContent =
|
||||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
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() {
|
loadDefaultPathSetting() {
|
||||||
const modelType = this.apiClient.modelType;
|
const modelType = this.apiClient.modelType;
|
||||||
const storageKey = `use_default_path_${modelType}`;
|
const storageKey = `use_default_path_${modelType}`;
|
||||||
@@ -2520,14 +2402,9 @@ export class DownloadManager {
|
|||||||
const singularType = this._isDiffusionModel
|
const singularType = this._isDiffusionModel
|
||||||
? 'unet'
|
? 'unet'
|
||||||
: this.apiClient.modelType.replace(/s$/, '');
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const templates = state.global?.settings?.download_path_templates;
|
const templates = state.global.settings.download_path_templates;
|
||||||
const template = templates?.[singularType];
|
const template = templates[singularType];
|
||||||
// An empty or absent template means a flat layout: keep the
|
fullPath += `/${template}`;
|
||||||
// root as-is instead of appending "/undefined" or a
|
|
||||||
// dangling slash.
|
|
||||||
if (template) {
|
|
||||||
fullPath += `/${template}`;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch template:', error);
|
console.error('Failed to fetch template:', error);
|
||||||
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
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