mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d3f82976f | |||
| 91b2735dad | |||
| 3112869a21 | |||
| aa630bf85b | |||
| e0052cd237 | |||
| 3cdc5ba7a2 | |||
| 04485e384f | |||
| a03dc4002f | |||
| cc9d3bff42 | |||
| 2672b3331b | |||
| 4963bf2b2e | |||
| 51cad6f852 | |||
| 1b5cbbbaa0 | |||
| e747946f7a | |||
| 53fa22f39c | |||
| 82b34097fb | |||
| a7995db009 | |||
| 5ae4aef30e | |||
| 08023f0cd9 | |||
| 6e2185c182 | |||
| 41302e75ba | |||
| a17399d667 | |||
| e2d85a0a21 | |||
| 303833bbae | |||
| f86b7b55d6 | |||
| 782bb53784 | |||
| 139231e225 | |||
| 121d8d5cea | |||
| ec147bd677 | |||
| 93fc28b499 | |||
| 7afed1a14b | |||
| e6f5142e48 | |||
| 87f05fb66c | |||
| cf64e5baa8 |
@@ -1,146 +0,0 @@
|
|||||||
---
|
|
||||||
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`).
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
# 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"
|
|
||||||
```
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#!/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)
|
|
||||||
@@ -170,6 +170,10 @@ The system runs in two modes:
|
|||||||
- 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
|
||||||
|
|
||||||
@@ -215,6 +219,26 @@ 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`)
|
||||||
|
|||||||
+427
-395
File diff suppressed because it is too large
Load Diff
@@ -137,7 +137,7 @@ and must be normalized. `en` = keep the English word as-is.
|
|||||||
|
|
||||||
| Term | Use | Fix |
|
| Term | Use | Fix |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| recipe | Rezept/Rezepte | 5 leftover English "Recipe" keys → Rezept (e.g. `globalContextMenu.repairRecipes.label`, `toast.recipes.recipeSaved`) |
|
| recipe | Rezept/Rezepte | leftover English "Recipe" keys → Rezept (e.g. `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" |
|
||||||
@@ -193,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 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
| recipe counter | 件 or 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
|
||||||
|
|
||||||
### ko
|
### ko
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# 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 项全部通过。
|
||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"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",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"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",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"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",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"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",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"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",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"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",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"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",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"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",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"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",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"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",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"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",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"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",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"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",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"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",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"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",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"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",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"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",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"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",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"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",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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 :",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"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",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "קיבוץ לפי מודל",
|
"groupByModel": "קיבוץ לפי מודל",
|
||||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||||
|
"stickyControls": "השארת סרגל הפעולות גלוי",
|
||||||
|
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
|
||||||
"displayDensity": "צפיפות תצוגה",
|
"displayDensity": "צפיפות תצוגה",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "ברירת מחדל",
|
"default": "ברירת מחדל",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-נתונים",
|
"refreshAll": "רענן את כל המטא-נתונים",
|
||||||
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
|
|
||||||
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"replacePreview": "החלף תצוגה מקדימה",
|
"replacePreview": "החלף תצוגה מקדימה",
|
||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
"repairMetadata": "תיקון מטא-נתונים",
|
|
||||||
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "מתקן מטא-נתונים של מתכון...",
|
|
||||||
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
|
|
||||||
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
|
||||||
"failed": "תיקון המתכון נכשל: {message}",
|
|
||||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "מייבא מתכון מחדש מהמקור...",
|
"starting": "מייבא מתכון מחדש מהמקור...",
|
||||||
"success": "המתכון יובא מחדש בהצלחה",
|
"success": "המתכון יובא מחדש בהצלחה",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||||
"created": "המתכון נוצר בהצלחה",
|
"created": "המתכון נוצר בהצלחה",
|
||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
|
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
|
||||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "モデルでグループ化",
|
"groupByModel": "モデルでグループ化",
|
||||||
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||||
|
"stickyControls": "アクションバーを常に表示",
|
||||||
|
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
|
||||||
"displayDensity": "表示密度",
|
"displayDensity": "表示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
|
||||||
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"replacePreview": "プレビューを置換",
|
"replacePreview": "プレビューを置換",
|
||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
"repairMetadata": "メタデータを修復",
|
|
||||||
"rematchMetadata": "ローカルモデルに再マッチング",
|
"rematchMetadata": "ローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "レシピのメタデータを修復中...",
|
|
||||||
"success": "レシピのメタデータが正常に修復されました",
|
|
||||||
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
|
|
||||||
"failed": "レシピの修復に失敗しました: {message}",
|
|
||||||
"missingId": "レシピを修復できません: レシピIDがありません"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "ソースからレシピを再インポート中...",
|
"starting": "ソースからレシピを再インポート中...",
|
||||||
"success": "レシピの再インポートが完了しました",
|
"success": "レシピの再インポートが完了しました",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||||
"created": "レシピを作成しました",
|
"created": "レシピを作成しました",
|
||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
|
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
|
||||||
"noPreviousRecipe": "前のレシピがありません",
|
"noPreviousRecipe": "前のレシピがありません",
|
||||||
"noNextRecipe": "次のレシピがありません",
|
"noNextRecipe": "次のレシピがありません",
|
||||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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": "一部のレシピの再インポートに失敗しました",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "모델별 그룹화",
|
"groupByModel": "모델별 그룹화",
|
||||||
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||||
|
"stickyControls": "작업 표시줄 항상 표시",
|
||||||
|
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
|
||||||
"displayDensity": "표시 밀도",
|
"displayDensity": "표시 밀도",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "기본",
|
"default": "기본",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
|
||||||
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"replacePreview": "미리보기 교체",
|
"replacePreview": "미리보기 교체",
|
||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
"repairMetadata": "메타데이터 복구",
|
|
||||||
"rematchMetadata": "로컬 모델에 다시 매칭",
|
"rematchMetadata": "로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "레시피 메타데이터 복구 중...",
|
|
||||||
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
|
|
||||||
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
|
|
||||||
"failed": "레시피 복구 실패: {message}",
|
|
||||||
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
"starting": "소스에서 레시피를 다시 가져오는 중...",
|
||||||
"success": "레시피를 다시 가져왔습니다",
|
"success": "레시피를 다시 가져왔습니다",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||||
"created": "레시피가 생성되었습니다",
|
"created": "레시피가 생성되었습니다",
|
||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
|
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
|
||||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||||
"noNextRecipe": "다음 레시피가 없습니다",
|
"noNextRecipe": "다음 레시피가 없습니다",
|
||||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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": "일부 레시피를 다시 가져오지 못했습니다",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Группировать по модели",
|
"groupByModel": "Группировать по модели",
|
||||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
||||||
|
"stickyControls": "Держать панель действий видимой",
|
||||||
|
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
|
||||||
"displayDensity": "Плотность отображения",
|
"displayDensity": "Плотность отображения",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"setContentRating": "Установить рейтинг контента для всех",
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
|
||||||
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"replacePreview": "Заменить превью",
|
"replacePreview": "Заменить превью",
|
||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
"repairMetadata": "Восстановить метаданные",
|
|
||||||
"rematchMetadata": "Сопоставить с локальными моделями",
|
"rematchMetadata": "Сопоставить с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "Восстановление метаданных рецепта...",
|
|
||||||
"success": "Метаданные рецепта успешно восстановлены",
|
|
||||||
"skipped": "Рецепт уже последней версии, восстановление не требуется",
|
|
||||||
"failed": "Не удалось восстановить рецепт: {message}",
|
|
||||||
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "Переимпорт рецепта из источника...",
|
"starting": "Переимпорт рецепта из источника...",
|
||||||
"success": "Рецепт успешно переимпортирован",
|
"success": "Рецепт успешно переимпортирован",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||||
"created": "Рецепт успешно создан",
|
"created": "Рецепт успешно создан",
|
||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
|
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
|
||||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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": "Не удалось переимпортировать некоторые рецепты",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "按模型分组",
|
"groupByModel": "按模型分组",
|
||||||
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||||
|
"stickyControls": "保持操作栏可见",
|
||||||
|
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
|
||||||
"displayDensity": "显示密度",
|
"displayDensity": "显示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"setContentRating": "为所选中设置内容评级",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
"repairMetadata": "修复所选中元数据",
|
|
||||||
"rematchMetadata": "将所选中重新匹配到本地模型",
|
"rematchMetadata": "将所选中重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"replacePreview": "替换预览",
|
"replacePreview": "替换预览",
|
||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
"repairMetadata": "修复元数据",
|
|
||||||
"rematchMetadata": "重新匹配到本地模型",
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
"getInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
"prepareError": "准备下载 LoRA 时出错:{message}"
|
"prepareError": "准备下载 LoRA 时出错:{message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "正在修复配方元数据...",
|
|
||||||
"success": "配方元数据修复成功",
|
|
||||||
"skipped": "配方已是最新版本,无需修复",
|
|
||||||
"failed": "修复配方失败:{message}",
|
|
||||||
"missingId": "无法修复配方:缺少配方 ID"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "正在从源重新导入配方...",
|
"starting": "正在从源重新导入配方...",
|
||||||
"success": "配方已从源重新导入成功",
|
"success": "配方已从源重新导入成功",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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": "未找到此模型的本地示例图片。可选操作:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"createMissingData": "缺少创建配方所需的数据",
|
"createMissingData": "缺少创建配方所需的数据",
|
||||||
"created": "配方创建成功",
|
"created": "配方创建成功",
|
||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
|
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
|
||||||
"noPreviousRecipe": "没有上一个配方",
|
"noPreviousRecipe": "没有上一个配方",
|
||||||
"noNextRecipe": "没有下一个配方",
|
"noNextRecipe": "没有下一个配方",
|
||||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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": "重新导入某些配方失败",
|
||||||
|
|||||||
+39
-26
@@ -212,20 +212,10 @@
|
|||||||
"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}"
|
||||||
},
|
},
|
||||||
@@ -484,6 +474,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "按模型分組",
|
"groupByModel": "按模型分組",
|
||||||
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||||
|
"stickyControls": "保持操作列可見",
|
||||||
|
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
|
||||||
"displayDensity": "顯示密度",
|
"displayDensity": "顯示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "預設",
|
"default": "預設",
|
||||||
@@ -819,7 +811,6 @@
|
|||||||
"setContentRating": "為全部設定內容分級",
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
"repairMetadata": "修復所選中元數據",
|
|
||||||
"rematchMetadata": "將所選中重新匹配到本地模型",
|
"rematchMetadata": "將所選中重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
@@ -875,7 +866,6 @@
|
|||||||
"replacePreview": "更換預覽圖",
|
"replacePreview": "更換預覽圖",
|
||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
"repairMetadata": "修復元數據",
|
|
||||||
"rematchMetadata": "重新匹配到本地模型",
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
@@ -1128,13 +1118,6 @@
|
|||||||
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "正在修復配方元數據...",
|
|
||||||
"success": "配方元數據修復成功",
|
|
||||||
"skipped": "配方已是最新版本,無需修復",
|
|
||||||
"failed": "修復配方失敗:{message}",
|
|
||||||
"missingId": "無法修復配方:缺少配方 ID"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "正在從來源重新匯入配方...",
|
"starting": "正在從來源重新匯入配方...",
|
||||||
"success": "配方已從來源重新匯入成功",
|
"success": "配方已從來源重新匯入成功",
|
||||||
@@ -1515,6 +1498,41 @@
|
|||||||
"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": "此模型未找到本機範例圖片。可選擇:",
|
||||||
@@ -2182,6 +2200,7 @@
|
|||||||
"createMissingData": "缺少建立配方所需的資料",
|
"createMissingData": "缺少建立配方所需的資料",
|
||||||
"created": "配方建立成功",
|
"created": "配方建立成功",
|
||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
|
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
|
||||||
"noPreviousRecipe": "沒有上一個配方",
|
"noPreviousRecipe": "沒有上一個配方",
|
||||||
"noNextRecipe": "沒有下一個配方",
|
"noNextRecipe": "沒有下一個配方",
|
||||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
@@ -2236,16 +2255,10 @@
|
|||||||
"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": "重新匯入某些配方失敗",
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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 []
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,12 +122,8 @@ 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_dict)
|
await MetadataManager.save_metadata(dest_path, metadata)
|
||||||
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
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from ...utils.constants import (
|
|||||||
)
|
)
|
||||||
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 (
|
||||||
@@ -3884,6 +3885,7 @@ 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
|
||||||
@@ -3904,6 +3906,7 @@ 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,
|
||||||
@@ -3962,6 +3965,8 @@ 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,
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ 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."""
|
||||||
@@ -129,11 +149,6 @@ 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,
|
||||||
@@ -796,157 +811,6 @@ 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()
|
||||||
@@ -958,12 +822,9 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Mutual exclusion: a global rematch cannot start while a rematch
|
# Mutual exclusion: a global rematch cannot start while a rematch
|
||||||
# OR a repair is already running — both mutate recipes under the
|
# is already running — both mutate recipes under the same
|
||||||
# same mutation lock.
|
# mutation lock.
|
||||||
if (
|
if self._ws_manager.is_recipe_rematch_running():
|
||||||
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,
|
||||||
@@ -971,6 +832,8 @@ 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)
|
||||||
|
|
||||||
@@ -978,7 +841,8 @@ 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(
|
||||||
@@ -1051,7 +915,13 @@ class RecipeManagementHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
relaxed = bool(data.get("relaxed")) or (
|
||||||
|
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(
|
||||||
@@ -1080,7 +950,10 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
recipe_id = request.match_info["recipe_id"]
|
recipe_id = request.match_info["recipe_id"]
|
||||||
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
relaxed = await _parse_relaxed_flag(request)
|
||||||
|
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)
|
||||||
@@ -1179,12 +1052,55 @@ class RecipeManagementHandler:
|
|||||||
persisted_source_path=persisted_source_path,
|
persisted_source_path=persisted_source_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self._import_semaphore:
|
# Optional caller-supplied metadata payload (companion browser
|
||||||
import_response = await self._do_import_from_url(
|
# extension re-import). Only honored for CivitAI image page
|
||||||
source_path,
|
# sources; everything else uses the native URL import below.
|
||||||
recipe_scanner,
|
params = request.rel_url.query
|
||||||
target_dir=old_folder,
|
payload_image_url = params.get("image_url")
|
||||||
)
|
payload_name = params.get("name")
|
||||||
|
payload_resources = params.get("resources")
|
||||||
|
has_import_payload = bool(
|
||||||
|
payload_image_url and payload_name and payload_resources
|
||||||
|
)
|
||||||
|
|
||||||
|
import_response: web.Response | None = None
|
||||||
|
if has_import_payload and image_id:
|
||||||
|
try:
|
||||||
|
async with self._import_semaphore:
|
||||||
|
import_response = await self._import_remote_recipe_impl(
|
||||||
|
image_url=payload_image_url,
|
||||||
|
name=payload_name,
|
||||||
|
resources_raw=payload_resources,
|
||||||
|
gen_params_raw=params.get("gen_params"),
|
||||||
|
tags_raw=params.get("tags"),
|
||||||
|
base_model=params.get("base_model", "") or "",
|
||||||
|
source_path=source_path,
|
||||||
|
target_dir=old_folder,
|
||||||
|
)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
# Malformed resources/gen_params JSON: treat as "no
|
||||||
|
# payload" and use the legacy URL re-import.
|
||||||
|
self._logger.warning(
|
||||||
|
"Ignoring malformed re-import payload for recipe %s "
|
||||||
|
"(%s); falling back to source URL re-import",
|
||||||
|
recipe_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning(
|
||||||
|
"Payload-based re-import failed for recipe %s: %s; "
|
||||||
|
"falling back to source URL re-import",
|
||||||
|
recipe_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
if import_response is None:
|
||||||
|
async with self._import_semaphore:
|
||||||
|
import_response = await self._do_import_from_url(
|
||||||
|
source_path,
|
||||||
|
recipe_scanner,
|
||||||
|
target_dir=old_folder,
|
||||||
|
)
|
||||||
|
|
||||||
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
|
||||||
@@ -1211,14 +1127,19 @@ class RecipeManagementHandler:
|
|||||||
exc,
|
exc,
|
||||||
)
|
)
|
||||||
|
|
||||||
return web.json_response(
|
response_body: Dict[str, Any] = {
|
||||||
{
|
"success": True,
|
||||||
"success": True,
|
"old_recipe_id": recipe_id,
|
||||||
"old_recipe_id": recipe_id,
|
"recipe_id": new_recipe_id,
|
||||||
"recipe_id": new_recipe_id,
|
"source_path": source_path,
|
||||||
"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:
|
||||||
@@ -1231,18 +1152,6 @@ 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()
|
||||||
@@ -1263,31 +1172,14 @@ 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._do_import_remote_recipe(
|
return await self._import_remote_recipe_impl(
|
||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
name=name,
|
name=name,
|
||||||
lora_entries=lora_entries,
|
resources_raw=resources_raw,
|
||||||
checkpoint_entry=checkpoint_entry,
|
gen_params_raw=params.get("gen_params"),
|
||||||
gen_params_request=gen_params_request,
|
tags_raw=params.get("tags"),
|
||||||
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,
|
||||||
)
|
)
|
||||||
@@ -1301,6 +1193,52 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -1312,6 +1250,7 @@ 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:
|
||||||
@@ -1475,6 +1414,7 @@ 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)
|
||||||
|
|
||||||
@@ -1939,6 +1879,25 @@ 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
|
||||||
@@ -3165,6 +3124,12 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -3338,31 +3303,27 @@ 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:
|
||||||
return web.json_response(
|
path = Path.home()
|
||||||
{"success": False, "error": "Directory path is required"},
|
else:
|
||||||
status=400,
|
path = Path(directory_path).expanduser().resolve()
|
||||||
)
|
|
||||||
|
|
||||||
# Normalize the path
|
# Access check: browsing intentionally covers the whole server
|
||||||
path = Path(directory_path).expanduser().resolve()
|
# filesystem (the server operator browses their own machine). On
|
||||||
|
# POSIX every absolute path is under "/", but Path("/") has no
|
||||||
# Security check: ensure path is within allowed directories
|
# drive letter on Windows and can never anchor a drive-qualified
|
||||||
# Allow common image/model directories
|
# path in relative_to(), so test for a drive there instead.
|
||||||
allowed_roots = [
|
if os.name == "nt":
|
||||||
Path.home(),
|
is_allowed = bool(path.drive)
|
||||||
Path("/"), # Allow browsing from root for flexibility
|
else:
|
||||||
]
|
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(
|
||||||
@@ -3429,15 +3390,24 @@ 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())
|
||||||
|
|
||||||
# Add parent directory if not at root
|
# Parent directory. A filesystem root is its own parent
|
||||||
parent_path = path.parent
|
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||||
show_parent = str(path) != str(path.root)
|
# drive root (C:\) links up to the virtual drive list so users
|
||||||
|
# can switch drives. The previous str(path) != str(path.root)
|
||||||
|
# check misfired on Windows, where a drive root's parent is
|
||||||
|
# itself, producing an infinite self-loop.
|
||||||
|
if path.parent == path:
|
||||||
|
parent_path = (
|
||||||
|
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parent_path = str(path.parent)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"current_path": str(path),
|
"current_path": str(path),
|
||||||
"parent_path": str(parent_path) if show_parent else None,
|
"parent_path": parent_path,
|
||||||
"directories": directories,
|
"directories": directories,
|
||||||
"image_files": image_files,
|
"image_files": image_files,
|
||||||
"image_count": len(image_files),
|
"image_count": len(image_files),
|
||||||
@@ -3464,3 +3434,30 @@ 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,6 +103,10 @@ 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,6 +41,7 @@ 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__)
|
||||||
@@ -140,6 +141,7 @@ 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,
|
||||||
@@ -161,6 +163,7 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -84,11 +84,6 @@ 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"),
|
||||||
@@ -115,6 +110,11 @@ 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,7 +407,6 @@ 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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ 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()
|
||||||
@@ -251,7 +256,11 @@ 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 current is not None and current.gid == transfer.gid:
|
if (
|
||||||
|
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(
|
||||||
@@ -339,21 +348,43 @@ 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 self._rpc_call("aria2.addUri", [[resolved_url], options])
|
gid = await asyncio.shield(add_task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
leaked_gid: Any = None
|
||||||
|
try:
|
||||||
|
leaked_gid = await add_task
|
||||||
|
except Exception:
|
||||||
|
leaked_gid = None
|
||||||
|
if isinstance(leaked_gid, str) and leaked_gid:
|
||||||
|
logger.info(
|
||||||
|
"Removing aria2 gid %s accepted while download %s was "
|
||||||
|
"being cancelled",
|
||||||
|
leaked_gid,
|
||||||
|
download_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._rpc_call("aria2.forceRemove", [leaked_gid])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to remove leaked aria2 gid %s for download %s: %s",
|
||||||
|
leaked_gid,
|
||||||
|
download_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
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(
|
||||||
@@ -372,7 +403,46 @@ 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]]:
|
||||||
|
|||||||
@@ -410,6 +410,10 @@ 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)
|
||||||
@@ -419,9 +423,7 @@ 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(
|
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||||
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,6 +505,50 @@ 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
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ from urllib.parse import urlparse
|
|||||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
||||||
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,
|
||||||
@@ -32,9 +31,11 @@ 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
|
||||||
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
|
||||||
@@ -929,6 +930,42 @@ 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,
|
||||||
*,
|
*,
|
||||||
@@ -1584,27 +1621,13 @@ class DownloadManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check if this checkpoint should be treated as a diffusion model
|
# Check if this checkpoint should be treated as a diffusion model
|
||||||
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
# (shared with the download routing endpoint so the UI location
|
||||||
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
# step and the actual download agree on the target roots).
|
||||||
is_diffusion_model = False
|
is_diffusion_model = is_diffusion_model_download(
|
||||||
if model_type == "checkpoint":
|
model_type,
|
||||||
# Check file types first (more direct signal from CivitAI)
|
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||||
version_files = version_info.get("files", [])
|
base_model=base_model_value,
|
||||||
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
|
||||||
@@ -1858,6 +1881,24 @@ 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:
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
from ..utils.constants import 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
|
||||||
+92
-49
@@ -11,6 +11,7 @@ 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
|
||||||
@@ -32,8 +33,26 @@ _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.
|
||||||
@@ -46,61 +65,85 @@ 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
|
global _catalog_cache, _model_output_limits, _catalog_last_failure
|
||||||
if _catalog_cache is not None:
|
if _catalog_cache is not None:
|
||||||
return _catalog_cache
|
return _catalog_cache
|
||||||
|
|
||||||
try:
|
async with _catalog_lock:
|
||||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
# Re-check under the lock: another caller may have fetched (or
|
||||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
# failed) while we were waiting.
|
||||||
if resp.status != 200:
|
if _catalog_cache is not None:
|
||||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
return _catalog_cache
|
||||||
return _catalog_cache or {}
|
if (
|
||||||
data = await resp.json()
|
_catalog_last_failure is not None
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError) as exc:
|
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
||||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
):
|
||||||
return _catalog_cache or {}
|
logger.debug(
|
||||||
|
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
||||||
|
time.monotonic() - _catalog_last_failure,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
try:
|
||||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||||
return _catalog_cache or {}
|
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||||
|
_catalog_last_failure = time.monotonic()
|
||||||
|
return {}
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||||
|
_catalog_last_failure = time.monotonic()
|
||||||
|
return {}
|
||||||
|
|
||||||
result: Dict[str, List[str]] = {}
|
if not isinstance(data, dict):
|
||||||
output_limits: Dict[str, Dict[str, int]] = {}
|
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||||
for provider_id, provider_info in data.items():
|
_catalog_last_failure = time.monotonic()
|
||||||
if not isinstance(provider_info, dict):
|
return {}
|
||||||
continue
|
|
||||||
models_dict = provider_info.get("models")
|
result: Dict[str, List[str]] = {}
|
||||||
if not isinstance(models_dict, dict):
|
output_limits: Dict[str, Dict[str, int]] = {}
|
||||||
continue
|
for provider_id, provider_info in data.items():
|
||||||
model_ids: List[str] = []
|
if not isinstance(provider_info, dict):
|
||||||
provider_limits: Dict[str, int] = {}
|
|
||||||
for mid, model_info in models_dict.items():
|
|
||||||
if not isinstance(mid, str):
|
|
||||||
continue
|
continue
|
||||||
model_ids.append(mid)
|
models_dict = provider_info.get("models")
|
||||||
if isinstance(model_info, dict):
|
if not isinstance(models_dict, dict):
|
||||||
limit = model_info.get("limit")
|
continue
|
||||||
if isinstance(limit, dict):
|
model_ids: List[str] = []
|
||||||
output = limit.get("output")
|
provider_limits: Dict[str, int] = {}
|
||||||
if isinstance(output, (int, float)) and output > 0:
|
for mid, model_info in models_dict.items():
|
||||||
provider_limits[mid] = int(output)
|
if not isinstance(mid, str):
|
||||||
if model_ids:
|
continue
|
||||||
result[provider_id] = model_ids
|
model_ids.append(mid)
|
||||||
if provider_limits:
|
if isinstance(model_info, dict):
|
||||||
output_limits[provider_id] = provider_limits
|
limit = model_info.get("limit")
|
||||||
|
if isinstance(limit, dict):
|
||||||
|
output = limit.get("output")
|
||||||
|
if isinstance(output, (int, float)) and output > 0:
|
||||||
|
provider_limits[mid] = int(output)
|
||||||
|
if model_ids:
|
||||||
|
result[provider_id] = model_ids
|
||||||
|
if provider_limits:
|
||||||
|
output_limits[provider_id] = provider_limits
|
||||||
|
|
||||||
_catalog_cache = result
|
_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]:
|
||||||
@@ -126,12 +169,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) as resp:
|
async with session.get(url, headers=_NO_BROTLI_HEADERS) 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) as exc:
|
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) 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,6 +33,11 @@ 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,6 +169,17 @@ 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"""
|
||||||
|
|
||||||
@@ -203,6 +214,11 @@ 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"""
|
||||||
|
|
||||||
@@ -700,6 +716,37 @@ 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)
|
||||||
|
|
||||||
@@ -791,6 +838,16 @@ 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"""
|
||||||
|
|
||||||
|
|||||||
+208
-68
@@ -62,10 +62,6 @@ 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 = {
|
||||||
@@ -89,6 +85,10 @@ 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"""
|
||||||
@@ -144,8 +144,9 @@ 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
|
||||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
# Guard against concurrent all-folders backfill walks (cold fallback
|
||||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
# for persisted snapshots that predate folder recording).
|
||||||
|
self._all_folders_backfill_running = False
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -217,7 +218,6 @@ 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:
|
||||||
@@ -702,7 +702,8 @@ 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
|
||||||
|
|
||||||
@@ -737,6 +738,7 @@ 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)
|
||||||
@@ -784,7 +786,12 @@ 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')
|
||||||
@@ -1005,20 +1012,36 @@ 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 = {}
|
|
||||||
for cached_path in cached_paths:
|
# physical path -> cached business path, for the alias case where the
|
||||||
try:
|
# same file is reachable under a different path than the cached one
|
||||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
# (overlapping roots / symlink layout changes): keep the existing
|
||||||
except Exception:
|
# entry instead of delete + re-add (which would re-read metadata and
|
||||||
continue
|
# re-hash every file). Built lazily on the first miss, because a
|
||||||
|
# realpath per cached entry is ~half the cost of a no-change
|
||||||
|
# reconcile and the map is only ever consulted for misses.
|
||||||
|
cached_real_paths: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
def lookup_cached_real_path(real_path: str) -> Optional[str]:
|
||||||
|
nonlocal cached_real_paths
|
||||||
|
if cached_real_paths is None:
|
||||||
|
cached_real_paths = {}
|
||||||
|
for cached_path in cached_paths:
|
||||||
|
try:
|
||||||
|
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return cached_real_paths.get(real_path)
|
||||||
|
|
||||||
# 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():
|
||||||
@@ -1033,19 +1056,31 @@ 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
|
||||||
|
|
||||||
cached_real_match = cached_real_paths.get(real_file_path)
|
# Only a cache miss needs the physical path, so the
|
||||||
|
# realpath syscalls are paid per changed file rather
|
||||||
|
# than per file in the library.
|
||||||
|
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||||
|
|
||||||
|
cached_real_match = lookup_cached_real_path(real_file_path)
|
||||||
if cached_real_match:
|
if cached_real_match:
|
||||||
found_paths.add(cached_real_match)
|
found_paths.add(cached_real_match)
|
||||||
continue
|
continue
|
||||||
@@ -1090,6 +1125,9 @@ 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:
|
||||||
@@ -1098,12 +1136,10 @@ 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
|
||||||
model_roots = self.get_model_roots()
|
normalized_path = os.path.normpath(path)
|
||||||
for potential_root in model_roots:
|
for potential_root in model_roots:
|
||||||
# Normalize both paths for comparison
|
# Normalize both paths for comparison
|
||||||
normalized_path = os.path.normpath(path)
|
if normalized_path.startswith(os.path.normpath(potential_root)):
|
||||||
normalized_root = os.path.normpath(potential_root)
|
|
||||||
if normalized_path.startswith(normalized_root):
|
|
||||||
root_path = potential_root
|
root_path = potential_root
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1200,24 +1236,40 @@ 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]
|
||||||
|
|
||||||
dedup_removed = 0
|
# Defensive integrity pass: drop entries sharing a business path.
|
||||||
seen_paths: set[str] = set()
|
# Duplicates can only be introduced by external code rewriting
|
||||||
deduped: list[Dict[str, Any]] = []
|
# raw_data directly or by this pass's own appends, so an unchanged
|
||||||
for item in reversed(self._cache.raw_data):
|
# filesystem walk over a clean cache has nothing to clean. The size
|
||||||
path = item.get('file_path', '')
|
# mismatch is an O(1) tell that the snapshot already contained
|
||||||
if path not in seen_paths:
|
# duplicates; skipping the O(N) pass when it is provably clean is
|
||||||
seen_paths.add(path)
|
# what keeps a no-change Refresh cheap.
|
||||||
deduped.append(item)
|
if cached_size_before != len(cached_paths) or total_added > 0:
|
||||||
else:
|
dedup_removed = 0
|
||||||
for tag in item.get('tags', []):
|
seen_paths: set[str] = set()
|
||||||
if tag in self._tags_count:
|
deduped: list[Dict[str, Any]] = []
|
||||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
for item in reversed(self._cache.raw_data):
|
||||||
if self._tags_count[tag] == 0:
|
path = item.get('file_path', '')
|
||||||
del self._tags_count[tag]
|
if path not in seen_paths:
|
||||||
dedup_removed += 1
|
seen_paths.add(path)
|
||||||
if dedup_removed > 0:
|
deduped.append(item)
|
||||||
self._cache.raw_data = list(reversed(deduped))
|
else:
|
||||||
total_removed += dedup_removed
|
for tag in item.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
dedup_removed += 1
|
||||||
|
if dedup_removed > 0:
|
||||||
|
self._cache.raw_data = list(reversed(deduped))
|
||||||
|
total_removed += dedup_removed
|
||||||
|
|
||||||
|
# The walk above visited every directory, so refresh the recorded
|
||||||
|
# folder list (including empty folders) even when no model files
|
||||||
|
# changed — e.g. an empty folder was created or removed externally.
|
||||||
|
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
||||||
|
folders_changed = self._cache.all_folders != sorted_discovered
|
||||||
|
if folders_changed:
|
||||||
|
self._cache.all_folders = sorted_discovered
|
||||||
|
|
||||||
# Resort cache if changes were made
|
# Resort cache if changes were made
|
||||||
if total_added > 0 or total_removed > 0:
|
if total_added > 0 or total_removed > 0:
|
||||||
@@ -1231,6 +1283,8 @@ 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(
|
||||||
@@ -1270,22 +1324,73 @@ 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.
|
||||||
|
|
||||||
Unlike the models-only ``cache.folders``, this includes empty
|
Runs in a worker thread. Hidden directories (any segment starting
|
||||||
directories, so it stays accurate even when the in-memory cache was
|
with '.') and the pending-delete staging dir are excluded.
|
||||||
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()
|
||||||
|
|
||||||
@@ -1307,17 +1412,7 @@ 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)
|
||||||
|
|
||||||
folders = set(discovered)
|
return sorted(discovered, key=lambda x: x.lower())
|
||||||
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)"""
|
||||||
@@ -1339,6 +1434,14 @@ 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 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:
|
||||||
@@ -1533,6 +1636,9 @@ 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()
|
||||||
@@ -1630,6 +1736,7 @@ 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:
|
||||||
@@ -1708,6 +1815,13 @@ 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}")
|
||||||
@@ -1727,7 +1841,8 @@ 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:
|
||||||
@@ -1869,6 +1984,20 @@ 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 {
|
||||||
@@ -1970,6 +2099,16 @@ 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
|
||||||
|
|
||||||
@@ -1977,10 +2116,6 @@ 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()
|
||||||
@@ -2064,6 +2199,11 @@ 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")
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ 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.
|
||||||
@@ -128,6 +131,14 @@ 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:
|
||||||
@@ -216,14 +227,20 @@ 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) -> 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, all_folders: Optional[Sequence[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:
|
||||||
@@ -469,6 +486,27 @@ 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()
|
||||||
@@ -554,6 +592,17 @@ 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,7 +52,6 @@ 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",
|
||||||
@@ -442,7 +441,6 @@ 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,
|
||||||
@@ -541,7 +539,6 @@ 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,
|
||||||
@@ -599,7 +596,6 @@ 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,
|
||||||
|
|||||||
+158
-233
@@ -94,8 +94,6 @@ 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,
|
||||||
@@ -485,32 +483,43 @@ 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(self, entry: dict[str, Any]) -> bool:
|
def _is_rematch_candidate(
|
||||||
|
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
|
||||||
it may be a recipe imported without downloading the model yet, and
|
in the default strict mode: it may be a recipe imported without
|
||||||
its CivitAI-valid hash must never be overwritten by the imprecise
|
downloading the model yet, and its CivitAI-valid hash must never be
|
||||||
filename fallback.
|
overwritten by the imprecise filename fallback.
|
||||||
|
|
||||||
|
With ``relaxed=True`` any entry carrying an identifier is a
|
||||||
|
candidate, including healthy ones — the caller opted into trying to
|
||||||
|
reconnect "Not in Library" entries by file name. Entries without
|
||||||
|
any identifier are never candidates in either mode.
|
||||||
"""
|
"""
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return False
|
return False
|
||||||
unresolved = (
|
|
||||||
entry.get("isDeleted")
|
|
||||||
or entry.get("hashInvalid")
|
|
||||||
or not entry.get("hash")
|
|
||||||
or not entry.get("file_name")
|
|
||||||
)
|
|
||||||
has_identifier = (
|
has_identifier = (
|
||||||
entry.get("hash")
|
entry.get("hash")
|
||||||
or entry.get("modelVersionId")
|
or entry.get("modelVersionId")
|
||||||
or entry.get("id")
|
or entry.get("id")
|
||||||
or entry.get("file_name")
|
or entry.get("file_name")
|
||||||
)
|
)
|
||||||
return bool(unresolved and has_identifier)
|
if not has_identifier:
|
||||||
|
return False
|
||||||
|
if relaxed:
|
||||||
|
return True
|
||||||
|
unresolved = (
|
||||||
|
entry.get("isDeleted")
|
||||||
|
or entry.get("hashInvalid")
|
||||||
|
or not entry.get("hash")
|
||||||
|
or not entry.get("file_name")
|
||||||
|
)
|
||||||
|
return bool(unresolved)
|
||||||
|
|
||||||
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.
|
||||||
@@ -811,208 +820,9 @@ class RecipeScanner:
|
|||||||
"""Check if cancellation has been requested."""
|
"""Check if cancellation has been requested."""
|
||||||
return self._cancel_requested
|
return self._cancel_requested
|
||||||
|
|
||||||
async def repair_all_recipes(
|
async def rematch_recipe_by_id(
|
||||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
self, recipe_id: str, *, relaxed: bool = False
|
||||||
) -> 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
|
||||||
@@ -1020,12 +830,14 @@ 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)
|
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
|
||||||
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",
|
||||||
@@ -1038,7 +850,9 @@ class RecipeScanner:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
async def _rematch_recipe_by_id(
|
||||||
|
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
|
||||||
@@ -1049,12 +863,16 @@ 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. The legacy ``skipped``
|
fields) and a per-entry ``details`` report plus a flattened
|
||||||
|
``l4_matches`` list (filename-level matches for review/undo,
|
||||||
|
consistent with the bulk/global paths). The legacy ``skipped``
|
||||||
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.
|
||||||
@@ -1075,7 +893,8 @@ 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(
|
||||||
@@ -1094,12 +913,16 @@ 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 {
|
||||||
@@ -1111,6 +934,7 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,6 +948,7 @@ 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),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1133,6 +958,8 @@ 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.
|
||||||
|
|
||||||
@@ -1148,16 +975,24 @@ 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}],
|
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
|
||||||
"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
|
||||||
@@ -1166,11 +1001,23 @@ 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 entry in loras:
|
for lora_index, entry in enumerate(loras):
|
||||||
if not self._is_rematch_candidate(entry):
|
if not is_actionable_candidate(entry):
|
||||||
continue
|
continue
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
item, level = await self._match_rematch_entry_with_level(
|
||||||
entry,
|
entry,
|
||||||
@@ -1194,6 +1041,7 @@ 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)
|
||||||
@@ -1203,7 +1051,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 self._is_rematch_candidate(checkpoint):
|
if is_actionable_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,
|
||||||
@@ -1268,8 +1116,36 @@ 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, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
self,
|
||||||
|
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.
|
||||||
|
|
||||||
@@ -1283,14 +1159,19 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_callback: Optional callback for progress updates
|
progress_callback: Optional callback for progress updates
|
||||||
(started/processing/cancelled/completed events).
|
(started/processing/cancelled/completed events). The
|
||||||
|
completed/cancelled payloads carry ``l4_matches``, a
|
||||||
|
flattened list of filename-level matches for review/undo.
|
||||||
|
relaxed: When True, healthy entries are rematch candidates too
|
||||||
|
(see ``_rematch_single_recipe``).
|
||||||
|
|
||||||
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). ``rematched`` (legacy) counts updated recipes —
|
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
|
||||||
use ``matched_entries`` for the entry-level total.
|
updated recipes — use ``matched_entries`` for the entry-level
|
||||||
|
total.
|
||||||
"""
|
"""
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
@@ -1312,6 +1193,7 @@ 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():
|
||||||
@@ -1340,6 +1222,7 @@ 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 {
|
||||||
@@ -1353,6 +1236,7 @@ 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:
|
||||||
@@ -1368,11 +1252,15 @@ 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
|
||||||
|
|
||||||
@@ -1418,6 +1306,7 @@ 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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1431,9 +1320,12 @@ 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(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
async def rematch_recipes_bulk(
|
||||||
|
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
|
||||||
@@ -1444,14 +1336,18 @@ 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) and a per-recipe ``details`` list. The legacy ``rematched``
|
fields), a per-recipe ``details`` list, and ``l4_matches`` — a
|
||||||
field is the total entry count (same as ``matched_entries``) —
|
flattened list of filename-level matches for review/undo. The
|
||||||
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
legacy ``rematched`` field is the total entry count (same as
|
||||||
|
``matched_entries``) — unlike ``rematch_all_recipes`` where it
|
||||||
|
counts updated recipes.
|
||||||
"""
|
"""
|
||||||
total = len(recipe_ids)
|
total = len(recipe_ids)
|
||||||
matched_recipes = 0
|
matched_recipes = 0
|
||||||
@@ -1462,10 +1358,13 @@ 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(recipe_id)
|
result = await self._rematch_recipe_by_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)
|
||||||
@@ -1478,6 +1377,9 @@ 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:
|
||||||
@@ -1512,12 +1414,22 @@ 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
|
||||||
|
|
||||||
@@ -1541,6 +1453,8 @@ 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:
|
||||||
@@ -1552,6 +1466,15 @@ 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
|
||||||
|
|
||||||
@@ -1592,6 +1515,8 @@ 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")
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ 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": "",
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ 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()
|
||||||
@@ -193,14 +191,6 @@ 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
|
||||||
@@ -209,22 +199,6 @@ 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
|
||||||
|
|||||||
@@ -77,9 +77,6 @@ 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
|
||||||
@@ -92,9 +89,6 @@ 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"""
|
||||||
|
|||||||
+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.1"
|
version = "1.2.2"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -592,3 +592,145 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -174,7 +174,7 @@
|
|||||||
z-index: var(--z-toast);
|
z-index: var(--z-toast);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
/* No align-items (defaults to stretch) so every toast shares one equal width */
|
||||||
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 */
|
||||||
|
|||||||
+33
-8
@@ -25,6 +25,23 @@
|
|||||||
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 {
|
||||||
@@ -201,20 +218,17 @@
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-left: 6px;
|
min-width: 16px;
|
||||||
min-width: 18px;
|
height: 16px;
|
||||||
height: 18px;
|
padding: 0 4px;
|
||||||
padding: 0 5px;
|
font-size: 10px;
|
||||||
font-size: 11px;
|
font-weight: 500;
|
||||||
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);
|
||||||
}
|
}
|
||||||
@@ -225,10 +239,21 @@
|
|||||||
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 */
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
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);
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ 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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ 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',
|
||||||
};
|
};
|
||||||
@@ -678,7 +677,7 @@ export class RecipeSidebarApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async repairBulkModels(filePaths) {
|
async rematchBulkModels(filePaths, options = {}) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
}
|
}
|
||||||
@@ -691,36 +690,11 @@ 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 response = await fetch(this.apiConfig.endpoints.repairBulk, {
|
const body = { recipe_ids: recipeIds };
|
||||||
method: 'POST',
|
// Only sent when opted in — the strict body stays exactly
|
||||||
headers: {
|
// {recipe_ids} for backward compatibility.
|
||||||
'Content-Type': 'application/json',
|
if (options.relaxed === true) {
|
||||||
},
|
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, {
|
||||||
@@ -728,9 +702,7 @@ export class RecipeSidebarApiClient {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
recipe_ids: recipeIds,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|||||||
@@ -41,13 +41,9 @@ 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';
|
||||||
}
|
}
|
||||||
@@ -283,9 +279,6 @@ 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,6 +25,7 @@ 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,6 +4,8 @@ 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() {
|
||||||
@@ -23,7 +25,6 @@ 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');
|
||||||
@@ -41,7 +42,6 @@ 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,7 +50,6 @@ 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');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +94,6 @@ 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);
|
||||||
@@ -371,100 +365,19 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
return `${displayName}s`;
|
return `${displayName}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async repairRecipes(menuItem) {
|
async rematchRecipes(menuItem) {
|
||||||
if (this._repairInProgress) {
|
if (this._rematchInProgress) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._repairInProgress = true;
|
// Collect options (relaxed matching) before starting anything; the
|
||||||
menuItem?.classList.add('disabled');
|
// run only begins when the user confirms the dialog.
|
||||||
|
rematchModalManager.showOptionsModal({
|
||||||
const loadingMessage = translate(
|
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
|
||||||
'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 cancelRepair() {
|
async _startRematch(menuItem, relaxed = false) {
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -485,6 +398,7 @@ 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();
|
||||||
@@ -512,48 +426,32 @@ 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;
|
||||||
if (entries > 0) {
|
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
|
||||||
const successKey = failures > 0
|
// Complete no-op (nothing matched, nothing
|
||||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
// unresolved, no errors) keeps the lightweight
|
||||||
: 'globalContextMenu.rematchRecipes.success';
|
// toast; anything else opens the post-run summary
|
||||||
const successText = failures > 0
|
// modal.
|
||||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
|
||||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
if (isNoop) {
|
||||||
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) {
|
||||||
@@ -570,7 +468,23 @@ 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).`
|
||||||
));
|
));
|
||||||
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
// A cancelled run still reports partial results
|
||||||
|
// via the summary modal (marked as cancelled).
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'global',
|
||||||
|
cancelled: true,
|
||||||
|
total: p.total || 0,
|
||||||
|
matchedRecipes: cancelledRecipes,
|
||||||
|
matchedEntries: cancelledEntries,
|
||||||
|
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||||
|
unresolvedEntries: p.unresolved_entries ?? 0,
|
||||||
|
skipped: p.skipped || 0,
|
||||||
|
errors: p.errors || 0,
|
||||||
|
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
|
||||||
|
});
|
||||||
|
if (window.recipesPage) {
|
||||||
|
window.recipesPage.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} 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,8 +1,7 @@
|
|||||||
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, showToast } from '../../utils/uiHelpers.js';
|
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } 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';
|
||||||
|
|
||||||
@@ -27,16 +26,6 @@ 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)) {
|
||||||
@@ -75,9 +64,6 @@ 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;
|
||||||
@@ -87,68 +73,6 @@ 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 || '{}');
|
||||||
|
|||||||
@@ -278,6 +278,79 @@ 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;
|
||||||
@@ -388,6 +461,9 @@ 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;
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ 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() {
|
||||||
@@ -93,10 +96,6 @@ 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);
|
||||||
@@ -297,44 +296,6 @@ 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');
|
||||||
@@ -344,26 +305,36 @@ 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();
|
||||||
@@ -371,16 +342,22 @@ 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
|
|
||||||
// models deleted from Civitai; informational, not an error.
|
if (isNoop) {
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchUnmatched',
|
|
||||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||||
|
} else {
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'single',
|
||||||
|
total: 1,
|
||||||
|
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes: result.unresolved_recipes || 0,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped: result.skipped || 0,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Rematch failed');
|
throw new Error(result.error || 'Rematch failed');
|
||||||
@@ -397,6 +374,24 @@ 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 {
|
||||||
@@ -419,6 +414,34 @@ 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
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Modal Component
|
// Recipe Modal Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } 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,8 +1078,9 @@ 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 —
|
// identifiers at all cannot be fixed by downloading, so no
|
||||||
// reconnecting a local LoRA is the only remediation.
|
// download button is offered. Reconnect is always available
|
||||||
|
// for missing entries (see renderLoraItemActions).
|
||||||
const needsReconnect = !existsLocally
|
const needsReconnect = !existsLocally
|
||||||
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
||||||
|
|
||||||
@@ -1180,7 +1181,7 @@ class RecipeModal {
|
|||||||
</div>
|
</div>
|
||||||
${actionsRow}
|
${actionsRow}
|
||||||
</div>
|
</div>
|
||||||
${needsReconnect ? `
|
${!existsLocally ? `
|
||||||
<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>
|
||||||
@@ -2853,11 +2854,7 @@ 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) {
|
||||||
if (!message) {
|
return isUnresolvableDownloadError(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) {
|
||||||
@@ -2877,12 +2874,14 @@ 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;
|
||||||
// Direct download needs both identifiers; a hash alone is enough
|
// A bare CivitAI version id is enough: it uniquely pins the exact
|
||||||
// because downloadRecipeLora resolves it to a version on demand —
|
// file, and downloadRecipeLora resolves the owning model id from the
|
||||||
// the same fallback the bulk "download missing" flow uses.
|
// version on demand (the same fallback the bulk "download missing"
|
||||||
return !!((modelId && versionId) || lora.hash);
|
// flow uses). A hash alone is likewise sufficient. A model id without
|
||||||
|
// an exact version id is NOT enough — downloading the model's latest
|
||||||
|
// version could silently mismatch the recipe's pinned version.
|
||||||
|
return !!(versionId || lora.hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderCivitaiLink(url) {
|
renderCivitaiLink(url) {
|
||||||
@@ -2913,19 +2912,9 @@ 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.
|
// download action is unconditional in this branch.
|
||||||
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(`
|
||||||
@@ -2936,6 +2925,18 @@ 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>`;
|
||||||
}
|
}
|
||||||
@@ -2991,6 +2992,9 @@ 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;
|
||||||
@@ -3001,21 +3005,41 @@ class RecipeModal {
|
|||||||
return { modelId, versionId, versionName };
|
return { modelId, versionId, versionName };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lora.hash) {
|
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
|
||||||
return null;
|
// model/version through the same endpoint the bulk "download
|
||||||
|
// missing" flow uses.
|
||||||
|
if (lora.hash) {
|
||||||
|
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
|
||||||
|
const versionInfo = await response.json();
|
||||||
|
if (versionInfo?.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelId = versionInfo.modelId || versionInfo.model?.id;
|
||||||
|
versionId = versionInfo.id;
|
||||||
|
versionName = versionInfo.name || versionName;
|
||||||
|
|
||||||
|
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
|
// Version-only entries (page-imported recipes whose CivitAI versions
|
||||||
const versionInfo = await response.json();
|
// expose no sha256): the version id still pins the exact file, so
|
||||||
if (versionInfo?.error) {
|
// resolve the owning model id from the version endpoint on demand.
|
||||||
return null;
|
if (versionId) {
|
||||||
|
const response = await fetch(`/api/lm/loras/civitai/model/version/${versionId}`);
|
||||||
|
const versionInfo = await response.json();
|
||||||
|
if (!versionInfo || versionInfo?.error === 'Model not found') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
modelId = versionInfo.modelId || versionInfo.model?.id;
|
||||||
|
versionId = versionInfo.id || versionId;
|
||||||
|
versionName = versionInfo.name || versionName;
|
||||||
|
|
||||||
|
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
modelId = versionInfo.modelId || versionInfo.model?.id;
|
return null;
|
||||||
versionId = versionInfo.id;
|
|
||||||
versionName = versionInfo.name || versionName;
|
|
||||||
|
|
||||||
return modelId && versionId ? { modelId, versionId, versionName } : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1390,8 +1390,22 @@ export function initVersionsTab({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const client = ensureClient();
|
const client = ensureClient();
|
||||||
const rootsData = await client.fetchModelRoots();
|
// On the checkpoints page a diffusion model lives under the unet
|
||||||
const roots = rootsData?.roots;
|
// roots, so both root sets are needed to locate the current file.
|
||||||
|
let roots;
|
||||||
|
if (modelType === 'checkpoints') {
|
||||||
|
const [checkpointRoots, unetRoots] = await Promise.all([
|
||||||
|
client.fetchModelRoots(),
|
||||||
|
client.fetchModelRoots('diffusion_model'),
|
||||||
|
]);
|
||||||
|
roots = [
|
||||||
|
...(checkpointRoots?.roots || []),
|
||||||
|
...(unetRoots?.roots || []),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const rootsData = await client.fetchModelRoots();
|
||||||
|
roots = rootsData?.roots;
|
||||||
|
}
|
||||||
if (!Array.isArray(roots) || roots.length === 0) {
|
if (!Array.isArray(roots) || roots.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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';
|
||||||
@@ -68,6 +69,7 @@ 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();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class BatchImportManager {
|
|||||||
this.results = null;
|
this.results = null;
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
this.isImporting = false;
|
this.isImporting = false;
|
||||||
|
this.currentParentPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -718,9 +719,10 @@ export class BatchImportManager {
|
|||||||
browser.style.display = isVisible ? 'none' : 'block';
|
browser.style.display = isVisible ? 'none' : 'block';
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
// Load initial directory when opening
|
// Load initial directory when opening. An empty path lets the
|
||||||
|
// 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 || '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +763,10 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -811,11 +817,9 @@ export class BatchImportManager {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
if (isParent) {
|
// The parent entry uses the server-provided parent_path (or the
|
||||||
this.navigateToParentDirectory();
|
// Windows drive-list token) directly — both are plain load targets.
|
||||||
} else {
|
this.loadDirectory(path);
|
||||||
this.loadDirectory(path);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -839,15 +843,12 @@ export class BatchImportManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to parent directory
|
* Navigate to parent directory using the path reported by the server.
|
||||||
|
* Deriving it client-side by splitting on "/" breaks Windows paths.
|
||||||
*/
|
*/
|
||||||
navigateToParentDirectory() {
|
navigateToParentDirectory() {
|
||||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
if (this.currentParentPath) {
|
||||||
if (currentPath) {
|
this.loadDirectory(this.currentParentPath);
|
||||||
// Get parent path using path manipulation
|
|
||||||
const lastSeparator = currentPath.lastIndexOf('/');
|
|
||||||
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
|
|
||||||
this.loadDirectory(parentPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +859,13 @@ export class BatchImportManager {
|
|||||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||||
|
|
||||||
if (currentPath && directoryInput) {
|
if (!currentPath) {
|
||||||
|
// 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');
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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';
|
||||||
@@ -10,6 +12,7 @@ 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 {
|
||||||
@@ -103,7 +106,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -858,17 +860,74 @@ 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 {
|
||||||
for (let i = 0; i < filePaths.length; i++) {
|
const processedBeforeNative = completed + failed;
|
||||||
const filePath = filePaths[i];
|
for (let i = 0; i < nativeFilePaths.length; 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((i / total) * 100),
|
Math.floor((processed / total) * 100),
|
||||||
recipeName,
|
recipeName,
|
||||||
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
|
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!recipeId) {
|
if (!recipeId) {
|
||||||
@@ -910,76 +969,6 @@ 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');
|
||||||
@@ -991,6 +980,15 @@ 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);
|
||||||
@@ -1002,7 +1000,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);
|
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const total = result.total || filePaths.length;
|
const total = result.total || filePaths.length;
|
||||||
@@ -1028,38 +1026,29 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedEntries > 0) {
|
// Complete no-op (nothing matched, nothing unresolved, no
|
||||||
const hasFailures = failures > 0;
|
// errors) keeps the lightweight toast; anything else opens
|
||||||
const toastKey = hasFailures
|
// the post-run summary modal.
|
||||||
? 'toast.recipes.rematchCompleteErrors'
|
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||||
: 'toast.recipes.rematchComplete';
|
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||||
showToast(
|
if (isNoop) {
|
||||||
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,7 +1,9 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
// Execute download
|
// Execute download
|
||||||
await this.executeDownload(this.pendingLoras);
|
await this.executeDownload(this.pendingLoras);
|
||||||
this.pendingLoras = [];
|
this.pendingLoras = [];
|
||||||
|
this.pendingMissingByRecipe = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +157,9 @@ 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');
|
||||||
@@ -196,6 +203,7 @@ 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;
|
||||||
|
|
||||||
@@ -304,6 +312,12 @@ 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, '');
|
||||||
@@ -312,6 +326,9 @@ 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,9 +352,16 @@ 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;
|
||||||
@@ -354,6 +378,59 @@ 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
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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';
|
||||||
@@ -489,8 +490,9 @@ export class DownloadManager {
|
|||||||
return { type: 'civitai' };
|
return { type: 'civitai' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hugging Face resolve URL → direct file
|
// Hugging Face resolve/blob URL → direct file
|
||||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
// "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
|
||||||
|
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
|
||||||
if (hfResolveMatch) {
|
if (hfResolveMatch) {
|
||||||
return {
|
return {
|
||||||
type: 'hf-resolve',
|
type: 'hf-resolve',
|
||||||
@@ -953,12 +955,7 @@ export class DownloadManager {
|
|||||||
async proceedToLocationContent() {
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const _isDiffusionModel = this.selectedFile
|
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||||
? (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') {
|
||||||
@@ -1019,6 +1016,55 @@ 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;
|
||||||
|
}
|
||||||
|
|
||||||
loadDefaultPathSetting() {
|
loadDefaultPathSetting() {
|
||||||
const modelType = this.apiClient.modelType;
|
const modelType = this.apiClient.modelType;
|
||||||
const storageKey = `use_default_path_${modelType}`;
|
const storageKey = `use_default_path_${modelType}`;
|
||||||
|
|||||||
@@ -347,6 +347,19 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register rematchOptionsModal
|
||||||
|
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
|
||||||
|
if (rematchOptionsModal) {
|
||||||
|
this.registerModal('rematchOptionsModal', {
|
||||||
|
element: rematchOptionsModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('rematchOptionsModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', this.boundHandleEscape);
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -329,7 +329,11 @@ class MoveManager {
|
|||||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||||
movedFiles = (results || [])
|
movedFiles = (results || [])
|
||||||
.filter(r => r.success)
|
.filter(r => r.success)
|
||||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
.map(r => ({
|
||||||
|
original_file_path: r.original_file_path,
|
||||||
|
new_file_path: r.new_file_path,
|
||||||
|
sub_type: r.cache_entry?.sub_type
|
||||||
|
}));
|
||||||
|
|
||||||
// Deselect moving items and exit bulk mode
|
// Deselect moving items and exit bulk mode
|
||||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||||
@@ -340,7 +344,11 @@ class MoveManager {
|
|||||||
if (result) {
|
if (result) {
|
||||||
movedFiles.push({
|
movedFiles.push({
|
||||||
original_file_path: result.original_file_path || this.currentFilePath,
|
original_file_path: result.original_file_path || this.currentFilePath,
|
||||||
new_file_path: result.new_file_path
|
new_file_path: result.new_file_path,
|
||||||
|
// The backend recalculates location-derived fields
|
||||||
|
// (e.g. checkpoint -> diffusion_model) during the move;
|
||||||
|
// carry them so the card re-renders with the new type.
|
||||||
|
sub_type: result.cache_entry?.sub_type
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,24 +387,28 @@ class MoveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stillVisible) {
|
if (stillVisible) {
|
||||||
|
const newData = {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: newRelativeFolder
|
||||||
|
};
|
||||||
|
if (moved.sub_type) newData.sub_type = moved.sub_type;
|
||||||
pathsToUpdate.push({
|
pathsToUpdate.push({
|
||||||
originalPath: moved.original_file_path,
|
originalPath: moved.original_file_path,
|
||||||
newData: {
|
newData
|
||||||
file_path: moved.new_file_path,
|
|
||||||
folder: newRelativeFolder
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pathsToRemove.push(moved.original_file_path);
|
pathsToRemove.push(moved.original_file_path);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No folder filter active — items remain visible, just update path
|
// No folder filter active — items remain visible, just update path
|
||||||
|
const newData = {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: this._getRelativeFolder(moved.new_file_path)
|
||||||
|
};
|
||||||
|
if (moved.sub_type) newData.sub_type = moved.sub_type;
|
||||||
pathsToUpdate.push({
|
pathsToUpdate.push({
|
||||||
originalPath: moved.original_file_path,
|
originalPath: moved.original_file_path,
|
||||||
newData: {
|
newData
|
||||||
file_path: moved.new_file_path,
|
|
||||||
folder: this._getRelativeFolder(moved.new_file_path)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
|
||||||
|
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
|
||||||
|
* and only then invokes the run callback. Post-run reporting lives in
|
||||||
|
* static/js/components/RematchSummaryModal.js.
|
||||||
|
*/
|
||||||
|
export class RematchModalManager {
|
||||||
|
constructor() {
|
||||||
|
this._optionsConfirmCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the options modal. `onConfirm({ relaxed })` fires only when the
|
||||||
|
* user clicks Rematch — Cancel/X runs nothing.
|
||||||
|
*
|
||||||
|
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
|
||||||
|
*/
|
||||||
|
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
|
||||||
|
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
|
||||||
|
const message = document.getElementById('rematchOptionsMessage');
|
||||||
|
if (message) {
|
||||||
|
if (resolvedScope === 'bulk') {
|
||||||
|
message.textContent = translate(
|
||||||
|
'modals.rematchOptions.messageBulk',
|
||||||
|
{ count: recipeCount },
|
||||||
|
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
|
||||||
|
);
|
||||||
|
} else if (resolvedScope === 'single') {
|
||||||
|
message.textContent = translate(
|
||||||
|
'modals.rematchOptions.messageSingle',
|
||||||
|
{},
|
||||||
|
'This recipe will be scanned against your local model library.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message.textContent = translate(
|
||||||
|
'modals.rematchOptions.messageGlobal',
|
||||||
|
{},
|
||||||
|
'All recipes will be scanned against your local model library.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
|
||||||
|
modalManager.showModal('rematchOptionsModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmOptions() {
|
||||||
|
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||||
|
const relaxed = checkbox ? !!checkbox.checked : false;
|
||||||
|
const callback = this._optionsConfirmCallback;
|
||||||
|
this._optionsConfirmCallback = null;
|
||||||
|
modalManager.closeModal('rematchOptionsModal');
|
||||||
|
if (callback) {
|
||||||
|
// Returned so callers (and tests) can await the started run.
|
||||||
|
return callback({ relaxed });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelOptions() {
|
||||||
|
this._optionsConfirmCallback = null;
|
||||||
|
modalManager.closeModal('rematchOptionsModal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rematchModalManager = new RematchModalManager();
|
||||||
@@ -1047,6 +1047,12 @@ export class SettingsManager {
|
|||||||
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
|
groupByModelCheckbox.checked = !!state.global.settings.group_by_model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Set sticky controls
|
||||||
|
const stickyControlsCheckbox = document.getElementById('stickyControls');
|
||||||
|
if (stickyControlsCheckbox) {
|
||||||
|
stickyControlsCheckbox.checked = !!state.global.settings.sticky_controls;
|
||||||
|
}
|
||||||
|
|
||||||
// Set model name display setting
|
// Set model name display setting
|
||||||
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
|
||||||
if (modelNameDisplaySelect) {
|
if (modelNameDisplaySelect) {
|
||||||
@@ -3396,6 +3402,10 @@ export class SettingsManager {
|
|||||||
const groupByModel = !!state.global.settings.group_by_model;
|
const groupByModel = !!state.global.settings.group_by_model;
|
||||||
document.body.classList.toggle('group-by-model', groupByModel);
|
document.body.classList.toggle('group-by-model', groupByModel);
|
||||||
|
|
||||||
|
// Apply sticky controls mode (keeps the action bar visible while scrolling)
|
||||||
|
const stickyControls = !!state.global.settings.sticky_controls;
|
||||||
|
document.body.classList.toggle('sticky-controls', stickyControls);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
strip_lora_on_copy: false,
|
strip_lora_on_copy: false,
|
||||||
use_new_license_icons: true,
|
use_new_license_icons: true,
|
||||||
group_by_model: false,
|
group_by_model: false,
|
||||||
|
sticky_controls: false,
|
||||||
llm_provider: 'openai',
|
llm_provider: 'openai',
|
||||||
llm_api_key: '',
|
llm_api_key: '',
|
||||||
llm_api_base: '',
|
llm_api_base: '',
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* Bridge to the companion LoRA Manager browser extension.
|
||||||
|
*
|
||||||
|
* The extension can re-import recipes sourced from CivitAI image pages with
|
||||||
|
* the complete page metadata (internal trpc data scraped with the user's
|
||||||
|
* session), fixing recipes that the native import (REST API + EXIF only)
|
||||||
|
* saved with 0 LoRAs.
|
||||||
|
*
|
||||||
|
* Protocol: DOM CustomEvents on `document`; `detail` is ALWAYS a JSON
|
||||||
|
* string on both sides.
|
||||||
|
*
|
||||||
|
* LM page -> extension: `lm:reimportProbe`, detail `{}`.
|
||||||
|
* extension -> LM page: `lm:reimportProbeResult`,
|
||||||
|
* detail `{supported, licenseValid, extensionVersion?, reason?}`.
|
||||||
|
* LM page -> extension: `lm:reimportViaExtension`,
|
||||||
|
* detail `{requestId, recipes: [{recipeId, imageId, imageUrl, title}]}`.
|
||||||
|
* extension -> LM page: `lm:reimportProgress`,
|
||||||
|
* detail `{requestId, current, total, recipeId, title, status, message?}`.
|
||||||
|
* extension -> LM page: `lm:reimportBatchDone`,
|
||||||
|
* detail `{requestId, completed, failed}`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROBE_EVENT = 'lm:reimportProbe';
|
||||||
|
const PROBE_RESULT_EVENT = 'lm:reimportProbeResult';
|
||||||
|
const REIMPORT_EVENT = 'lm:reimportViaExtension';
|
||||||
|
const PROGRESS_EVENT = 'lm:reimportProgress';
|
||||||
|
const BATCH_DONE_EVENT = 'lm:reimportBatchDone';
|
||||||
|
|
||||||
|
const DEFAULT_PROBE_TIMEOUT_MS = 500;
|
||||||
|
// Generous batch timeout; any progress event resets it (heartbeat).
|
||||||
|
const DEFAULT_REIMPORT_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
// Mirrors py/utils/civitai_utils.py (_SUPPORTED_CIVITAI_PAGE_HOSTS).
|
||||||
|
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
|
||||||
|
'civitai.com',
|
||||||
|
'civitai.red',
|
||||||
|
'civitai.green',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the JSON-string `detail` of a protocol event.
|
||||||
|
* @param {CustomEvent} event
|
||||||
|
* @returns {object|null} Parsed detail object, or null when absent/invalid.
|
||||||
|
*/
|
||||||
|
function parseDetail(event) {
|
||||||
|
try {
|
||||||
|
const detail = JSON.parse(event?.detail ?? 'null');
|
||||||
|
return detail && typeof detail === 'object' ? detail : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a protocol event with a JSON-stringified detail.
|
||||||
|
* @param {string} type - Event name.
|
||||||
|
* @param {object} payload - Detail payload (JSON-stringified).
|
||||||
|
*/
|
||||||
|
function dispatchProtocolEvent(type, payload) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent(type, { detail: JSON.stringify(payload ?? {}) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a correlation id for a re-import batch.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function generateRequestId() {
|
||||||
|
if (globalThis.crypto?.randomUUID) {
|
||||||
|
return globalThis.crypto.randomUUID();
|
||||||
|
}
|
||||||
|
return `lm-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe whether the companion extension is installed and usable.
|
||||||
|
*
|
||||||
|
* @param {{timeoutMs?: number}} [options]
|
||||||
|
* @returns {Promise<{supported: boolean, licenseValid: boolean, extensionVersion?: string, reason?: string}|null>}
|
||||||
|
* Resolves with the probe result, or null when the extension is absent or
|
||||||
|
* too old to answer (timeout).
|
||||||
|
*/
|
||||||
|
export function probeExtension({ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const timer = setTimeout(() => finish(null), timeoutMs);
|
||||||
|
|
||||||
|
const finish = (value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener(PROBE_RESULT_EVENT, onResult);
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onResult = (event) => {
|
||||||
|
const detail = parseDetail(event);
|
||||||
|
if (!detail) return;
|
||||||
|
finish({
|
||||||
|
supported: Boolean(detail.supported),
|
||||||
|
licenseValid: Boolean(detail.licenseValid),
|
||||||
|
extensionVersion: detail.extensionVersion,
|
||||||
|
reason: detail.reason,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(PROBE_RESULT_EVENT, onResult);
|
||||||
|
dispatchProtocolEvent(PROBE_EVENT, {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delegate a batch of recipe re-imports to the companion extension.
|
||||||
|
*
|
||||||
|
* @param {Array<{recipeId: string, imageId: number, imageUrl: string, title: string}>} recipes
|
||||||
|
* @param {{onProgress?: (progress: object) => void, timeoutMs?: number}} [options]
|
||||||
|
* @returns {Promise<{completed: number, failed: number}>} Resolves on
|
||||||
|
* `lm:reimportBatchDone`; rejects on timeout. Listeners are cleaned up in
|
||||||
|
* all outcomes.
|
||||||
|
*/
|
||||||
|
export function delegateReimport(recipes, { onProgress, timeoutMs = DEFAULT_REIMPORT_TIMEOUT_MS } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!Array.isArray(recipes) || recipes.length === 0) {
|
||||||
|
reject(new Error('delegateReimport requires a non-empty recipe list'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = generateRequestId();
|
||||||
|
let settled = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener(PROGRESS_EVENT, onProgressEvent);
|
||||||
|
document.removeEventListener(BATCH_DONE_EVENT, onBatchDone);
|
||||||
|
};
|
||||||
|
const succeed = (value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const fail = (error) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cleanup();
|
||||||
|
reject(error);
|
||||||
|
};
|
||||||
|
const armTimer = () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(
|
||||||
|
() => fail(new Error('Extension re-import timed out')),
|
||||||
|
timeoutMs
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onProgressEvent = (event) => {
|
||||||
|
const detail = parseDetail(event);
|
||||||
|
if (!detail || detail.requestId !== requestId) return;
|
||||||
|
// Heartbeat: any progress for this batch resets the timeout.
|
||||||
|
armTimer();
|
||||||
|
if (typeof onProgress === 'function') {
|
||||||
|
try {
|
||||||
|
onProgress(detail);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[extensionReimportBridge] onProgress callback failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onBatchDone = (event) => {
|
||||||
|
const detail = parseDetail(event);
|
||||||
|
if (!detail || detail.requestId !== requestId) return;
|
||||||
|
succeed({
|
||||||
|
completed: Number.isInteger(detail.completed) ? detail.completed : 0,
|
||||||
|
failed: Number.isInteger(detail.failed) ? detail.failed : 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(PROGRESS_EVENT, onProgressEvent);
|
||||||
|
document.addEventListener(BATCH_DONE_EVENT, onBatchDone);
|
||||||
|
armTimer();
|
||||||
|
dispatchProtocolEvent(REIMPORT_EVENT, { requestId, recipes });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract CivitAI image page info from a recipe source_path.
|
||||||
|
* Mirrors py/utils/civitai_utils.py `extract_civitai_image_id`.
|
||||||
|
*
|
||||||
|
* @param {string|null} sourcePath - Recipe source_path.
|
||||||
|
* @returns {{imageId: number, imageUrl: string}|null} Null when the path is
|
||||||
|
* not a `/images/<id>` URL on civitai.com/.red/.green.
|
||||||
|
*/
|
||||||
|
export function getCivitaiImageInfo(sourcePath) {
|
||||||
|
if (!sourcePath || typeof sourcePath !== 'string') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = new URL(sourcePath);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!SUPPORTED_CIVITAI_PAGE_HOSTS.has(parsed.hostname.toLowerCase())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathMatch = parsed.pathname.match(/\/images\/(\d+)/);
|
||||||
|
if (!pathMatch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imageId: Number(pathMatch[1]), imageUrl: sourcePath };
|
||||||
|
}
|
||||||
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
|
|||||||
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a download failure means the model is unrecoverable.
|
||||||
|
*
|
||||||
|
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
|
||||||
|
* only set when CivitAI explicitly says the model cannot be resolved — never
|
||||||
|
* for transient transport errors (network, 5xx).
|
||||||
|
* @param {*} message - The error message carried by the failed download
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isUnresolvableDownloadError(message) {
|
||||||
|
if (!message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const text = String(message).toLowerCase();
|
||||||
|
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreFolderFilter() {
|
export function restoreFolderFilter() {
|
||||||
const activeFolder = getStorageItem('activeFolder');
|
const activeFolder = getStorageItem('activeFolder');
|
||||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="enrich-hf-llm">
|
||||||
|
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>
|
||||||
|
</div>
|
||||||
<div class="context-menu-separator menu-section-break"></div>
|
<div class="context-menu-separator menu-section-break"></div>
|
||||||
<!-- Workflow -->
|
<!-- Workflow -->
|
||||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||||
@@ -54,8 +57,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="sticky-topbar">
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
{% include 'components/breadcrumb.html' %}
|
{% include 'components/breadcrumb.html' %}
|
||||||
|
</div>
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -94,9 +94,6 @@
|
|||||||
<div class="context-menu-item" data-action="check-updates">
|
<div class="context-menu-item" data-action="check-updates">
|
||||||
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="repair-metadata">
|
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('loras.bulkOperations.repairMetadata') }}</span> (Deprecated)
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="rematch-metadata">
|
<div class="context-menu-item" data-action="rematch-metadata">
|
||||||
<i class="fas fa-link"></i> <span>{{ t('loras.bulkOperations.rematchMetadata') }}</span>
|
<i class="fas fa-link"></i> <span>{{ t('loras.bulkOperations.rematchMetadata') }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,9 +199,6 @@
|
|||||||
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
|
<i class="fas fa-layer-group"></i> <span>{{ t('globalContextMenu.groupByModel.label') }}</span>
|
||||||
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
<i class="fas fa-check check-indicator" style="margin-left:auto;display:none"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="context-menu-item" data-action="repair-recipes">
|
|
||||||
<i class="fas fa-tools"></i> <span>{{ t('globalContextMenu.repairRecipes.label') }}</span> (Deprecated)
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="rematch-recipes">
|
<div class="context-menu-item" data-action="rematch-recipes">
|
||||||
<i class="fas fa-link"></i> <span>{{ t('globalContextMenu.rematchRecipes.label') }}</span>
|
<i class="fas fa-link"></i> <span>{{ t('globalContextMenu.rematchRecipes.label') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -126,3 +126,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipe Rematch Options Modal -->
|
||||||
|
<div id="rematchOptionsModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{{ t('modals.rematchOptions.title') }}</h2>
|
||||||
|
<span class="close" onclick="rematchModalManager.cancelOptions()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="confirmation-message" id="rematchOptionsMessage"></p>
|
||||||
|
<label class="rematch-option-card" for="rematchOptionsRelaxed">
|
||||||
|
<input type="checkbox" id="rematchOptionsRelaxed">
|
||||||
|
<span class="rematch-option-checkmark" aria-hidden="true"></span>
|
||||||
|
<span class="rematch-option-text">
|
||||||
|
<span class="rematch-option-title">{{ t('modals.rematchOptions.relaxedLabel') }}</span>
|
||||||
|
<span class="rematch-option-caveat">
|
||||||
|
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||||
|
{{ t('modals.rematchOptions.relaxedDescription') }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" onclick="rematchModalManager.cancelOptions()">{{ t('common.actions.cancel') }}</button>
|
||||||
|
<button class="primary-btn" id="rematchOptionsConfirmBtn" onclick="rematchModalManager.confirmOptions()">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
{{ t('modals.rematchOptions.confirmButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -79,6 +79,9 @@
|
|||||||
<!-- Group by model toggle -->
|
<!-- Group by model toggle -->
|
||||||
{{ sm.setting_toggle('groupByModel', 'group_by_model', 'settings.layoutSettings.groupByModel', 'settings.layoutSettings.groupByModelHelp') }}
|
{{ sm.setting_toggle('groupByModel', 'group_by_model', 'settings.layoutSettings.groupByModel', 'settings.layoutSettings.groupByModelHelp') }}
|
||||||
|
|
||||||
|
<!-- Sticky controls toggle -->
|
||||||
|
{{ sm.setting_toggle('stickyControls', 'sticky_controls', 'settings.layoutSettings.stickyControls', 'settings.layoutSettings.stickyControlsHelp') }}
|
||||||
|
|
||||||
{{ sm.setting_select('cardInfoDisplay', 'card_info_display', 'settings.layoutSettings.cardInfoDisplay', [
|
{{ sm.setting_select('cardInfoDisplay', 'card_info_display', 'settings.layoutSettings.cardInfoDisplay', [
|
||||||
('always', 'settings.layoutSettings.cardInfoDisplayOptions.always'),
|
('always', 'settings.layoutSettings.cardInfoDisplayOptions.always'),
|
||||||
('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'),
|
('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'),
|
||||||
|
|||||||
@@ -53,8 +53,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="sticky-topbar">
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
{% include 'components/breadcrumb.html' %}
|
{% include 'components/breadcrumb.html' %}
|
||||||
|
</div>
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@
|
|||||||
{% block init_check_url %}/api/loras/list?page=1&page_size=1{% endblock %}
|
{% block init_check_url %}/api/loras/list?page=1&page_size=1{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="sticky-topbar">
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
{% include 'components/breadcrumb.html' %}
|
{% include 'components/breadcrumb.html' %}
|
||||||
|
</div>
|
||||||
{% include 'components/duplicates_banner.html' %}
|
{% include 'components/duplicates_banner.html' %}
|
||||||
{% include 'components/folder_sidebar.html' %}
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
|
|||||||
@@ -18,9 +18,6 @@
|
|||||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||||
<!-- Metadata -->
|
<!-- Metadata -->
|
||||||
<div class="context-menu-item" data-action="repair">
|
|
||||||
<i class="fas fa-tools"></i> {{ t('loras.contextMenu.repairMetadata') }} (Deprecated)
|
|
||||||
</div>
|
|
||||||
<div class="context-menu-item" data-action="rematch">
|
<div class="context-menu-item" data-action="rematch">
|
||||||
<i class="fas fa-link"></i> {{ t('loras.contextMenu.rematchMetadata') }}
|
<i class="fas fa-link"></i> {{ t('loras.contextMenu.rematchMetadata') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -64,10 +61,12 @@
|
|||||||
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
|
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<!-- Recipe controls -->
|
<!-- Sticky topbar: controls + breadcrumb -->
|
||||||
|
<div class="sticky-topbar">
|
||||||
{% include 'components/controls.html' %}
|
{% include 'components/controls.html' %}
|
||||||
<!-- Breadcrumb Navigation -->
|
<!-- Breadcrumb Navigation -->
|
||||||
{% include 'components/breadcrumb.html' %}
|
{% include 'components/breadcrumb.html' %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Duplicates banner (hidden by default) -->
|
<!-- Duplicates banner (hidden by default) -->
|
||||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ def evaluate_model(
|
|||||||
flagged issues.
|
flagged issues.
|
||||||
"""
|
"""
|
||||||
civitai = metadata.get("civitai") or {}
|
civitai = metadata.get("civitai") or {}
|
||||||
trained_words: List[str] = civitai.get("trainedWords") or metadata.get("trainedWords") or []
|
trained_words: List[str] = civitai.get("trainedWords") or []
|
||||||
short_desc: str = civitai.get("description") or ""
|
short_desc: str = civitai.get("description") or ""
|
||||||
tags: List[str] = metadata.get("tags") or []
|
tags: List[str] = metadata.get("tags") or []
|
||||||
notes: str = metadata.get("notes") or ""
|
notes: str = metadata.get("notes") or ""
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ def create_initial_metadata(
|
|||||||
"metadata_source": "",
|
"metadata_source": "",
|
||||||
"last_checked_at": 0,
|
"last_checked_at": 0,
|
||||||
"hash_status": "completed",
|
"hash_status": "completed",
|
||||||
"trainedWords": [],
|
|
||||||
"hf_url": hf_url,
|
"hf_url": hf_url,
|
||||||
"usage_tips": "{}",
|
"usage_tips": "{}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -309,6 +309,21 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
expect(global.fetch).not.toHaveBeenCalled();
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes relaxed in the bulk rematch body only when opted in', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, total: 1, rematched: 1, skipped: 0, errors: 0, recipes: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.rematchBulkModels(['/recipes/a.webp'], { relaxed: true });
|
||||||
|
|
||||||
|
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
recipe_ids: ['a'],
|
||||||
|
relaxed: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('throws the backend error when bulk rematch fails', async () => {
|
it('throws the backend error when bulk rematch fails', async () => {
|
||||||
const api = new RecipeSidebarApiClient();
|
const api = new RecipeSidebarApiClient();
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => {
|
||||||
|
if (typeof fallback === 'string') {
|
||||||
|
// Apply {param} interpolation so counts remain assertable.
|
||||||
|
return Object.entries(params || {}).reduce(
|
||||||
|
(text, [name, value]) => text.replaceAll(`{${name}}`, String(value)),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function getShowRematchSummary() {
|
||||||
|
const { showRematchSummary } = await import(
|
||||||
|
'../../../static/js/components/RematchSummaryModal.js'
|
||||||
|
);
|
||||||
|
return showRematchSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const L4_LORA = { recipe_id: 'r1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 2 };
|
||||||
|
const L4_CHECKPOINT = { recipe_id: 'r2', type: 'checkpoint', entry: 'cp-old', file_name: 'cp-new.safetensors' };
|
||||||
|
|
||||||
|
describe('RematchSummaryModal', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
delete global.fetch;
|
||||||
|
delete navigator.clipboard;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a success header when everything matched cleanly', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'global', total: 10, matchedRecipes: 2, matchedEntries: 3 });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal).not.toBeNull();
|
||||||
|
expect(modal.querySelector('.summary-header').classList.contains('success')).toBe(true);
|
||||||
|
expect(modal.querySelector('.summary-title').textContent).toBe('Matched 3 entries');
|
||||||
|
expect(modal.querySelector('.failure-table')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an error header when nothing matched and errors occurred', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'global', total: 3, errors: 3 });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||||
|
expect(modal.querySelector('.summary-title').textContent).toBe('Rematch failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a warning header for unresolved entries, L4 matches, or cancellations', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 1, unresolvedEntries: 1, unresolvedRecipes: 1 });
|
||||||
|
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA] });
|
||||||
|
expect(document.querySelector('#rematchSummaryModal .summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'global', total: 5, matchedEntries: 2, cancelled: true });
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(modal.querySelector('.rematch-cancelled-note')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the four stat cards in order', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total: 4,
|
||||||
|
matchedRecipes: 1,
|
||||||
|
matchedEntries: 2,
|
||||||
|
unresolvedEntries: 3,
|
||||||
|
errors: 1,
|
||||||
|
l4Matches: [L4_LORA],
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
const values = Array.from(modal.querySelectorAll('.stat-card-value')).map(el => el.textContent);
|
||||||
|
expect(values).toEqual(['2', '1', '3', '1']);
|
||||||
|
const labels = Array.from(modal.querySelectorAll('.stat-card-label')).map(el => el.textContent);
|
||||||
|
expect(labels).toEqual(['Matched entries', 'Needs review', 'Unresolved', 'Errors']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the L4 review table only when matches exist', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 2, matchedEntries: 2, l4Matches: [L4_LORA, L4_CHECKPOINT] });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
const rows = modal.querySelectorAll('.failure-table tbody tr');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(rows[0].textContent).toContain('r1');
|
||||||
|
expect(rows[0].textContent).toContain('old.safetensors');
|
||||||
|
expect(rows[0].textContent).toContain('new.safetensors');
|
||||||
|
expect(rows[1].textContent).toContain('cp-new.safetensors');
|
||||||
|
expect(modal.querySelectorAll('.rematch-undo-btn')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo posts to the lora restore endpoint, then strikes and disables the row', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
const row = modal.querySelector('tr[data-l4-index="0"]');
|
||||||
|
const button = row.querySelector('.rematch-undo-btn');
|
||||||
|
button.click();
|
||||||
|
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/lora/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ recipe_id: 'r1', lora_index: 2 }),
|
||||||
|
});
|
||||||
|
expect(row.classList.contains('undone')).toBe(true);
|
||||||
|
expect(button.textContent).toBe('Undone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undo posts to the checkpoint restore endpoint with recipe_id only', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_CHECKPOINT] });
|
||||||
|
|
||||||
|
const button = document.querySelector('.rematch-undo-btn');
|
||||||
|
button.click();
|
||||||
|
await vi.waitFor(() => expect(button.disabled).toBe(true));
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/checkpoint/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ recipe_id: 'r2' }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the row actionable and toasts when undo fails', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: false, error: 'no snapshot' }) });
|
||||||
|
|
||||||
|
showRematchSummary({ scope: 'bulk', total: 1, matchedEntries: 1, l4Matches: [L4_LORA] });
|
||||||
|
|
||||||
|
const row = document.querySelector('tr[data-l4-index="0"]');
|
||||||
|
const button = row.querySelector('.rematch-undo-btn');
|
||||||
|
button.click();
|
||||||
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalled());
|
||||||
|
|
||||||
|
expect(button.disabled).toBe(false);
|
||||||
|
expect(row.classList.contains('undone')).toBe(false);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'modals.rematchResults.undoFailed',
|
||||||
|
{ message: 'no snapshot' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copy report includes scope, counts and the L4 list with undo status', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
global.fetch.mockResolvedValue({ ok: true, json: async () => ({ success: true }) });
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
navigator.clipboard = { writeText };
|
||||||
|
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total: 2,
|
||||||
|
matchedRecipes: 1,
|
||||||
|
matchedEntries: 2,
|
||||||
|
unresolvedEntries: 1,
|
||||||
|
unresolvedRecipes: 1,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
l4Matches: [L4_LORA, L4_CHECKPOINT],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Undo the first row before copying so the report carries its status.
|
||||||
|
const undoButton = document.querySelector('tr[data-l4-index="0"] .rematch-undo-btn');
|
||||||
|
undoButton.click();
|
||||||
|
await vi.waitFor(() => expect(undoButton.disabled).toBe(true));
|
||||||
|
|
||||||
|
document.querySelector('[data-action="copy-report"]').click();
|
||||||
|
await vi.waitFor(() => expect(writeText).toHaveBeenCalled());
|
||||||
|
|
||||||
|
const report = writeText.mock.calls[0][0];
|
||||||
|
expect(report).toContain('Scope: Selected recipes');
|
||||||
|
expect(report).toContain('Total recipes: 2');
|
||||||
|
expect(report).toContain('Matched entries: 2');
|
||||||
|
expect(report).toContain('Needs review (filename matches): 2');
|
||||||
|
expect(report).toContain('Unresolved entries: 1 (in 1 recipes)');
|
||||||
|
expect(report).toContain('[r1] old.safetensors -> new.safetensors [undone]');
|
||||||
|
expect(report).toContain('[r2] cp-old -> cp-new.safetensors');
|
||||||
|
// The success toast fires in the writeText .then() microtask.
|
||||||
|
await vi.waitFor(() => expect(showToastMock).toHaveBeenCalledWith('toast.api.copiedToClipboard', {}, 'success'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('close removes the modal from the DOM', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({ scope: 'single', total: 1, matchedEntries: 1 });
|
||||||
|
|
||||||
|
expect(document.getElementById('rematchSummaryModal')).not.toBeNull();
|
||||||
|
document.querySelector('[data-action="close-modal"].cancel-btn').click();
|
||||||
|
expect(document.getElementById('rematchSummaryModal')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes HTML in L4 row fields', async () => {
|
||||||
|
const showRematchSummary = await getShowRematchSummary();
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total: 1,
|
||||||
|
matchedEntries: 1,
|
||||||
|
l4Matches: [{ recipe_id: 'r<x>', type: 'lora', entry: '<img src=x>', file_name: 'f.safetensors', lora_index: 0 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const modal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(modal.querySelector('.failure-table img')).toBeNull();
|
||||||
|
expect(modal.querySelector('.failure-table tbody tr').textContent).toContain('<img src=x>');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -253,37 +253,4 @@ describe('AutoComplete active-filters flag', () => {
|
|||||||
|
|
||||||
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('broadcasts a setting-toggled window event when /activefilters is accepted', async () => {
|
|
||||||
const events = [];
|
|
||||||
const listener = (event) => events.push(event.detail);
|
|
||||||
window.addEventListener('lora-manager:setting-toggled', listener);
|
|
||||||
try {
|
|
||||||
const input = document.createElement('textarea');
|
|
||||||
input.value = '/activefilters';
|
|
||||||
input.selectionStart = input.value.length;
|
|
||||||
input.focus = vi.fn();
|
|
||||||
input.setSelectionRange = vi.fn();
|
|
||||||
document.body.append(input);
|
|
||||||
|
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
|
|
||||||
|
|
||||||
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
|
||||||
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
|
|
||||||
// The command token is cleared after acceptance; simulate the caret
|
|
||||||
// helper seeing the cleared input so the synthetic input event does
|
|
||||||
// not re-trigger command parsing (same pattern as behavior tests).
|
|
||||||
caretHelperInstance.getBeforeCursor.mockReturnValue('');
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
expect(events).toContainEqual({
|
|
||||||
settingId: 'loramanager.lora_active_filters_autocomplete',
|
|
||||||
value: true,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
window.removeEventListener('lora-manager:setting-toggled', listener);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
API_MODULE,
|
||||||
|
APP_MODULE,
|
||||||
|
AUTOCOMPLETE_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
|
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: {
|
||||||
|
fetchApi: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: {
|
||||||
|
canvas: {
|
||||||
|
ds: { scale: 1 },
|
||||||
|
},
|
||||||
|
extensionManager: {
|
||||||
|
setting: {
|
||||||
|
get: vi.fn(),
|
||||||
|
set: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registerExtension: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('formatAutocompleteTextOnBlur', () => {
|
||||||
|
it('preserves repeated spaces inside LoRA names', async () => {
|
||||||
|
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
|
||||||
|
expect(formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>')).toBe(
|
||||||
|
'<lora:test - 0021:1.00>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves repeated spaces across multiple LoRA entries', async () => {
|
||||||
|
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
formatAutocompleteTextOnBlur('<lora:test - 0021:1.00>,<lora:a b:0.50>')
|
||||||
|
).toBe('<lora:test - 0021:1.00>, <lora:a b:0.50>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still normalizes whitespace outside LoRA tags', async () => {
|
||||||
|
const { formatAutocompleteTextOnBlur } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
|
||||||
|
expect(formatAutocompleteTextOnBlur('masterpiece, best quality')).toBe(
|
||||||
|
'masterpiece, best quality'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -143,6 +143,16 @@ async function flushAsyncTasks() {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The real RematchModalManager runs against the mocked modalManager; the
|
||||||
|
// global rematch menu action now opens the options dialog first and only
|
||||||
|
// starts once confirmOptions() is invoked (the user clicking Rematch).
|
||||||
|
async function getRematchModalManager() {
|
||||||
|
const { rematchModalManager } = await import(
|
||||||
|
'../../../static/js/managers/RematchModalManager.js'
|
||||||
|
);
|
||||||
|
return rematchModalManager;
|
||||||
|
}
|
||||||
|
|
||||||
function createDeferred() {
|
function createDeferred() {
|
||||||
let resolve;
|
let resolve;
|
||||||
let reject;
|
let reject;
|
||||||
@@ -2223,7 +2233,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
|
expect(downloadExampleImagesApiMock).toHaveBeenCalledWith(['abc123hash'], null, { force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs global recipe rematch with polling and toasts the rematched count', async () => {
|
it('runs global recipe rematch with polling and opens the summary modal', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2266,27 +2276,44 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
// The click only opens the options dialog — nothing starts yet.
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
||||||
|
|
||||||
|
const rematchModalManager = await getRematchModalManager();
|
||||||
|
const runPromise = rematchModalManager.confirmOptions();
|
||||||
expect(rematchItem.classList.contains('disabled')).toBe(true);
|
expect(rematchItem.classList.contains('disabled')).toBe(true);
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
}
|
}
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
|
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipes/rematch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ relaxed: false }),
|
||||||
});
|
});
|
||||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
|
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipes/rematch-progress');
|
||||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
|
expect(progressUI.showCancelButton).toHaveBeenCalledTimes(1);
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes.');
|
// A non-noop run opens the summary modal instead of toasting; the
|
||||||
// Oracle R4-F1 pin: count comes from `rematched`, a blind `repaired` mirror renders undefined
|
// progress overlay completes without a message.
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'globalContextMenu.rematchRecipes.success',
|
'globalContextMenu.rematchRecipes.success',
|
||||||
{ count: 2, recipes: 2, entries: 5, failures: 0 },
|
expect.anything(),
|
||||||
'success'
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// unresolved_entries > 0 forces the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('5');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('0');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
|
||||||
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
|
expect(window.recipesPage.refresh).toHaveBeenCalledTimes(1);
|
||||||
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
expect(rematchItem.classList.contains('disabled')).toBe(false);
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
@@ -2295,7 +2322,7 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the warning toast variant when a global rematch completes with failures', async () => {
|
it('opens the summary modal with a warning header when a global rematch completes with failures', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2331,24 +2358,28 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const rematchModalManager = await getRematchModalManager();
|
||||||
|
const runPromise = rematchModalManager.confirmOptions();
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
}
|
}
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Matched 5 entries across 2 recipes, 2 failed.');
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.successErrors',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ count: 2, recipes: 2, entries: 5, failures: 2 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'warning'
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||||
);
|
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete window.recipesPage;
|
delete window.recipesPage;
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an error when every recipe in a global rematch failed', async () => {
|
it('opens the summary modal with an error header when every recipe in a global rematch failed', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2384,24 +2415,28 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const rematchModalManager = await getRematchModalManager();
|
||||||
|
const runPromise = rematchModalManager.confirmOptions();
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
}
|
}
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch failed for 3 of 3 recipes.');
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.allFailed',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ total: 3, recipes: 0, entries: 0, failures: 3 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||||
'error'
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('3');
|
||||||
);
|
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete window.recipesPage;
|
delete window.recipesPage;
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an info message when a global rematch found no local matches', async () => {
|
it('opens the summary modal listing unresolved entries when a global rematch found no local matches', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2437,24 +2472,28 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const rematchModalManager = await getRematchModalManager();
|
||||||
|
const runPromise = rematchModalManager.confirmOptions();
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
}
|
}
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('No local match found for 2 entries in 1 recipes.');
|
expect(progressUI.complete).toHaveBeenCalledWith();
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.noMatch',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ entries: 2, recipes: 1, total: 3, failures: 0 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'info'
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||||
);
|
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete window.recipesPage;
|
delete window.recipesPage;
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts the rematched count when a global rematch is cancelled', async () => {
|
it('opens the summary modal marked as cancelled when a global rematch is cancelled', async () => {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<div id="globalContextMenu" class="context-menu">
|
<div id="globalContextMenu" class="context-menu">
|
||||||
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
<div class="context-menu-item" data-action="rematch-recipes"></div>
|
||||||
@@ -2489,17 +2528,22 @@ describe('Interaction-level regression coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
rematchItem.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const rematchModalManager = await getRematchModalManager();
|
||||||
|
const runPromise = rematchModalManager.confirmOptions();
|
||||||
|
|
||||||
for (let i = 0; i < 5; i++) {
|
for (let i = 0; i < 5; i++) {
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
}
|
}
|
||||||
|
await runPromise;
|
||||||
|
|
||||||
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
expect(progressUI.complete).toHaveBeenCalledWith('Rematch cancelled. 1 recipes updated (2 entries).');
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'globalContextMenu.rematchRecipes.cancelled',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ count: 1, recipes: 1, entries: 2 },
|
expect(summaryModal.querySelector('.rematch-cancelled-note')).not.toBeNull();
|
||||||
'info'
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
|
||||||
expect(menu._rematchInProgress).toBe(false);
|
expect(menu._rematchInProgress).toBe(false);
|
||||||
|
|
||||||
delete stateStub.currentPageType;
|
delete stateStub.currentPageType;
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ const setSettingValueMock = vi.fn();
|
|||||||
vi.mock(SETTINGS_MODULE, () => ({
|
vi.mock(SETTINGS_MODULE, () => ({
|
||||||
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
|
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
|
||||||
"loramanager.lora_active_filters_autocomplete",
|
"loramanager.lora_active_filters_autocomplete",
|
||||||
SETTING_TOGGLED_EVENT_NAME: "lora-manager:setting-toggled",
|
|
||||||
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
|
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
|
||||||
setLoraManagerSettingValue: setSettingValueMock,
|
setLoraManagerSettingValue: setSettingValueMock,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const showSimpleLoadingMock = vi.fn();
|
||||||
|
const hideLoadingMock = vi.fn();
|
||||||
|
const resetAndReloadMock = vi.fn();
|
||||||
|
const probeExtensionMock = vi.fn();
|
||||||
|
const delegateReimportMock = vi.fn();
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
virtualScroller: { items: [] },
|
||||||
|
loadingManager: {
|
||||||
|
showSimpleLoading: showSimpleLoadingMock,
|
||||||
|
hide: hideLoadingMock,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
setSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
updateRecipeMetadata: vi.fn(),
|
||||||
|
resetAndReload: resetAndReloadMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
|
||||||
|
moveManager: { showMoveModal: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
|
||||||
|
ModelContextMenuMixin: {
|
||||||
|
handleCommonMenuActions: vi.fn(() => false),
|
||||||
|
initNSFWSelector: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Keep the real getCivitaiImageInfo (gating logic under test); mock only the
|
||||||
|
// extension communication.
|
||||||
|
vi.mock('../../../static/js/utils/extensionReimportBridge.js', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
probeExtension: probeExtensionMock,
|
||||||
|
delegateReimport: delegateReimportMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RecipeContextMenu.reimportRecipe extension delegation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||||
|
<div class="context-menu-item" data-action="reimport"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
stateStub.virtualScroller.items = [
|
||||||
|
{
|
||||||
|
id: 'recipe-1',
|
||||||
|
file_path: '/recipes/recipe-1.webp',
|
||||||
|
title: 'Civitai Recipe',
|
||||||
|
source_path: 'https://civitai.com/images/12345',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'recipe-2',
|
||||||
|
file_path: '/recipes/recipe-2.webp',
|
||||||
|
title: 'Local Recipe',
|
||||||
|
source_path: '/data/imports/local.png',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, recipe_id: 'new-id', loras_count: 2 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createMenu() {
|
||||||
|
const { RecipeContextMenu } = await import(
|
||||||
|
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
|
||||||
|
);
|
||||||
|
return new RecipeContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('delegates to the extension for a CivitAI image source when licensed', async () => {
|
||||||
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
|
||||||
|
delegateReimportMock.mockResolvedValue({ completed: 1, failed: 0 });
|
||||||
|
|
||||||
|
const menu = await createMenu();
|
||||||
|
await menu.reimportRecipe('recipe-1');
|
||||||
|
|
||||||
|
expect(delegateReimportMock).toHaveBeenCalledWith([{
|
||||||
|
recipeId: 'recipe-1',
|
||||||
|
imageId: 12345,
|
||||||
|
imageUrl: 'https://civitai.com/images/12345',
|
||||||
|
title: 'Civitai Recipe',
|
||||||
|
}]);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the failure toast when the extension reports failures', async () => {
|
||||||
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
|
||||||
|
delegateReimportMock.mockResolvedValue({ completed: 0, failed: 1 });
|
||||||
|
|
||||||
|
const menu = await createMenu();
|
||||||
|
await menu.reimportRecipe('recipe-1');
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'recipes.contextMenu.reimport.failed',
|
||||||
|
{ message: 'Extension re-import failed' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the native path for non-CivitAI sources without probing', async () => {
|
||||||
|
const menu = await createMenu();
|
||||||
|
await menu.reimportRecipe('recipe-2');
|
||||||
|
|
||||||
|
expect(probeExtensionMock).not.toHaveBeenCalled();
|
||||||
|
expect(delegateReimportMock).not.toHaveBeenCalled();
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-2/reimport', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the native path when the extension is absent (probe timeout)', async () => {
|
||||||
|
probeExtensionMock.mockResolvedValue(null);
|
||||||
|
|
||||||
|
const menu = await createMenu();
|
||||||
|
await menu.reimportRecipe('recipe-1');
|
||||||
|
|
||||||
|
expect(delegateReimportMock).not.toHaveBeenCalled();
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the native path when the license is invalid', async () => {
|
||||||
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: false });
|
||||||
|
|
||||||
|
const menu = await createMenu();
|
||||||
|
await menu.reimportRecipe('recipe-1');
|
||||||
|
|
||||||
|
expect(delegateReimportMock).not.toHaveBeenCalled();
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the native path when delegation fails', async () => {
|
||||||
|
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
|
||||||
|
delegateReimportMock.mockRejectedValue(new Error('Extension re-import timed out'));
|
||||||
|
|
||||||
|
const menu = await createMenu();
|
||||||
|
await menu.reimportRecipe('recipe-1');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,6 +44,22 @@ const flushAsyncTasks = async (rounds = 5) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The single-recipe rematch now opens the options dialog first and only
|
||||||
|
// starts once confirmOptions() is invoked (the user clicking Rematch).
|
||||||
|
async function confirmRematchOptions() {
|
||||||
|
const { rematchModalManager } = await import(
|
||||||
|
'../../../static/js/managers/RematchModalManager.js'
|
||||||
|
);
|
||||||
|
return rematchModalManager.confirmOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRematchOptions() {
|
||||||
|
const { rematchModalManager } = await import(
|
||||||
|
'../../../static/js/managers/RematchModalManager.js'
|
||||||
|
);
|
||||||
|
rematchModalManager.cancelOptions();
|
||||||
|
}
|
||||||
|
|
||||||
describe('RecipeContextMenu.rematchRecipe', () => {
|
describe('RecipeContextMenu.rematchRecipe', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
@@ -69,8 +85,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
|
// Oracle R4-F1 pin: branches on `result.rematched > 0` — a blind `repaired`
|
||||||
// mirror would fire the skipped toast here.
|
// mirror would render 0 matched entries in the summary modal here.
|
||||||
it('posts to the per-recipe rematch endpoint and toasts the rematched count', async () => {
|
it('posts to the per-recipe rematch endpoint and opens the summary modal', async () => {
|
||||||
const menu = await createMenu();
|
const menu = await createMenu();
|
||||||
const card = document.getElementById('card');
|
const card = document.getElementById('card');
|
||||||
menu.showMenu(100, 100, card);
|
menu.showMenu(100, 100, card);
|
||||||
@@ -91,19 +107,33 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
|
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
// The click only opened the options dialog — nothing started yet.
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
|
expect(global.fetch).toHaveBeenNthCalledWith(1, '/api/lm/recipe/recipe-1/rematch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ relaxed: false }),
|
||||||
});
|
});
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
// Non-noop runs open the summary modal instead of toasting.
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchComplete',
|
'toast.recipes.rematchComplete',
|
||||||
{ rematched: 2, skipped: 0, total: 1, entries: 2, recipes: 1, failures: 0 },
|
expect.anything(),
|
||||||
'success'
|
expect.anything()
|
||||||
);
|
);
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('success')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('2');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
|
||||||
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
|
expect(global.fetch).toHaveBeenNthCalledWith(2, '/api/lm/recipe/recipe-1');
|
||||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
|
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/recipe-1.webp', {
|
||||||
id: 'recipe-1',
|
id: 'recipe-1',
|
||||||
@@ -111,7 +141,7 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an info message when the entries had no local match', async () => {
|
it('opens the summary modal when the entries had no local match', async () => {
|
||||||
const menu = await createMenu();
|
const menu = await createMenu();
|
||||||
const card = document.getElementById('card');
|
const card = document.getElementById('card');
|
||||||
menu.showMenu(100, 100, card);
|
menu.showMenu(100, 100, card);
|
||||||
@@ -126,12 +156,14 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchUnmatched',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ entries: 2, recipes: 1, total: 1 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'info'
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -155,6 +187,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
@@ -186,6 +220,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchFailed',
|
'toast.recipes.rematchFailed',
|
||||||
@@ -207,6 +243,8 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
.dispatchEvent(new Event('click', { bubbles: true }));
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
await flushAsyncTasks();
|
await flushAsyncTasks();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchFailed',
|
'toast.recipes.rematchFailed',
|
||||||
@@ -214,4 +252,91 @@ describe('RecipeContextMenu.rematchRecipe', () => {
|
|||||||
'error'
|
'error'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
|
||||||
|
const menu = await createMenu();
|
||||||
|
const card = document.getElementById('card');
|
||||||
|
menu.showMenu(100, 100, card);
|
||||||
|
|
||||||
|
document.body.insertAdjacentHTML(
|
||||||
|
'beforeend',
|
||||||
|
'<input type="checkbox" id="rematchOptionsRelaxed">'
|
||||||
|
);
|
||||||
|
|
||||||
|
global.fetch.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, rematched: 0, skipped: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('[data-action="rematch"]')
|
||||||
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
// The dialog resets the checkbox to unchecked on open; the user opts in.
|
||||||
|
document.getElementById('rematchOptionsRelaxed').checked = true;
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/rematch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ relaxed: true }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts nothing when the options dialog is cancelled', async () => {
|
||||||
|
const menu = await createMenu();
|
||||||
|
const card = document.getElementById('card');
|
||||||
|
menu.showMenu(100, 100, card);
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('[data-action="rematch"]')
|
||||||
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
|
await flushAsyncTasks();
|
||||||
|
await cancelRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
expect(global.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
|
||||||
|
const menu = await createMenu();
|
||||||
|
const card = document.getElementById('card');
|
||||||
|
menu.showMenu(100, 100, card);
|
||||||
|
|
||||||
|
const l4Matches = [
|
||||||
|
{ recipe_id: 'recipe-1', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
||||||
|
];
|
||||||
|
global.fetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, rematched: 1, matched_entries: 1, l4_matches: l4Matches }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'recipe-1', title: 'Updated Recipe' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
document
|
||||||
|
.querySelector('[data-action="rematch"]')
|
||||||
|
.dispatchEvent(new Event('click', { bubbles: true }));
|
||||||
|
|
||||||
|
await flushAsyncTasks();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
await flushAsyncTasks();
|
||||||
|
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// L4 matches to review force the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
|
||||||
|
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].textContent).toContain('old.safetensors');
|
||||||
|
expect(rows[0].textContent).toContain('new.safetensors');
|
||||||
|
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|||||||
stripLoraTags: vi.fn((text) => text),
|
stripLoraTags: vi.fn((text) => text),
|
||||||
sendPromptToWorkflow: vi.fn(),
|
sendPromptToWorkflow: vi.fn(),
|
||||||
sendGenParamsToWorkflow: vi.fn(),
|
sendGenParamsToWorkflow: vi.fn(),
|
||||||
|
// Keep the real predicate: the download-failure tests assert on its
|
||||||
|
// unresolvable-error classification.
|
||||||
|
isUnresolvableDownloadError: (message) =>
|
||||||
|
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
@@ -153,6 +157,16 @@ const hashInvalidLora = {
|
|||||||
hashInvalid: true,
|
hashInvalid: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Mirrors the shape served for page-imported recipes whose CivitAI version
|
||||||
|
// exposes no sha256: an exact modelVersionId but no modelId and no hash.
|
||||||
|
const versionOnlyLora = {
|
||||||
|
name: 'version-lora',
|
||||||
|
modelName: 'Version Only LoRA',
|
||||||
|
inLibrary: false,
|
||||||
|
modelVersionId: 3221586,
|
||||||
|
modelVersionName: 'V1 KREA-2',
|
||||||
|
};
|
||||||
|
|
||||||
const recipeWithResources = {
|
const recipeWithResources = {
|
||||||
id: 'recipe-resources',
|
id: 'recipe-resources',
|
||||||
file_path: '/recipes/resources.json',
|
file_path: '/recipes/resources.json',
|
||||||
@@ -171,6 +185,7 @@ const recipeWithResources = {
|
|||||||
hashInvalidLora,
|
hashInvalidLora,
|
||||||
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
|
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
|
||||||
hashOnlyLora,
|
hashOnlyLora,
|
||||||
|
versionOnlyLora,
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -281,6 +296,59 @@ describe('RecipeModal resource item interactions', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a download action alongside reconnect for a version-only LoRA', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
recipeModal.showRecipeDetails(recipeWithResources);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const item = document.querySelector('[data-lora-index="6"]');
|
||||||
|
expect(item).not.toBeNull();
|
||||||
|
expect(item.classList.contains('missing-locally')).toBe(true);
|
||||||
|
// Missing from the local library (badge) but still downloadable by its
|
||||||
|
// exact CivitAI version id, so the row offers Download as the primary
|
||||||
|
// action; Reconnect stays available for entries the user already has
|
||||||
|
// locally under a different hash.
|
||||||
|
expect(item.querySelector('.missing-badge')).not.toBeNull();
|
||||||
|
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||||
|
expect(item.querySelector('.lora-reconnect')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => {
|
||||||
|
const recipeModal = await createRecipeModal();
|
||||||
|
const requests = [];
|
||||||
|
// Isolated copy keeps mutations out of the shared fixture.
|
||||||
|
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
|
||||||
|
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
|
||||||
|
global.fetch = vi.fn(async (url) => {
|
||||||
|
requests.push(String(url));
|
||||||
|
if (String(url).includes('/civitai/model/version/3221586')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 3221586, modelId: 56789, name: 'V1 KREA-2' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
recipeModal.showRecipeDetails(isolatedRecipe);
|
||||||
|
await flushWiring();
|
||||||
|
|
||||||
|
const item = document.querySelector('[data-lora-index="6"]');
|
||||||
|
item.querySelector('.lora-download').click();
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
requests.some(u => u.includes('/civitai/model/version/3221586'))
|
||||||
|
).toBe(true);
|
||||||
|
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
|
||||||
|
'loras',
|
||||||
|
56789,
|
||||||
|
3221586,
|
||||||
|
expect.objectContaining({ source: 'recipe-modal' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not navigate when a missing LoRA row is clicked', async () => {
|
it('does not navigate when a missing LoRA row is clicked', async () => {
|
||||||
const recipeModal = await createRecipeModal();
|
const recipeModal = await createRecipeModal();
|
||||||
const navigateSpy = vi
|
const navigateSpy = vi
|
||||||
@@ -526,10 +594,12 @@ describe('RecipeModal resource item interactions', () => {
|
|||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 50));
|
||||||
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
|
expect(requests.some(r => r.url.includes('mark-hash-invalid'))).toBe(false);
|
||||||
|
|
||||||
// The entry keeps the download action and never flips to reconnect
|
// The entry keeps the download action and never flips to hash-invalid
|
||||||
|
// (reconnect is always present for missing entries now; the signal here
|
||||||
|
// is that the download action survives and no invalid badge appears)
|
||||||
const item = document.querySelector('[data-lora-index="1"]');
|
const item = document.querySelector('[data-lora-index="1"]');
|
||||||
expect(item.querySelector('.lora-download')).not.toBeNull();
|
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||||
expect(item.querySelector('.lora-reconnect')).toBeNull();
|
expect(item.querySelector('.invalid-hash-badge')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
|
it('offers download for hash-only LoRAs and resolves identifiers on demand', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { renderTemplate } from '../utils/domFixtures.js';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
setupAutoNewlineOnPaste: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
WS_ENDPOINTS: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
getStorageItem: vi.fn(() => true),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BatchImportManager directory browser (#1106)', () => {
|
||||||
|
let batchImportManager;
|
||||||
|
let fetchMock;
|
||||||
|
let showToast;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
renderTemplate('components/batch_import_modal.html');
|
||||||
|
|
||||||
|
fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ success: true }),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const uiHelpers = await import('../../../static/js/utils/uiHelpers.js');
|
||||||
|
showToast = uiHelpers.showToast;
|
||||||
|
|
||||||
|
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
|
||||||
|
batchImportManager = new batchModule.BatchImportManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function lastRequestBody() {
|
||||||
|
return JSON.parse(fetchMock.mock.calls.at(-1)[1].body);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens the browser with an empty path so the server picks the default', async () => {
|
||||||
|
// The old POSIX-only "/" initial path fails the access check on Windows.
|
||||||
|
document.getElementById('batchDirectoryInput').value = '';
|
||||||
|
|
||||||
|
batchImportManager.toggleDirectoryBrowser();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('');
|
||||||
|
expect(document.getElementById('batchDirectoryBrowser').style.display).toBe('block');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to the parent using the server-provided path (Windows-safe)', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
current_path: 'C:\\Users\\miao\\Pictures',
|
||||||
|
parent_path: 'C:\\Users\\miao',
|
||||||
|
directories: [],
|
||||||
|
image_files: [],
|
||||||
|
image_count: 0,
|
||||||
|
directory_count: 0,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await batchImportManager.loadDirectory('C:\\Users\\miao\\Pictures');
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
batchImportManager.navigateToParentDirectory();
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||||
|
expect(lastRequestBody().path).toBe('C:\\Users\\miao');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses to select a virtual level that has no current path (drive list)', async () => {
|
||||||
|
fetchMock.mockImplementation(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
current_path: '',
|
||||||
|
parent_path: null,
|
||||||
|
directories: [{ name: 'C:\\', path: 'C:\\', is_parent: false }],
|
||||||
|
image_files: [],
|
||||||
|
image_count: 0,
|
||||||
|
directory_count: 1,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await batchImportManager.loadDirectory('__drives__');
|
||||||
|
batchImportManager.selectCurrentDirectory();
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||||
|
expect(document.getElementById('batchDirectoryInput').value).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -73,12 +73,29 @@ vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
|||||||
getNsfwLevelSelector: vi.fn(),
|
getNsfwLevelSelector: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The real RematchModalManager runs against the mocked modalManager; confirm
|
||||||
|
// is invoked explicitly, mirroring the user clicking Rematch in the dialog.
|
||||||
|
async function confirmRematchOptions() {
|
||||||
|
const { rematchModalManager } = await import(
|
||||||
|
'../../../static/js/managers/RematchModalManager.js'
|
||||||
|
);
|
||||||
|
return rematchModalManager.confirmOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelRematchOptions() {
|
||||||
|
const { rematchModalManager } = await import(
|
||||||
|
'../../../static/js/managers/RematchModalManager.js'
|
||||||
|
);
|
||||||
|
rematchModalManager.cancelOptions();
|
||||||
|
}
|
||||||
|
|
||||||
describe('BulkManager.rematchSelectedRecipes', () => {
|
describe('BulkManager.rematchSelectedRecipes', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
stateStub.currentPageType = 'recipes';
|
stateStub.currentPageType = 'recipes';
|
||||||
stateStub.bulkMode = false;
|
stateStub.bulkMode = false;
|
||||||
stateStub.selectedModels.clear();
|
stateStub.selectedModels.clear();
|
||||||
|
document.body.innerHTML = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
async function createBulkManager() {
|
async function createBulkManager() {
|
||||||
@@ -91,9 +108,9 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
expect(bulk.actionConfig.recipes.rematchMetadata).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Oracle R4-F1 pin: the complete toast must branch on `rematched` — a blind
|
// Oracle R4-F1 pin: the summary modal must branch on `matched_entries` — a
|
||||||
// `repaired` mirror would fire the skipped toast with count 0 here.
|
// blind `repaired` mirror would render 0 matched entries here.
|
||||||
it('toasts the rematched count when the bulk rematch succeeds', async () => {
|
it('opens the summary modal when the bulk rematch succeeds', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -114,29 +131,41 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(rematchBulkModelsMock).toHaveBeenCalledWith([
|
expect(rematchBulkModelsMock).toHaveBeenCalledWith(
|
||||||
'/recipes/a.webp',
|
[
|
||||||
'/recipes/b.webp',
|
'/recipes/a.webp',
|
||||||
'/recipes/c.webp',
|
'/recipes/b.webp',
|
||||||
]);
|
'/recipes/c.webp',
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
],
|
||||||
|
{ relaxed: false }
|
||||||
|
);
|
||||||
|
// Non-noop runs open the summary modal instead of toasting.
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchComplete',
|
'toast.recipes.rematchComplete',
|
||||||
{ rematched: 4, skipped: 1, total: 3, entries: 4, recipes: 2, failures: 0 },
|
expect.anything(),
|
||||||
'success'
|
expect.anything()
|
||||||
);
|
);
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
expect.anything()
|
expect.anything()
|
||||||
);
|
);
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// unresolved_entries > 0 forces the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('4');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('1');
|
||||||
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('0');
|
||||||
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
|
expect(updateSingleItemMock).toHaveBeenCalledWith('/recipes/a.webp', rematchedRecipe);
|
||||||
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
expect(loadingManagerStub.showSimpleLoading).toHaveBeenCalled();
|
||||||
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
expect(loadingManagerStub.hide).toHaveBeenCalled();
|
||||||
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
expect(loadingManagerStub.restoreProgressBar).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the errors toast variant when the bulk rematch has failures', async () => {
|
it('opens the summary modal with a warning header when the bulk rematch has failures', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -155,15 +184,16 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchCompleteErrors',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ rematched: 3, skipped: 0, total: 2, entries: 3, recipes: 1, failures: 2 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'warning'
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('3');
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an error when every selected recipe failed to rematch', async () => {
|
it('opens the summary modal with an error header when every selected recipe failed to rematch', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -182,12 +212,12 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchAllFailed',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ total: 2, failures: 2 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('error')).toBe(true);
|
||||||
'error'
|
expect(summaryModal.querySelector('.stat-card-failure .stat-card-value').textContent).toBe('2');
|
||||||
);
|
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -195,7 +225,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('toasts an info message when entries had no local match', async () => {
|
it('opens the summary modal when entries had no local match', async () => {
|
||||||
const bulk = await createBulkManager();
|
const bulk = await createBulkManager();
|
||||||
stateStub.selectedModels.add('/recipes/a.webp');
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
stateStub.selectedModels.add('/recipes/b.webp');
|
stateStub.selectedModels.add('/recipes/b.webp');
|
||||||
@@ -215,12 +245,13 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
'toast.recipes.rematchUnmatched',
|
expect(summaryModal).not.toBeNull();
|
||||||
{ entries: 2, recipes: 1, total: 3 },
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
'info'
|
expect(summaryModal.querySelector('.stat-card-success .stat-card-value').textContent).toBe('0');
|
||||||
);
|
expect(summaryModal.querySelector('.stat-card-total .stat-card-value').textContent).toBe('2');
|
||||||
expect(showToastMock).not.toHaveBeenCalledWith(
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
@@ -243,6 +274,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
@@ -268,6 +300,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchFailed',
|
'toast.recipes.rematchFailed',
|
||||||
@@ -284,6 +317,7 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
|
rematchBulkModelsMock.mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
await bulk.rematchSelectedRecipes();
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
expect(showToastMock).toHaveBeenCalledWith(
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
'toast.recipes.rematchFailed',
|
'toast.recipes.rematchFailed',
|
||||||
@@ -319,4 +353,101 @@ describe('BulkManager.rematchSelectedRecipes', () => {
|
|||||||
);
|
);
|
||||||
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not start the rematch until the options dialog is confirmed', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
|
|
||||||
|
await bulk.rematchSelectedRecipes();
|
||||||
|
|
||||||
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
rematchBulkModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
total: 1,
|
||||||
|
rematched: 1,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
matched_recipes: 1,
|
||||||
|
matched_entries: 1,
|
||||||
|
recipes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
|
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends relaxed: true when the relaxed checkbox is checked', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
|
document.body.innerHTML = '<input type="checkbox" id="rematchOptionsRelaxed">';
|
||||||
|
|
||||||
|
rematchBulkModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
total: 1,
|
||||||
|
rematched: 0,
|
||||||
|
skipped: 1,
|
||||||
|
errors: 0,
|
||||||
|
recipes: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await bulk.rematchSelectedRecipes();
|
||||||
|
// The dialog resets the checkbox to unchecked on open; the user opts in.
|
||||||
|
document.getElementById('rematchOptionsRelaxed').checked = true;
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
|
expect(rematchBulkModelsMock).toHaveBeenCalledWith(['/recipes/a.webp'], { relaxed: true });
|
||||||
|
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts nothing when the options dialog is cancelled', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
|
|
||||||
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await cancelRematchOptions();
|
||||||
|
|
||||||
|
expect(rematchBulkModelsMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.recipes.rematchComplete',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists L4 filename matches in the summary modal with undo buttons', async () => {
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
|
|
||||||
|
const l4Matches = [
|
||||||
|
{ recipe_id: 'a', type: 'lora', entry: 'old.safetensors', file_name: 'new.safetensors', lora_index: 0 },
|
||||||
|
];
|
||||||
|
rematchBulkModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
total: 1,
|
||||||
|
rematched: 1,
|
||||||
|
skipped: 0,
|
||||||
|
errors: 0,
|
||||||
|
matched_recipes: 1,
|
||||||
|
matched_entries: 1,
|
||||||
|
recipes: [],
|
||||||
|
l4_matches: l4Matches,
|
||||||
|
});
|
||||||
|
|
||||||
|
await bulk.rematchSelectedRecipes();
|
||||||
|
await confirmRematchOptions();
|
||||||
|
|
||||||
|
const summaryModal = document.getElementById('rematchSummaryModal');
|
||||||
|
expect(summaryModal).not.toBeNull();
|
||||||
|
// L4 matches to review force the warning header
|
||||||
|
expect(summaryModal.querySelector('.summary-header').classList.contains('warning')).toBe(true);
|
||||||
|
expect(summaryModal.querySelector('.stat-card-skipped .stat-card-value').textContent).toBe('1');
|
||||||
|
const rows = summaryModal.querySelectorAll('tr[data-l4-index]');
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].textContent).toContain('old.safetensors');
|
||||||
|
expect(rows[0].textContent).toContain('new.safetensors');
|
||||||
|
expect(rows[0].querySelector('.rematch-undo-btn[data-action="undo-match"][data-index="0"]')).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const MODULE = '../../../static/js/managers/BulkMissingLoraDownloadManager.js';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const updateProgressMock = vi.fn();
|
||||||
|
const updateSingleItemMock = vi.fn();
|
||||||
|
|
||||||
|
const mockApiClient = {
|
||||||
|
downloadModel: vi.fn(),
|
||||||
|
cancelDownload: vi.fn(),
|
||||||
|
fetchModelRoots: vi.fn(() => Promise.resolve({ roots: ['/models/loras'] })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showDownloadProgress: vi.fn(() => updateProgressMock),
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
showCancelButton: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
// Keep the real predicate: these tests assert on its classification.
|
||||||
|
isUnresolvableDownloadError: (message) =>
|
||||||
|
!!message && /(not found|no longer available|deleted|removed|404|410|gone)/.test(String(message).toLowerCase()),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: vi.fn((_, __, fallback) => fallback ?? ''),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: vi.fn(() => mockApiClient),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
extractRecipeId: (filePath) => {
|
||||||
|
if (!filePath) return null;
|
||||||
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
|
const dotIndex = basename.lastIndexOf('.');
|
||||||
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: {
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: { updateSingleItem: updateSingleItemMock },
|
||||||
|
global: { settings: {} },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Mirrors the FakeWebSocket pattern from downloadManager.batchSummary.test.js. */
|
||||||
|
class FakeWebSocket {
|
||||||
|
static instances = [];
|
||||||
|
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.onopen = null;
|
||||||
|
this.onmessage = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.close = vi.fn();
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (this.onopen) this.onopen();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeRecipe = (filePath, loras) => ({ file_path: filePath, loras });
|
||||||
|
|
||||||
|
describe('BulkMissingLoraDownloadManager unresolvable-failure write-back', () => {
|
||||||
|
let manager;
|
||||||
|
let fetchMock;
|
||||||
|
let requests;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
vi.clearAllMocks();
|
||||||
|
loadingManagerStub.showDownloadProgress.mockReturnValue(updateProgressMock);
|
||||||
|
|
||||||
|
requests = [];
|
||||||
|
fetchMock = vi.fn((url, options) => {
|
||||||
|
requests.push({ url, options });
|
||||||
|
if (url === '/api/lm/recipe/lora/mark-hash-invalid') {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
}
|
||||||
|
// Recipe detail refresh after the download loop
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({ id: 'refreshed' }) });
|
||||||
|
});
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
({ bulkMissingLoraDownloadManager: manager } = await import(MODULE));
|
||||||
|
manager.pendingLoras = [];
|
||||||
|
manager.pendingRecipes = [];
|
||||||
|
manager.pendingMissingByRecipe = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const primePending = (recipes) => {
|
||||||
|
const stats = manager.collectMissingLoras(recipes);
|
||||||
|
manager.pendingRecipes = recipes;
|
||||||
|
manager.pendingMissingByRecipe = stats.missingLorasByRecipe;
|
||||||
|
return stats.uniqueLoras;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('marks every recipe occurrence hash-invalid when the failure is unresolvable', async () => {
|
||||||
|
const entryA = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||||
|
const entryB = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||||
|
const recipe1 = makeRecipe('/recipes/r1.json', [entryA]);
|
||||||
|
const recipe2 = makeRecipe('/recipes/r2.json', [{ hash: 'x', file_name: 'keep.safetensors', inLibrary: true }, entryB]);
|
||||||
|
const uniqueLoras = primePending([recipe1, recipe2]);
|
||||||
|
|
||||||
|
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Model not found' });
|
||||||
|
|
||||||
|
await manager.executeDownload(uniqueLoras);
|
||||||
|
|
||||||
|
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
|
||||||
|
expect(markCalls).toHaveLength(2);
|
||||||
|
const payloads = markCalls.map(r => JSON.parse(r.options.body));
|
||||||
|
expect(payloads).toContainEqual({ recipe_id: 'r1', lora_index: 0 });
|
||||||
|
expect(payloads).toContainEqual({ recipe_id: 'r2', lora_index: 1 });
|
||||||
|
expect(entryA.hashInvalid).toBe(true);
|
||||||
|
expect(entryB.hashInvalid).toBe(true);
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.recipes.unresolvableMarkedForReconnect',
|
||||||
|
{ count: 2 },
|
||||||
|
'info',
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves entries untouched when the failure is transient', async () => {
|
||||||
|
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||||
|
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
||||||
|
const uniqueLoras = primePending([recipe]);
|
||||||
|
|
||||||
|
mockApiClient.downloadModel.mockResolvedValue({ success: false, error: 'Connection timed out' });
|
||||||
|
|
||||||
|
await manager.executeDownload(uniqueLoras);
|
||||||
|
|
||||||
|
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
|
||||||
|
expect(entry.hashInvalid).toBeUndefined();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.recipes.unresolvableMarkedForReconnect',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks hash-invalid when the download request itself throws an unresolvable error', async () => {
|
||||||
|
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||||
|
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
||||||
|
const uniqueLoras = primePending([recipe]);
|
||||||
|
|
||||||
|
mockApiClient.downloadModel.mockRejectedValue(new Error('410 Gone'));
|
||||||
|
|
||||||
|
await manager.executeDownload(uniqueLoras);
|
||||||
|
|
||||||
|
const markCalls = requests.filter(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid');
|
||||||
|
expect(markCalls).toHaveLength(1);
|
||||||
|
expect(entry.hashInvalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not mark entries whose download succeeds', async () => {
|
||||||
|
const entry = { hash: 'abc123', file_name: 'a.safetensors', inLibrary: false, modelId: 1, id: 10 };
|
||||||
|
const recipe = makeRecipe('/recipes/r1.json', [entry]);
|
||||||
|
const uniqueLoras = primePending([recipe]);
|
||||||
|
|
||||||
|
mockApiClient.downloadModel.mockResolvedValue({ success: true });
|
||||||
|
|
||||||
|
await manager.executeDownload(uniqueLoras);
|
||||||
|
|
||||||
|
expect(requests.some(r => r.url === '/api/lm/recipe/lora/mark-hash-invalid')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -17,7 +17,8 @@ vi.mock('../../../static/js/state/index.js', () => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
getCurrentPageState: vi.fn(() => ({ activeFolder: null, searchOptions: {} }))
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
@@ -162,4 +163,75 @@ describe('MoveManager', () => {
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should propagate the recalculated sub_type from the move response to the card', async () => {
|
||||||
|
// Setup state: moving a checkpoint into the unet root
|
||||||
|
moveManager.useDefaultPath = false;
|
||||||
|
moveManager.bulkFilePaths = null;
|
||||||
|
moveManager.currentFilePath = '/models/checkpoints/model.safetensors';
|
||||||
|
moveManager.modelRoots = ['/models/checkpoints', '/models/unet'];
|
||||||
|
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/unet">/models/unet</option>';
|
||||||
|
document.getElementById('moveModelRoot').value = '/models/unet';
|
||||||
|
moveManager.folderTreeManager.selectedPath = '';
|
||||||
|
|
||||||
|
const updateSingleItem = vi.fn();
|
||||||
|
state.virtualScroller = {
|
||||||
|
updateSingleItem,
|
||||||
|
removeMultipleItemsByFilePath: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
original_file_path: '/models/checkpoints/model.safetensors',
|
||||||
|
new_file_path: '/models/unet/model.safetensors',
|
||||||
|
cache_entry: { sub_type: 'diffusion_model' }
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveManager.moveModel();
|
||||||
|
|
||||||
|
expect(updateSingleItem).toHaveBeenCalledWith(
|
||||||
|
'/models/checkpoints/model.safetensors',
|
||||||
|
expect.objectContaining({
|
||||||
|
file_path: '/models/unet/model.safetensors',
|
||||||
|
sub_type: 'diffusion_model'
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete state.virtualScroller;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should omit sub_type from the card update when the response has no cache entry', async () => {
|
||||||
|
moveManager.useDefaultPath = false;
|
||||||
|
moveManager.bulkFilePaths = null;
|
||||||
|
moveManager.currentFilePath = '/models/loras/a.safetensors';
|
||||||
|
moveManager.modelRoots = ['/models/loras'];
|
||||||
|
document.getElementById('moveModelRoot').innerHTML = '<option value="/models/loras">/models/loras</option>';
|
||||||
|
document.getElementById('moveModelRoot').value = '/models/loras';
|
||||||
|
moveManager.folderTreeManager.selectedPath = '';
|
||||||
|
|
||||||
|
const updateSingleItem = vi.fn();
|
||||||
|
state.virtualScroller = {
|
||||||
|
updateSingleItem,
|
||||||
|
removeMultipleItemsByFilePath: vi.fn()
|
||||||
|
};
|
||||||
|
|
||||||
|
mockApiClient.moveSingleModel = vi.fn().mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
original_file_path: '/models/loras/a.safetensors',
|
||||||
|
new_file_path: '/models/loras/b/a.safetensors'
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await moveManager.moveModel();
|
||||||
|
|
||||||
|
expect(updateSingleItem).toHaveBeenCalledWith(
|
||||||
|
'/models/loras/a.safetensors',
|
||||||
|
expect.not.objectContaining({ sub_type: expect.anything() })
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
delete state.virtualScroller;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
|
||||||
|
const modalManagerMock = {
|
||||||
|
showModal: vi.fn(),
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: modalManagerMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function getManager() {
|
||||||
|
const { rematchModalManager } = await import(
|
||||||
|
'../../../static/js/managers/RematchModalManager.js'
|
||||||
|
);
|
||||||
|
return rematchModalManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RematchModalManager options dialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<p id="rematchOptionsMessage"></p>
|
||||||
|
<input type="checkbox" id="rematchOptionsRelaxed">
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not invoke the callback until confirmOptions is called', async () => {
|
||||||
|
const manager = await getManager();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
manager.showOptionsModal({ recipeCount: 3, onConfirm });
|
||||||
|
|
||||||
|
expect(modalManagerMock.showModal).toHaveBeenCalledWith('rematchOptionsModal');
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
// Bulk message mentions the selection size.
|
||||||
|
expect(document.getElementById('rematchOptionsMessage').textContent).toContain('3');
|
||||||
|
// The checkbox always starts unchecked.
|
||||||
|
expect(document.getElementById('rematchOptionsRelaxed').checked).toBe(false);
|
||||||
|
|
||||||
|
manager.confirmOptions();
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith({ relaxed: false });
|
||||||
|
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the generic message when no recipe count is given', async () => {
|
||||||
|
const manager = await getManager();
|
||||||
|
|
||||||
|
manager.showOptionsModal({ onConfirm: vi.fn() });
|
||||||
|
|
||||||
|
expect(translateMock).toHaveBeenCalledWith(
|
||||||
|
'modals.rematchOptions.messageGlobal',
|
||||||
|
{},
|
||||||
|
'All recipes will be scanned against your local model library.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the single-recipe message for scope: single', async () => {
|
||||||
|
const manager = await getManager();
|
||||||
|
|
||||||
|
manager.showOptionsModal({ scope: 'single', onConfirm: vi.fn() });
|
||||||
|
|
||||||
|
expect(translateMock).toHaveBeenCalledWith(
|
||||||
|
'modals.rematchOptions.messageSingle',
|
||||||
|
{},
|
||||||
|
'This recipe will be scanned against your local model library.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes relaxed: true when the checkbox is checked', async () => {
|
||||||
|
const manager = await getManager();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
manager.showOptionsModal({ onConfirm });
|
||||||
|
document.getElementById('rematchOptionsRelaxed').checked = true;
|
||||||
|
manager.confirmOptions();
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledWith({ relaxed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the checkbox to unchecked each time the dialog opens', async () => {
|
||||||
|
const manager = await getManager();
|
||||||
|
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||||
|
checkbox.checked = true;
|
||||||
|
|
||||||
|
manager.showOptionsModal({ onConfirm: vi.fn() });
|
||||||
|
|
||||||
|
expect(checkbox.checked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancelOptions runs nothing and clears the callback', async () => {
|
||||||
|
const manager = await getManager();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
|
||||||
|
manager.showOptionsModal({ onConfirm });
|
||||||
|
manager.cancelOptions();
|
||||||
|
|
||||||
|
expect(modalManagerMock.closeModal).toHaveBeenCalledWith('rematchOptionsModal');
|
||||||
|
// A later confirm must not fire the cancelled callback.
|
||||||
|
manager.confirmOptions();
|
||||||
|
expect(onConfirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
DOWNLOAD_MANAGER_MODULE,
|
||||||
|
MODAL_MANAGER_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
LOADING_MANAGER_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
STORAGE_HELPERS_MODULE,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE,
|
||||||
|
I18N_HELPERS_MODULE,
|
||||||
|
SUMMARY_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
|
||||||
|
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
|
||||||
|
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(MODAL_MANAGER_MODULE, () => ({
|
||||||
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||||
|
}));
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
setupAutoNewlineOnPaste: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: { global: { settings: {} }, loadingManager: {} },
|
||||||
|
}));
|
||||||
|
vi.mock(LOADING_MANAGER_MODULE, () => ({
|
||||||
|
LoadingManager: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
getModelApiClient: vi.fn(),
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock(STORAGE_HELPERS_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
|
||||||
|
FolderTreeManager: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
vi.mock(I18N_HELPERS_MODULE, () => ({
|
||||||
|
translate: vi.fn((_key, _vars, fallback) => fallback ?? ''),
|
||||||
|
}));
|
||||||
|
vi.mock(SUMMARY_MODULE, () => ({
|
||||||
|
showDownloadBatchSummary: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
|
||||||
|
|
||||||
|
describe('DownloadManager._resolveIsDiffusionModel', () => {
|
||||||
|
let manager;
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
manager = new DownloadManager();
|
||||||
|
manager.apiClient = { modelType: 'checkpoints' };
|
||||||
|
manager.selectedFile = null;
|
||||||
|
manager.selectedFiles = [];
|
||||||
|
manager.currentVersion = null;
|
||||||
|
fetchMock = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockRoutingResponse(data, ok = true) {
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok,
|
||||||
|
status: ok ? 200 : 500,
|
||||||
|
json: async () => data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('asks the backend and routes baseModel-only diffusion models to unet roots', async () => {
|
||||||
|
// The reported Anima case: file type is plain "Model".
|
||||||
|
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
|
||||||
|
mockRoutingResponse({ success: true, is_diffusion_model: true, root_kind: 'unet' });
|
||||||
|
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(true);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_type: 'checkpoint',
|
||||||
|
base_model: 'Anima',
|
||||||
|
file_types: ['Model'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the backend decision for regular checkpoints', async () => {
|
||||||
|
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] };
|
||||||
|
mockRoutingResponse({ success: true, is_diffusion_model: false, root_kind: 'checkpoint' });
|
||||||
|
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends only the selected file type when a file is selected', async () => {
|
||||||
|
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'UNet' }] };
|
||||||
|
manager.selectedFile = { type: 'UNet' };
|
||||||
|
mockRoutingResponse({ success: true, is_diffusion_model: true, root_kind: 'unet' });
|
||||||
|
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(true);
|
||||||
|
expect(JSON.parse(fetchMock.mock.calls[0][1].body).file_types).toEqual(['UNet']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the local file-type check when the endpoint fails', async () => {
|
||||||
|
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'UNet' }] };
|
||||||
|
fetchMock.mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to false when the endpoint fails and no local signal exists', async () => {
|
||||||
|
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
|
||||||
|
mockRoutingResponse({}, false);
|
||||||
|
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never calls the endpoint for non-checkpoint pages', async () => {
|
||||||
|
manager.apiClient = { modelType: 'loras' };
|
||||||
|
manager.currentVersion = { baseModel: 'Anima', files: [{ type: 'Model' }] };
|
||||||
|
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never calls the endpoint without version metadata (e.g. Hugging Face)', async () => {
|
||||||
|
expect(await manager._resolveIsDiffusionModel()).toBe(false);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -40,6 +40,15 @@ describe("applyLoraValuesToText", () => {
|
|||||||
|
|
||||||
expect(result).toBe("<lora:Expanded:1.00:1.00>");
|
expect(result).toBe("<lora:Expanded:1.00:1.00>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves repeated spaces inside LoRA names", () => {
|
||||||
|
const original = "<lora:test - 0021:1.00>";
|
||||||
|
const result = applyLoraValuesToText(original, [
|
||||||
|
{ name: "test - 0021", strength: 0.5 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result).toBe("<lora:test - 0021:0.50>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeStrengthValue", () => {
|
describe("normalizeStrengthValue", () => {
|
||||||
@@ -74,6 +83,18 @@ describe("cleanupLoraSyntax", () => {
|
|||||||
it("collapses whitespace and stray commas", () => {
|
it("collapses whitespace and stray commas", () => {
|
||||||
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
|
expect(cleanupLoraSyntax(" <lora:A:1.00> , ," )).toBe("<lora:A:1.00>");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves repeated spaces inside LoRA names", () => {
|
||||||
|
expect(cleanupLoraSyntax("<lora:test - 0021:1.00> , ,")).toBe(
|
||||||
|
"<lora:test - 0021:1.00>"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still normalizes whitespace between entries", () => {
|
||||||
|
expect(
|
||||||
|
cleanupLoraSyntax(" <lora:A:1.00> <lora:test - 0021:0.50> ")
|
||||||
|
).toBe("<lora:A:1.00> <lora:test - 0021:0.50>");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("debounce", () => {
|
describe("debounce", () => {
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
probeExtension,
|
||||||
|
delegateReimport,
|
||||||
|
getCivitaiImageInfo,
|
||||||
|
} from '../../../static/js/utils/extensionReimportBridge.js';
|
||||||
|
|
||||||
|
const dispatchedEvents = [];
|
||||||
|
|
||||||
|
function dispatchProtocolEvent(type, payload) {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent(type, { detail: JSON.stringify(payload) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Installs a fake extension that answers probes with the given result.
|
||||||
|
function installProbeResponder(result) {
|
||||||
|
const listener = () => dispatchProtocolEvent('lm:reimportProbeResult', result);
|
||||||
|
document.addEventListener('lm:reimportProbe', listener);
|
||||||
|
return () => document.removeEventListener('lm:reimportProbe', listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
dispatchedEvents.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('probeExtension', () => {
|
||||||
|
it('resolves null when no extension answers within the timeout', async () => {
|
||||||
|
const result = await probeExtension({ timeoutMs: 20 });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the probe result when the extension answers', async () => {
|
||||||
|
const uninstall = installProbeResponder({
|
||||||
|
supported: true,
|
||||||
|
licenseValid: true,
|
||||||
|
extensionVersion: '1.2.3',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await probeExtension({ timeoutMs: 1000 });
|
||||||
|
expect(result).toEqual({
|
||||||
|
supported: true,
|
||||||
|
licenseValid: true,
|
||||||
|
extensionVersion: '1.2.3',
|
||||||
|
reason: undefined,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
uninstall();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports unsupported/unlicensed answers verbatim', async () => {
|
||||||
|
const uninstall = installProbeResponder({
|
||||||
|
supported: false,
|
||||||
|
licenseValid: false,
|
||||||
|
reason: 'license expired',
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const result = await probeExtension({ timeoutMs: 1000 });
|
||||||
|
expect(result.supported).toBe(false);
|
||||||
|
expect(result.licenseValid).toBe(false);
|
||||||
|
expect(result.reason).toBe('license expired');
|
||||||
|
} finally {
|
||||||
|
uninstall();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores malformed probe results and times out', async () => {
|
||||||
|
const listener = () => {
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('lm:reimportProbeResult', { detail: '{broken json' })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
document.addEventListener('lm:reimportProbe', listener);
|
||||||
|
try {
|
||||||
|
const result = await probeExtension({ timeoutMs: 20 });
|
||||||
|
expect(result).toBeNull();
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener('lm:reimportProbe', listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delegateReimport', () => {
|
||||||
|
const recipes = [
|
||||||
|
{ recipeId: 'r1', imageId: 123, imageUrl: 'https://civitai.com/images/123', title: 'One' },
|
||||||
|
{ recipeId: 'r2', imageId: 456, imageUrl: 'https://civitai.com/images/456', title: 'Two' },
|
||||||
|
];
|
||||||
|
|
||||||
|
it('rejects immediately for an empty recipe list', async () => {
|
||||||
|
await expect(delegateReimport([])).rejects.toThrow('non-empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects on timeout when the extension never answers', async () => {
|
||||||
|
await expect(
|
||||||
|
delegateReimport(recipes, { timeoutMs: 20 })
|
||||||
|
).rejects.toThrow('timed out');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches the batch with a requestId and resolves on batchDone', async () => {
|
||||||
|
const progressEvents = [];
|
||||||
|
let seenRequest = null;
|
||||||
|
|
||||||
|
const listener = (event) => {
|
||||||
|
seenRequest = JSON.parse(event.detail);
|
||||||
|
const { requestId } = seenRequest;
|
||||||
|
// Progress for a DIFFERENT batch must be ignored.
|
||||||
|
dispatchProtocolEvent('lm:reimportProgress', {
|
||||||
|
requestId: 'other-batch',
|
||||||
|
current: 99,
|
||||||
|
total: 99,
|
||||||
|
recipeId: 'nope',
|
||||||
|
title: 'nope',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
dispatchProtocolEvent('lm:reimportProgress', {
|
||||||
|
requestId,
|
||||||
|
current: 1,
|
||||||
|
total: 2,
|
||||||
|
recipeId: 'r1',
|
||||||
|
title: 'One',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
dispatchProtocolEvent('lm:reimportProgress', {
|
||||||
|
requestId,
|
||||||
|
current: 2,
|
||||||
|
total: 2,
|
||||||
|
recipeId: 'r2',
|
||||||
|
title: 'Two',
|
||||||
|
status: 'failed',
|
||||||
|
message: 'boom',
|
||||||
|
});
|
||||||
|
dispatchProtocolEvent('lm:reimportBatchDone', {
|
||||||
|
requestId,
|
||||||
|
completed: 1,
|
||||||
|
failed: 1,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
document.addEventListener('lm:reimportViaExtension', listener);
|
||||||
|
try {
|
||||||
|
const result = await delegateReimport(recipes, {
|
||||||
|
onProgress: (progress) => progressEvents.push(progress),
|
||||||
|
timeoutMs: 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(seenRequest.recipes).toEqual(recipes);
|
||||||
|
expect(typeof seenRequest.requestId).toBe('string');
|
||||||
|
expect(seenRequest.requestId.length).toBeGreaterThan(0);
|
||||||
|
expect(result).toEqual({ completed: 1, failed: 1 });
|
||||||
|
// Only this batch's progress events reach the callback.
|
||||||
|
expect(progressEvents.map((p) => p.recipeId)).toEqual(['r1', 'r2']);
|
||||||
|
expect(progressEvents[1].status).toBe('failed');
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener('lm:reimportViaExtension', listener);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets the timeout on every progress heartbeat', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
let requestId = null;
|
||||||
|
const listener = (event) => {
|
||||||
|
requestId = JSON.parse(event.detail).requestId;
|
||||||
|
};
|
||||||
|
document.addEventListener('lm:reimportViaExtension', listener);
|
||||||
|
try {
|
||||||
|
const promise = delegateReimport(recipes, { timeoutMs: 1000 });
|
||||||
|
|
||||||
|
// At t=900ms a progress event arrives, pushing the deadline to t=1900ms.
|
||||||
|
await vi.advanceTimersByTimeAsync(900);
|
||||||
|
dispatchProtocolEvent('lm:reimportProgress', {
|
||||||
|
requestId,
|
||||||
|
current: 1,
|
||||||
|
total: 2,
|
||||||
|
recipeId: 'r1',
|
||||||
|
title: 'One',
|
||||||
|
status: 'started',
|
||||||
|
});
|
||||||
|
// t=1800ms: past the original deadline, still alive thanks to heartbeat.
|
||||||
|
await vi.advanceTimersByTimeAsync(900);
|
||||||
|
dispatchProtocolEvent('lm:reimportBatchDone', {
|
||||||
|
requestId,
|
||||||
|
completed: 2,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({ completed: 2, failed: 0 });
|
||||||
|
} finally {
|
||||||
|
document.removeEventListener('lm:reimportViaExtension', listener);
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCivitaiImageInfo', () => {
|
||||||
|
it.each([
|
||||||
|
'https://civitai.com/images/12345',
|
||||||
|
'https://civitai.red/images/12345',
|
||||||
|
'https://civitai.green/images/12345',
|
||||||
|
'https://civitai.com/images/12345?foo=bar',
|
||||||
|
])('extracts the image id from %s', (url) => {
|
||||||
|
expect(getCivitaiImageInfo(url)).toEqual({ imageId: 12345, imageUrl: url });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
null,
|
||||||
|
'',
|
||||||
|
'not a url',
|
||||||
|
'ftp://civitai.com/images/12345',
|
||||||
|
'https://civitai.com/models/12345',
|
||||||
|
'https://example.com/images/12345',
|
||||||
|
'https://image.civitai.com/x/y/original=true/pic.png',
|
||||||
|
])('returns null for %s', (url) => {
|
||||||
|
expect(getCivitaiImageInfo(url)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user