mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
28 Commits
6b41c3bbb4
..
v1.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e2185c182 | |||
| 41302e75ba | |||
| a17399d667 | |||
| e2d85a0a21 | |||
| 303833bbae | |||
| f86b7b55d6 | |||
| 782bb53784 | |||
| 139231e225 | |||
| 121d8d5cea | |||
| ec147bd677 | |||
| 93fc28b499 | |||
| 7afed1a14b | |||
| e6f5142e48 | |||
| 87f05fb66c | |||
| cf64e5baa8 | |||
| 634ea7f299 | |||
| 6ba64ebb3c | |||
| 03569c62df | |||
| a61840b366 | |||
| 726fc178f1 | |||
| 8260bd022d | |||
| b309becdf9 | |||
| 1e375bb8d9 | |||
| 14da8a6f17 | |||
| da71985c3e | |||
| 7c4c8b8f30 | |||
| 77109b3cf8 | |||
| 00095a5398 |
@@ -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.
|
||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "{type} werden aktualisiert...",
|
||||||
|
"fullRebuilding": "{type} werden vollständig neu aufgebaut...",
|
||||||
|
"actionRefresh": "Aktualisierung",
|
||||||
|
"actionFullRebuild": "Vollständiger Neuaufbau",
|
||||||
|
"actionRefreshLower": "Aktualisieren",
|
||||||
|
"actionRebuildLower": "Neuaufbau",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "Ordner werden gescannt...",
|
||||||
|
"count_models": "{total} Dateien gefunden",
|
||||||
|
"process_models": "Modelle werden verarbeitet",
|
||||||
|
"reconcile_scan": "Änderungen werden geprüft...",
|
||||||
|
"process_new": "Neue Modelle werden verarbeitet",
|
||||||
|
"finalizing": "Abschließen..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "Weniger als eine Minute verbleibend",
|
||||||
|
"minutes": "~{minutes} Min. verbleibend",
|
||||||
|
"hours": "~{hours} Std. {minutes} Min. verbleibend"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Massenoperationen",
|
"title": "Massenoperationen",
|
||||||
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken. Wählen Sie mehrere Modelle aus und führen Sie Stapeloperationen durch. Mit <span class=\"onboarding-shortcut\">Strg+A</span> können Sie alle sichtbaren Modelle auswählen."
|
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken, um mehrere Modelle auszuwählen und Stapeloperationen durchzuführen.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> wählt alle sichtbaren Modelle aus, <span class=\"onboarding-shortcut\">Shift+Click</span> wählt einen Bereich aus.<br>• <span class=\"onboarding-shortcut\">Esc</span> oder ein Klick auf einen leeren Bereich verlässt den Massenmodus."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "Suchoptionen",
|
"title": "Suchoptionen",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "Kontextmenü",
|
"title": "Kontextmenü",
|
||||||
"content": "<strong>Rechtsklick</strong> auf eine Modellkarte öffnet ein Kontextmenü mit weiteren Aktionen."
|
"content": "<strong>Rechtsklick</strong> auf eine beliebige Modellkarte öffnet ein Kontextmenü mit Kartenaktionen wie Verschieben, Löschen oder Bearbeiten von Metadaten."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "Durch Ziehen auswählen",
|
||||||
|
"content": "Halten Sie die <strong>linke Maustaste</strong> auf einem leeren Bereich des Rasters gedrückt und ziehen Sie, um einen Auswahlrahmen aufzuziehen, der mehrere Karten gleichzeitig auswählt."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "Organisieren durch Ziehen",
|
||||||
|
"content": "Ziehen Sie eine Modellkarte auf einen Ordner in der Seitenleiste, um die Datei dorthin zu verschieben. Dies funktioniert auch mit mehreren ausgewählten Karten im Massenmodus."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "Weitere Kontextmenüs",
|
||||||
|
"content": "<strong>Rechtsklick auf eine ausgewählte Karte</strong> im Massenmodus öffnet die Massenaktionen. <strong>Rechtsklick auf einen leeren Bereich</strong> der Seite öffnet globale Aktionen wie das Prüfen auf Updates und das Verwalten ausgeschlossener Modelle."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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...",
|
||||||
@@ -451,6 +477,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",
|
||||||
@@ -786,7 +814,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",
|
||||||
@@ -842,7 +869,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",
|
||||||
@@ -1095,13 +1121,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",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Wert",
|
"valuePlaceholder": "Wert",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
|
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y",
|
||||||
|
"invalidValue": "Bitte geben Sie eine gültige Zahl ein",
|
||||||
|
"saveFailed": "Fehler beim Speichern des voreingestellten Parameters",
|
||||||
|
"added": "Voreingestellter Parameter hinzugefügt",
|
||||||
|
"updated": "Voreingestellter Parameter aktualisiert"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Trigger Words",
|
"label": "Trigger Words",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
|
||||||
"editWord": "Trigger Word bearbeiten",
|
"editWord": "Trigger Word bearbeiten",
|
||||||
"editPlaceholder": "Trigger Word bearbeiten",
|
"editPlaceholder": "Trigger Word bearbeiten",
|
||||||
"copyWord": "Trigger Word kopieren",
|
"copyOrEditWord": "Klicken zum Kopieren, Doppelklick zum Bearbeiten",
|
||||||
"deleteWord": "Trigger Word löschen",
|
"deleteWord": "Trigger Word löschen",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "Keine Vorschläge verfügbar",
|
"noSuggestions": "Keine Vorschläge verfügbar",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "Erste Schritte",
|
"gettingStarted": "Erste Schritte",
|
||||||
"updateVlogs": "Update-Vlogs",
|
"updateVlogs": "Update-Vlogs",
|
||||||
"documentation": "Dokumentation"
|
"documentation": "Dokumentation",
|
||||||
|
"shortcuts": "Tastenkürzel"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Erste Schritte mit LoRA Manager"
|
"title": "Erste Schritte mit LoRA Manager",
|
||||||
|
"replayTutorial": "Tutorial erneut abspielen"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Tastatur- & Mauskürzel",
|
||||||
|
"groups": {
|
||||||
|
"general": "Allgemein",
|
||||||
|
"actions": "Aktionen",
|
||||||
|
"selection": "Auswahl & Massenmodus",
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"modelModal": "Modell- / Rezept-Dialog",
|
||||||
|
"mediaViewer": "Medienanzeige / Beispielgalerie"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "Klick",
|
||||||
|
"drag": "Ziehen",
|
||||||
|
"rightClick": "Rechtsklick",
|
||||||
|
"letter": "Buchstabe",
|
||||||
|
"swipe": "Wischen"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "Suche fokussieren",
|
||||||
|
"closeModal": "Dialog / Panel schließen",
|
||||||
|
"openShortcuts": "Dieses Tastenkürzel-Panel öffnen",
|
||||||
|
"refresh": "Modellliste aktualisieren",
|
||||||
|
"fetchMetadata": "Metadaten von CivitAI abrufen (nur Modellseiten)",
|
||||||
|
"downloadModel": "Ein Modell herunterladen (nur Modellseiten)",
|
||||||
|
"toggleBulkMode": "Massenmodus umschalten",
|
||||||
|
"selectAll": "Alle sichtbaren Modelle auswählen",
|
||||||
|
"rangeSelect": "Bereich auswählen",
|
||||||
|
"marqueeSelect": "Karten mit Auswahlrahmen auswählen (auf leerem Rasterbereich)",
|
||||||
|
"exitBulkMode": "Massenmodus verlassen",
|
||||||
|
"bulkActions": "Auf ausgewählter Karte: Menü für Massenaktionen",
|
||||||
|
"globalActions": "Auf leerem Seitenbereich: Menü für globale Aktionen (Updates prüfen, ausgeschlossene Modelle verwalten)",
|
||||||
|
"scrollPages": "Seiten scrollen",
|
||||||
|
"jumpAlphabet": "Zur Alphabetleiste springen",
|
||||||
|
"prevNext": "Vorheriges / nächstes Modell",
|
||||||
|
"deleteEntry": "Löschen",
|
||||||
|
"cycleMedia": "Medien durchblättern ([ / ] in der Beispielgalerie)",
|
||||||
|
"swipeTouch": "Medien auf Touch-Geräten durchblättern",
|
||||||
|
"closeViewer": "Medienanzeige schließen"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "Neueste Updates",
|
"title": "Neueste Updates",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "Einstellungen & Konfiguration",
|
"settings": "Einstellungen & Konfiguration",
|
||||||
"extensions": "Erweiterungen",
|
"extensions": "Erweiterungen",
|
||||||
"newBadge": "NEU"
|
"newBadge": "NEU"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "NEU"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Nach Updates suchen",
|
"title": "Nach Updates suchen",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"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",
|
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||||
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
||||||
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "Refreshing {type}s...",
|
||||||
|
"fullRebuilding": "Full rebuild {type}s...",
|
||||||
|
"actionRefresh": "Refresh",
|
||||||
|
"actionFullRebuild": "Full rebuild",
|
||||||
|
"actionRefreshLower": "refresh",
|
||||||
|
"actionRebuildLower": "rebuild",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "Scanning folders...",
|
||||||
|
"count_models": "Found {total} files",
|
||||||
|
"process_models": "Processing models",
|
||||||
|
"reconcile_scan": "Checking for changes...",
|
||||||
|
"process_new": "Processing new models",
|
||||||
|
"finalizing": "Finalizing..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "Less than a minute remaining",
|
||||||
|
"minutes": "~{minutes} min remaining",
|
||||||
|
"hours": "~{hours} hr {minutes} min remaining"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Bulk Operations",
|
"title": "Bulk Operations",
|
||||||
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span>. Select multiple models and perform batch operations. Use <span class=\"onboarding-shortcut\">Ctrl+A</span> to select all visible models."
|
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span> to select multiple models and perform batch operations.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> select all visible models, <span class=\"onboarding-shortcut\">Shift+Click</span> select a range.<br>• <span class=\"onboarding-shortcut\">Esc</span> or clicking an empty area exits bulk mode."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "Search Options",
|
"title": "Search Options",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "Context Menu",
|
"title": "Context Menu",
|
||||||
"content": "<strong>Right-click</strong> any model card for a context menu with additional actions."
|
"content": "<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "Drag to Select",
|
||||||
|
"content": "Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "Organize by Dragging",
|
||||||
|
"content": "Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "More Context Menus",
|
||||||
|
"content": "In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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...",
|
||||||
@@ -451,6 +477,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",
|
||||||
@@ -786,7 +814,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",
|
||||||
@@ -842,7 +869,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",
|
||||||
@@ -1095,13 +1121,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",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Value",
|
"valuePlaceholder": "Value",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"invalidRange": "Invalid range format. Use x.x-y.y"
|
"invalidRange": "Invalid range format. Use x.x-y.y",
|
||||||
|
"invalidValue": "Please enter a valid number",
|
||||||
|
"saveFailed": "Failed to save preset parameter",
|
||||||
|
"added": "Preset parameter added",
|
||||||
|
"updated": "Preset parameter updated"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Trigger Words",
|
"label": "Trigger Words",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "Type to add or click suggestions below",
|
"addPlaceholder": "Type to add or click suggestions below",
|
||||||
"editWord": "Edit trigger word",
|
"editWord": "Edit trigger word",
|
||||||
"editPlaceholder": "Edit trigger word",
|
"editPlaceholder": "Edit trigger word",
|
||||||
"copyWord": "Copy trigger word",
|
"copyOrEditWord": "Click to copy, double-click to edit",
|
||||||
"deleteWord": "Delete trigger word",
|
"deleteWord": "Delete trigger word",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "No suggestions available",
|
"noSuggestions": "No suggestions available",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "Getting Started",
|
"gettingStarted": "Getting Started",
|
||||||
"updateVlogs": "Update Vlogs",
|
"updateVlogs": "Update Vlogs",
|
||||||
"documentation": "Documentation"
|
"documentation": "Documentation",
|
||||||
|
"shortcuts": "Shortcuts"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Getting Started with LoRA Manager"
|
"title": "Getting Started with LoRA Manager",
|
||||||
|
"replayTutorial": "Replay Tutorial"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Keyboard & Mouse Shortcuts",
|
||||||
|
"groups": {
|
||||||
|
"general": "General",
|
||||||
|
"actions": "Actions",
|
||||||
|
"selection": "Selection & Bulk Mode",
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"modelModal": "Model / Recipe Modal",
|
||||||
|
"mediaViewer": "Media Viewer / Showcase"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "Click",
|
||||||
|
"drag": "Drag",
|
||||||
|
"rightClick": "Right-click",
|
||||||
|
"letter": "Letter",
|
||||||
|
"swipe": "Swipe"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "Focus search",
|
||||||
|
"closeModal": "Close modal / panel",
|
||||||
|
"openShortcuts": "Open this shortcuts panel",
|
||||||
|
"refresh": "Refresh model list",
|
||||||
|
"fetchMetadata": "Fetch metadata from CivitAI (model pages only)",
|
||||||
|
"downloadModel": "Download a model (model pages only)",
|
||||||
|
"toggleBulkMode": "Toggle bulk mode",
|
||||||
|
"selectAll": "Select all visible models",
|
||||||
|
"rangeSelect": "Range select",
|
||||||
|
"marqueeSelect": "Marquee-select cards (on empty grid area)",
|
||||||
|
"exitBulkMode": "Exit bulk mode",
|
||||||
|
"bulkActions": "On selected card: bulk actions menu",
|
||||||
|
"globalActions": "On empty page area: global actions menu (update check, manage excluded models)",
|
||||||
|
"scrollPages": "Scroll pages",
|
||||||
|
"jumpAlphabet": "Jump alphabet bar",
|
||||||
|
"prevNext": "Previous / next model",
|
||||||
|
"deleteEntry": "Delete",
|
||||||
|
"cycleMedia": "Cycle media ([ / ] in showcase gallery)",
|
||||||
|
"swipeTouch": "Cycle media on touch devices",
|
||||||
|
"closeViewer": "Close viewer"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "Latest Updates",
|
"title": "Latest Updates",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "Settings & Configuration",
|
"settings": "Settings & Configuration",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"newBadge": "NEW"
|
"newBadge": "NEW"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "New"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Check for Updates",
|
"title": "Check for Updates",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"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",
|
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
|
||||||
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
||||||
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "Actualizando {type}...",
|
||||||
|
"fullRebuilding": "Reconstrucción completa de {type}...",
|
||||||
|
"actionRefresh": "Actualización",
|
||||||
|
"actionFullRebuild": "Reconstrucción completa",
|
||||||
|
"actionRefreshLower": "actualizar",
|
||||||
|
"actionRebuildLower": "reconstruir",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "Escaneando carpetas...",
|
||||||
|
"count_models": "Se encontraron {total} archivos",
|
||||||
|
"process_models": "Procesando modelos",
|
||||||
|
"reconcile_scan": "Comprobando cambios...",
|
||||||
|
"process_new": "Procesando modelos nuevos",
|
||||||
|
"finalizing": "Finalizando..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "Queda menos de un minuto",
|
||||||
|
"minutes": "Quedan ~{minutes} min",
|
||||||
|
"hours": "Quedan ~{hours} h {minutes} min"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Operaciones por lotes",
|
"title": "Operaciones por lotes",
|
||||||
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span>. Selecciona varios modelos y realiza operaciones por lotes. Usa <span class=\"onboarding-shortcut\">Ctrl+A</span> para seleccionar todos los modelos visibles."
|
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span> para seleccionar varios modelos y realizar operaciones por lotes.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> selecciona todos los modelos visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> selecciona un rango.<br>• <span class=\"onboarding-shortcut\">Esc</span> o hacer clic en un área vacía sale del modo por lotes."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "Opciones de búsqueda",
|
"title": "Opciones de búsqueda",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "Menú contextual",
|
"title": "Menú contextual",
|
||||||
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones adicionales."
|
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones de la tarjeta como mover, eliminar o editar metadatos."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "Arrastrar para seleccionar",
|
||||||
|
"content": "Mantén pulsado el <strong>botón izquierdo del ratón</strong> en un área vacía de la cuadrícula y arrastra para dibujar un rectángulo de selección que selecciona varias tarjetas a la vez."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "Organizar arrastrando",
|
||||||
|
"content": "Arrastra una tarjeta de modelo hasta una carpeta de la barra lateral para mover el archivo allí. Esto también funciona con varias tarjetas seleccionadas en el modo por lotes."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "Más menús contextuales",
|
||||||
|
"content": "En el modo por lotes, <strong>haz clic derecho en una tarjeta seleccionada</strong> para ver las acciones por lotes. <strong>Haz clic derecho en un área vacía</strong> de la página para ver acciones globales como comprobar actualizaciones y gestionar modelos excluidos."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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...",
|
||||||
@@ -451,6 +477,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",
|
||||||
@@ -786,7 +814,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",
|
||||||
@@ -842,7 +869,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",
|
||||||
@@ -1095,13 +1121,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",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Valor",
|
"valuePlaceholder": "Valor",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
|
"invalidRange": "Formato de rango inválido. Use x.x-y.y",
|
||||||
|
"invalidValue": "Introduce un número válido",
|
||||||
|
"saveFailed": "Error al guardar el parámetro preajustado",
|
||||||
|
"added": "Parámetro preajustado añadido",
|
||||||
|
"updated": "Parámetro preajustado actualizado"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Palabras clave",
|
"label": "Palabras clave",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
|
||||||
"editWord": "Editar palabra de activación",
|
"editWord": "Editar palabra de activación",
|
||||||
"editPlaceholder": "Editar palabra de activación",
|
"editPlaceholder": "Editar palabra de activación",
|
||||||
"copyWord": "Copiar palabra de activación",
|
"copyOrEditWord": "Haz clic para copiar, doble clic para editar",
|
||||||
"deleteWord": "Eliminar palabra de activación",
|
"deleteWord": "Eliminar palabra de activación",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "No hay sugerencias disponibles",
|
"noSuggestions": "No hay sugerencias disponibles",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "Comenzando",
|
"gettingStarted": "Comenzando",
|
||||||
"updateVlogs": "Vlogs de actualización",
|
"updateVlogs": "Vlogs de actualización",
|
||||||
"documentation": "Documentación"
|
"documentation": "Documentación",
|
||||||
|
"shortcuts": "Atajos"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Comenzando con el gestor de LoRA"
|
"title": "Comenzando con el gestor de LoRA",
|
||||||
|
"replayTutorial": "Repetir tutorial"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Atajos de teclado y ratón",
|
||||||
|
"groups": {
|
||||||
|
"general": "General",
|
||||||
|
"actions": "Acciones",
|
||||||
|
"selection": "Selección y modo por lotes",
|
||||||
|
"navigation": "Navegación",
|
||||||
|
"modelModal": "Modal de modelo / receta",
|
||||||
|
"mediaViewer": "Visor de medios / Ejemplos"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "Clic",
|
||||||
|
"drag": "Arrastrar",
|
||||||
|
"rightClick": "Clic derecho",
|
||||||
|
"letter": "Letra",
|
||||||
|
"swipe": "Deslizar"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "Enfocar la búsqueda",
|
||||||
|
"closeModal": "Cerrar modal / panel",
|
||||||
|
"openShortcuts": "Abrir este panel de atajos",
|
||||||
|
"refresh": "Actualizar la lista de modelos",
|
||||||
|
"fetchMetadata": "Obtener metadatos de CivitAI (solo páginas de modelos)",
|
||||||
|
"downloadModel": "Descargar un modelo (solo páginas de modelos)",
|
||||||
|
"toggleBulkMode": "Activar/desactivar el modo por lotes",
|
||||||
|
"selectAll": "Seleccionar todos los modelos visibles",
|
||||||
|
"rangeSelect": "Selección por rango",
|
||||||
|
"marqueeSelect": "Seleccionar tarjetas con un rectángulo de selección (en un área vacía de la cuadrícula)",
|
||||||
|
"exitBulkMode": "Salir del modo por lotes",
|
||||||
|
"bulkActions": "En una tarjeta seleccionada: menú de acciones por lotes",
|
||||||
|
"globalActions": "En un área vacía de la página: menú de acciones globales (comprobar actualizaciones, gestionar modelos excluidos)",
|
||||||
|
"scrollPages": "Desplazarse por las páginas",
|
||||||
|
"jumpAlphabet": "Saltar con la barra alfabética",
|
||||||
|
"prevNext": "Modelo anterior / siguiente",
|
||||||
|
"deleteEntry": "Eliminar",
|
||||||
|
"cycleMedia": "Cambiar de medio ([ / ] en la galería de ejemplos)",
|
||||||
|
"swipeTouch": "Cambiar de medio en dispositivos táctiles",
|
||||||
|
"closeViewer": "Cerrar el visor"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "Últimas actualizaciones",
|
"title": "Últimas actualizaciones",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "Configuración",
|
"settings": "Configuración",
|
||||||
"extensions": "Extensiones",
|
"extensions": "Extensiones",
|
||||||
"newBadge": "NUEVO"
|
"newBadge": "NUEVO"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "NUEVO"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Comprobar actualizaciones",
|
"title": "Comprobar actualizaciones",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"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",
|
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
|
||||||
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
||||||
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "Mo",
|
"mb": "Mo",
|
||||||
"gb": "Go",
|
"gb": "Go",
|
||||||
"tb": "To"
|
"tb": "To"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "Actualisation des {type}...",
|
||||||
|
"fullRebuilding": "Reconstruction complète des {type}...",
|
||||||
|
"actionRefresh": "Actualisation",
|
||||||
|
"actionFullRebuild": "Reconstruction complète",
|
||||||
|
"actionRefreshLower": "l’actualisation",
|
||||||
|
"actionRebuildLower": "la reconstruction",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "Scan des dossiers...",
|
||||||
|
"count_models": "{total} fichiers trouvés",
|
||||||
|
"process_models": "Traitement des modèles",
|
||||||
|
"reconcile_scan": "Vérification des modifications...",
|
||||||
|
"process_new": "Traitement des nouveaux modèles",
|
||||||
|
"finalizing": "Finalisation..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "Moins d’une minute restante",
|
||||||
|
"minutes": "~{minutes} min restantes",
|
||||||
|
"hours": "~{hours} h {minutes} min restantes"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Opérations groupées",
|
"title": "Opérations groupées",
|
||||||
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span>. Sélectionnez plusieurs modèles et effectuez des opérations groupées. Utilisez <span class=\"onboarding-shortcut\">Ctrl+A</span> pour sélectionner tous les modèles visibles."
|
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span> pour sélectionner plusieurs modèles et effectuer des opérations groupées.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> sélectionne tous les modèles visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> sélectionne une plage.<br>• <span class=\"onboarding-shortcut\">Esc</span> ou un clic sur une zone vide quitte le mode groupé."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "Options de recherche",
|
"title": "Options de recherche",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "Menu contextuel",
|
"title": "Menu contextuel",
|
||||||
"content": "<strong>Clic droit</strong> sur une carte de modèle pour accéder à un menu contextuel avec des actions supplémentaires."
|
"content": "<strong>Clic droit</strong> sur n'importe quelle carte de modèle pour ouvrir un menu contextuel avec des actions sur la carte comme déplacer, supprimer ou modifier les métadonnées."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "Glisser pour sélectionner",
|
||||||
|
"content": "Maintenez le <strong>bouton gauche de la souris</strong> enfoncé sur une zone vide de la grille et glissez pour tracer un rectangle de sélection qui sélectionne plusieurs cartes à la fois."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "Organiser par glisser-déposer",
|
||||||
|
"content": "Glissez une carte de modèle sur un dossier de la barre latérale pour y déplacer le fichier. Cela fonctionne aussi avec plusieurs cartes sélectionnées en mode groupé."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "Plus de menus contextuels",
|
||||||
|
"content": "En mode groupé, <strong>faites un clic droit sur une carte sélectionnée</strong> pour accéder aux actions groupées. <strong>Faites un clic droit sur une zone vide</strong> de la page pour accéder aux actions globales comme la vérification des mises à jour et la gestion des modèles exclus."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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...",
|
||||||
@@ -451,6 +477,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",
|
||||||
@@ -786,7 +814,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",
|
||||||
@@ -842,7 +869,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",
|
||||||
@@ -1095,13 +1121,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",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Valeur",
|
"valuePlaceholder": "Valeur",
|
||||||
"add": "Ajouter",
|
"add": "Ajouter",
|
||||||
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
|
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y",
|
||||||
|
"invalidValue": "Veuillez saisir un nombre valide",
|
||||||
|
"saveFailed": "Échec de l'enregistrement du paramètre préréglé",
|
||||||
|
"added": "Paramètre préréglé ajouté",
|
||||||
|
"updated": "Paramètre préréglé mis à jour"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Mots-clés",
|
"label": "Mots-clés",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
|
||||||
"editWord": "Modifier le mot-clé",
|
"editWord": "Modifier le mot-clé",
|
||||||
"editPlaceholder": "Modifier le mot-clé",
|
"editPlaceholder": "Modifier le mot-clé",
|
||||||
"copyWord": "Copier le mot-clé",
|
"copyOrEditWord": "Cliquez pour copier, double-cliquez pour modifier",
|
||||||
"deleteWord": "Supprimer le mot-clé",
|
"deleteWord": "Supprimer le mot-clé",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "Aucune suggestion disponible",
|
"noSuggestions": "Aucune suggestion disponible",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "Commencer",
|
"gettingStarted": "Commencer",
|
||||||
"updateVlogs": "Vlogs de mise à jour",
|
"updateVlogs": "Vlogs de mise à jour",
|
||||||
"documentation": "Documentation"
|
"documentation": "Documentation",
|
||||||
|
"shortcuts": "Raccourcis"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Premiers pas avec le Gestionnaire LoRA"
|
"title": "Premiers pas avec le Gestionnaire LoRA",
|
||||||
|
"replayTutorial": "Rejouer le tutoriel"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Raccourcis clavier et souris",
|
||||||
|
"groups": {
|
||||||
|
"general": "Général",
|
||||||
|
"actions": "Actions",
|
||||||
|
"selection": "Sélection et mode groupé",
|
||||||
|
"navigation": "Navigation",
|
||||||
|
"modelModal": "Modale Modèle / Recipe",
|
||||||
|
"mediaViewer": "Visionneuse de médias / Galerie d'exemples"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "Clic",
|
||||||
|
"drag": "Glisser",
|
||||||
|
"rightClick": "Clic droit",
|
||||||
|
"letter": "Lettre",
|
||||||
|
"swipe": "Balayage"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "Donner le focus au champ de recherche",
|
||||||
|
"closeModal": "Fermer la fenêtre modale / le panneau",
|
||||||
|
"openShortcuts": "Ouvrir ce panneau de raccourcis",
|
||||||
|
"refresh": "Actualiser la liste des modèles",
|
||||||
|
"fetchMetadata": "Récupérer les métadonnées depuis CivitAI (pages de modèles uniquement)",
|
||||||
|
"downloadModel": "Télécharger un modèle (pages de modèles uniquement)",
|
||||||
|
"toggleBulkMode": "Activer/désactiver le mode groupé",
|
||||||
|
"selectAll": "Sélectionner tous les modèles visibles",
|
||||||
|
"rangeSelect": "Sélection d'une plage",
|
||||||
|
"marqueeSelect": "Sélection par glisser-déposer des cartes (sur une zone vide de la grille)",
|
||||||
|
"exitBulkMode": "Quitter le mode groupé",
|
||||||
|
"bulkActions": "Sur une carte sélectionnée : menu des actions groupées",
|
||||||
|
"globalActions": "Sur une zone vide de la page : menu des actions globales (vérification des mises à jour, gestion des modèles exclus)",
|
||||||
|
"scrollPages": "Faire défiler les pages",
|
||||||
|
"jumpAlphabet": "Sauter via la barre alphabétique",
|
||||||
|
"prevNext": "Modèle précédent / suivant",
|
||||||
|
"deleteEntry": "Supprimer",
|
||||||
|
"cycleMedia": "Parcourir les médias ([ / ] dans la galerie d'exemples)",
|
||||||
|
"swipeTouch": "Parcourir les médias sur les appareils tactiles",
|
||||||
|
"closeViewer": "Fermer la visionneuse"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "Dernières mises à jour",
|
"title": "Dernières mises à jour",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "Paramètres & Configuration",
|
"settings": "Paramètres & Configuration",
|
||||||
"extensions": "Extensions",
|
"extensions": "Extensions",
|
||||||
"newBadge": "NOUVEAU"
|
"newBadge": "NOUVEAU"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "NOUVEAU"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Vérifier les mises à jour",
|
"title": "Vérifier les mises à jour",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"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",
|
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
|
||||||
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
"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}",
|
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "מרענן {type}...",
|
||||||
|
"fullRebuilding": "בונה מחדש את כל ה-{type}...",
|
||||||
|
"actionRefresh": "רענון",
|
||||||
|
"actionFullRebuild": "רענון מלא",
|
||||||
|
"actionRefreshLower": "רענון",
|
||||||
|
"actionRebuildLower": "רענון מלא",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "סורק תיקיות...",
|
||||||
|
"count_models": "נמצאו {total} קבצים",
|
||||||
|
"process_models": "מעבד מודלים",
|
||||||
|
"reconcile_scan": "בודק שינויים...",
|
||||||
|
"process_new": "מעבד מודלים חדשים",
|
||||||
|
"finalizing": "מסיים..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "נותרה פחות מדקה",
|
||||||
|
"minutes": "נותרו ~{minutes} דקות",
|
||||||
|
"hours": "נותרו ~{hours} שעות ו-{minutes} דקות"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "פעולות בכמות גדולה",
|
"title": "פעולות בכמות גדולה",
|
||||||
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span>. בחר מספר מודלים ובצע פעולות בכמות גדולה. השתמש ב-<span class=\"onboarding-shortcut\">Ctrl+A</span> כדי לבחור את כל המודלים הגלויים."
|
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span> כדי לבחור מספר מודלים ולבצע פעולות בכמות גדולה.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> בחר את כל המודלים הגלויים, <span class=\"onboarding-shortcut\">Shift+Click</span> בחר טווח.<br>• <span class=\"onboarding-shortcut\">Esc</span> או לחיצה על אזור ריק מוציאים ממצב בכמות גדולה."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "אפשרויות חיפוש",
|
"title": "אפשרויות חיפוש",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "תפריט הקשר",
|
"title": "תפריט הקשר",
|
||||||
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות נוספות."
|
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות כרטיס כמו העברה, מחיקה או עריכת מטא-נתונים."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "גרור כדי לבחור",
|
||||||
|
"content": "החזק את <strong>לחצן העכבר השמאלי</strong> לחוץ על אזור ריק של הרשת וגרור כדי לצייר מסגרת בחירה שבוחרת מספר כרטיסים בבת אחת."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "ארגון באמצעות גרירה",
|
||||||
|
"content": "גרור כרטיס מודל אל תיקייה בסרגל הצד כדי להעביר את הקובץ לשם. פעולה זו עובדת גם עם מספר כרטיסים נבחרים במצב בכמות גדולה."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "תפריטי הקשר נוספים",
|
||||||
|
"content": "במצב בכמות גדולה, <strong>לחץ לחיצה ימנית על כרטיס נבחר</strong> לפעולות בכמות גדולה. <strong>לחץ לחיצה ימנית על אזור ריק</strong> בדף לפעולות גלובליות כמו בדיקת עדכונים וניהול מודלים מוחרגים."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||||
@@ -451,6 +477,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "קיבוץ לפי מודל",
|
"groupByModel": "קיבוץ לפי מודל",
|
||||||
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
|
||||||
|
"stickyControls": "השארת סרגל הפעולות גלוי",
|
||||||
|
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
|
||||||
"displayDensity": "צפיפות תצוגה",
|
"displayDensity": "צפיפות תצוגה",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "ברירת מחדל",
|
"default": "ברירת מחדל",
|
||||||
@@ -786,7 +814,6 @@
|
|||||||
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
|
||||||
"copyAll": "העתק את כל התחבירים",
|
"copyAll": "העתק את כל התחבירים",
|
||||||
"refreshAll": "רענן את כל המטא-נתונים",
|
"refreshAll": "רענן את כל המטא-נתונים",
|
||||||
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
|
|
||||||
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"checkUpdates": "בדוק עדכונים לבחירה",
|
"checkUpdates": "בדוק עדכונים לבחירה",
|
||||||
@@ -842,7 +869,6 @@
|
|||||||
"replacePreview": "החלף תצוגה מקדימה",
|
"replacePreview": "החלף תצוגה מקדימה",
|
||||||
"setContentRating": "הגדר דירוג תוכן",
|
"setContentRating": "הגדר דירוג תוכן",
|
||||||
"moveToFolder": "העבר לתיקייה",
|
"moveToFolder": "העבר לתיקייה",
|
||||||
"repairMetadata": "תיקון מטא-נתונים",
|
|
||||||
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
|
||||||
"reimportMetadata": "ייבא מחדש ממקור",
|
"reimportMetadata": "ייבא מחדש ממקור",
|
||||||
"excludeModel": "החרג מודל",
|
"excludeModel": "החרג מודל",
|
||||||
@@ -1095,13 +1121,6 @@
|
|||||||
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
|
||||||
},
|
},
|
||||||
"repair": {
|
|
||||||
"starting": "מתקן מטא-נתונים של מתכון...",
|
|
||||||
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
|
|
||||||
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
|
|
||||||
"failed": "תיקון המתכון נכשל: {message}",
|
|
||||||
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
|
|
||||||
},
|
|
||||||
"reimport": {
|
"reimport": {
|
||||||
"starting": "מייבא מתכון מחדש מהמקור...",
|
"starting": "מייבא מתכון מחדש מהמקור...",
|
||||||
"success": "המתכון יובא מחדש בהצלחה",
|
"success": "המתכון יובא מחדש בהצלחה",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "ערך",
|
"valuePlaceholder": "ערך",
|
||||||
"add": "הוסף",
|
"add": "הוסף",
|
||||||
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
|
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y",
|
||||||
|
"invalidValue": "נא להזין מספר תקין",
|
||||||
|
"saveFailed": "שמירת הפרמטר הקבוע מראש נכשלה",
|
||||||
|
"added": "הפרמטר הקבוע מראש נוסף",
|
||||||
|
"updated": "הפרמטר הקבוע מראש עודכן"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "מילות טריגר",
|
"label": "מילות טריגר",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
|
||||||
"editWord": "עריכת מילת טריגר",
|
"editWord": "עריכת מילת טריגר",
|
||||||
"editPlaceholder": "עריכת מילת טריגר",
|
"editPlaceholder": "עריכת מילת טריגר",
|
||||||
"copyWord": "העתק מילת טריגר",
|
"copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
|
||||||
"deleteWord": "מחק מילת טריגר",
|
"deleteWord": "מחק מילת טריגר",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "אין הצעות זמינות",
|
"noSuggestions": "אין הצעות זמינות",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "תחילת עבודה",
|
"gettingStarted": "תחילת עבודה",
|
||||||
"updateVlogs": "בלוגי וידאו של עדכונים",
|
"updateVlogs": "בלוגי וידאו של עדכונים",
|
||||||
"documentation": "תיעוד"
|
"documentation": "תיעוד",
|
||||||
|
"shortcuts": "קיצורי דרך"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "תחילת עבודה עם מנהל LoRA"
|
"title": "תחילת עבודה עם מנהל LoRA",
|
||||||
|
"replayTutorial": "הפעל את המדריך מחדש"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "קיצורי מקלדת ועכבר",
|
||||||
|
"groups": {
|
||||||
|
"general": "כללי",
|
||||||
|
"actions": "פעולות",
|
||||||
|
"selection": "בחירה ומצב בכמות גדולה",
|
||||||
|
"navigation": "ניווט",
|
||||||
|
"modelModal": "חלון מודל / מתכון",
|
||||||
|
"mediaViewer": "מציג מדיה / גלריית דוגמאות"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "לחיצה",
|
||||||
|
"drag": "גרירה",
|
||||||
|
"rightClick": "לחיצה ימנית",
|
||||||
|
"letter": "אות",
|
||||||
|
"swipe": "החלקה"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "העבר מיקוד לחיפוש",
|
||||||
|
"closeModal": "סגור חלון / פאנל",
|
||||||
|
"openShortcuts": "פתח את פאנל קיצורי הדרך הזה",
|
||||||
|
"refresh": "רענן את רשימת המודלים",
|
||||||
|
"fetchMetadata": "אחזר מטא-נתונים מ-CivitAI (דפי מודלים בלבד)",
|
||||||
|
"downloadModel": "הורד מודל (דפי מודלים בלבד)",
|
||||||
|
"toggleBulkMode": "הפעל/כבה מצב בכמות גדולה",
|
||||||
|
"selectAll": "בחר את כל המודלים הגלויים",
|
||||||
|
"rangeSelect": "בחר טווח",
|
||||||
|
"marqueeSelect": "בחר כרטיסים במסגרת בחירה (באזור ריק של הרשת)",
|
||||||
|
"exitBulkMode": "צא ממצב בכמות גדולה",
|
||||||
|
"bulkActions": "על כרטיס נבחר: תפריט פעולות בכמות גדולה",
|
||||||
|
"globalActions": "באזור ריק בדף: תפריט פעולות גלובליות (בדיקת עדכונים, ניהול מודלים מוחרגים)",
|
||||||
|
"scrollPages": "גלול בין דפים",
|
||||||
|
"jumpAlphabet": "קפוץ בעזרת סרגל האותיות",
|
||||||
|
"prevNext": "מודל קודם / הבא",
|
||||||
|
"deleteEntry": "מחק",
|
||||||
|
"cycleMedia": "עבור בין פריטי מדיה ([ / ] בגלריית הדוגמאות)",
|
||||||
|
"swipeTouch": "עבור בין פריטי מדיה במכשירי מגע",
|
||||||
|
"closeViewer": "סגור את המציג"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "עדכונים אחרונים",
|
"title": "עדכונים אחרונים",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "הגדרות ותצורה",
|
"settings": "הגדרות ותצורה",
|
||||||
"extensions": "הרחבות",
|
"extensions": "הרחבות",
|
||||||
"newBadge": "חדש"
|
"newBadge": "חדש"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "חדש"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "בדוק עדכונים",
|
"title": "בדוק עדכונים",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
|
|
||||||
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
|
|
||||||
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
|
|
||||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
||||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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": "ייבוא מחדש של חלק מהמתכונים נכשל",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "{type}を更新中...",
|
||||||
|
"fullRebuilding": "{type}を完全に再構築中...",
|
||||||
|
"actionRefresh": "更新",
|
||||||
|
"actionFullRebuild": "完全な再構築",
|
||||||
|
"actionRefreshLower": "更新",
|
||||||
|
"actionRebuildLower": "再構築",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "フォルダをスキャン中...",
|
||||||
|
"count_models": "{total} 件のファイルが見つかりました",
|
||||||
|
"process_models": "モデルを処理中",
|
||||||
|
"reconcile_scan": "変更を確認中...",
|
||||||
|
"process_new": "新しいモデルを処理中",
|
||||||
|
"finalizing": "最終処理中..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "残り1分未満",
|
||||||
|
"minutes": "残り約 {minutes} 分",
|
||||||
|
"hours": "残り約 {hours} 時間 {minutes} 分"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "一括操作",
|
"title": "一括操作",
|
||||||
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入ります。複数のモデルを選択して一括操作が可能です。<span class=\"onboarding-shortcut\">Ctrl+A</span>で表示中のモデルをすべて選択できます。"
|
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入り、複数のモデルを選択して一括操作を実行できます。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>で表示中のモデルをすべて選択、<span class=\"onboarding-shortcut\">Shift+Click</span>で範囲選択。<br>• <span class=\"onboarding-shortcut\">Esc</span>キーまたは空白部分をクリックすると一括モードを終了します。"
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "検索オプション",
|
"title": "検索オプション",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "コンテキストメニュー",
|
"title": "コンテキストメニュー",
|
||||||
"content": "<strong>モデルカードを右クリック</strong>すると追加の操作ができるコンテキストメニューが表示されます。"
|
"content": "<strong>モデルカードを右クリック</strong>すると、移動、削除、メタデータの編集などのカード操作を含むコンテキストメニューが表示されます。"
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "ドラッグで選択",
|
||||||
|
"content": "グリッドの空白部分で<strong>マウスの左ボタン</strong>を押したままドラッグすると、複数のカードを一度に選択する矩形(マーキー)を描画できます。"
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "ドラッグで整理",
|
||||||
|
"content": "モデルカードをサイドバーのフォルダにドラッグすると、ファイルをそこに移動できます。一括モードで複数選択したカードでも同様に機能します。"
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "その他のコンテキストメニュー",
|
||||||
|
"content": "一括モードでは、<strong>選択したカードを右クリック</strong>すると一括操作メニューが表示されます。<strong>ページの空白部分を右クリック</strong>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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": "レシピをローカルモデルに再マッチングしています...",
|
||||||
@@ -451,6 +477,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "モデルでグループ化",
|
"groupByModel": "モデルでグループ化",
|
||||||
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
|
||||||
|
"stickyControls": "アクションバーを常に表示",
|
||||||
|
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
|
||||||
"displayDensity": "表示密度",
|
"displayDensity": "表示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "デフォルト",
|
"default": "デフォルト",
|
||||||
@@ -786,7 +814,6 @@
|
|||||||
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
|
||||||
"copyAll": "すべての構文をコピー",
|
"copyAll": "すべての構文をコピー",
|
||||||
"refreshAll": "すべてのメタデータを更新",
|
"refreshAll": "すべてのメタデータを更新",
|
||||||
"repairMetadata": "選択したレシピのメタデータを修復",
|
|
||||||
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"checkUpdates": "選択項目の更新を確認",
|
"checkUpdates": "選択項目の更新を確認",
|
||||||
@@ -842,7 +869,6 @@
|
|||||||
"replacePreview": "プレビューを置換",
|
"replacePreview": "プレビューを置換",
|
||||||
"setContentRating": "コンテンツレーティングを設定",
|
"setContentRating": "コンテンツレーティングを設定",
|
||||||
"moveToFolder": "フォルダに移動",
|
"moveToFolder": "フォルダに移動",
|
||||||
"repairMetadata": "メタデータを修復",
|
|
||||||
"rematchMetadata": "ローカルモデルに再マッチング",
|
"rematchMetadata": "ローカルモデルに再マッチング",
|
||||||
"reimportMetadata": "ソースから再インポート",
|
"reimportMetadata": "ソースから再インポート",
|
||||||
"excludeModel": "モデルを除外",
|
"excludeModel": "モデルを除外",
|
||||||
@@ -1095,13 +1121,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": "レシピの再インポートが完了しました",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "値",
|
"valuePlaceholder": "値",
|
||||||
"add": "追加",
|
"add": "追加",
|
||||||
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
|
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください",
|
||||||
|
"invalidValue": "有効な数値を入力してください",
|
||||||
|
"saveFailed": "プリセットパラメータの保存に失敗しました",
|
||||||
|
"added": "プリセットパラメータを追加しました",
|
||||||
|
"updated": "プリセットパラメータを更新しました"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "トリガーワード",
|
"label": "トリガーワード",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
"addPlaceholder": "入力して追加するか、下の提案をクリック",
|
||||||
"editWord": "トリガーワードを編集",
|
"editWord": "トリガーワードを編集",
|
||||||
"editPlaceholder": "トリガーワードを編集",
|
"editPlaceholder": "トリガーワードを編集",
|
||||||
"copyWord": "トリガーワードをコピー",
|
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
|
||||||
"deleteWord": "トリガーワードを削除",
|
"deleteWord": "トリガーワードを削除",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "提案はありません",
|
"noSuggestions": "提案はありません",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "はじめに",
|
"gettingStarted": "はじめに",
|
||||||
"updateVlogs": "更新Vlog",
|
"updateVlogs": "更新Vlog",
|
||||||
"documentation": "ドキュメント"
|
"documentation": "ドキュメント",
|
||||||
|
"shortcuts": "ショートカット"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "LoRA Managerを始める"
|
"title": "LoRA Managerを始める",
|
||||||
|
"replayTutorial": "チュートリアルをもう一度再生"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "キーボード & マウスのショートカット",
|
||||||
|
"groups": {
|
||||||
|
"general": "一般",
|
||||||
|
"actions": "操作",
|
||||||
|
"selection": "選択 & 一括モード",
|
||||||
|
"navigation": "ナビゲーション",
|
||||||
|
"modelModal": "モデル / レシピモーダル",
|
||||||
|
"mediaViewer": "メディアビューア / ショーケース"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "クリック",
|
||||||
|
"drag": "ドラッグ",
|
||||||
|
"rightClick": "右クリック",
|
||||||
|
"letter": "文字キー",
|
||||||
|
"swipe": "スワイプ"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "検索にフォーカス",
|
||||||
|
"closeModal": "モーダル / パネルを閉じる",
|
||||||
|
"openShortcuts": "このショートカットパネルを開く",
|
||||||
|
"refresh": "モデルリストを更新",
|
||||||
|
"fetchMetadata": "CivitAIからメタデータを取得(モデルページのみ)",
|
||||||
|
"downloadModel": "モデルをダウンロード(モデルページのみ)",
|
||||||
|
"toggleBulkMode": "一括モードを切り替え",
|
||||||
|
"selectAll": "表示中のモデルをすべて選択",
|
||||||
|
"rangeSelect": "範囲選択",
|
||||||
|
"marqueeSelect": "カードを矩形選択(グリッドの空白部分で)",
|
||||||
|
"exitBulkMode": "一括モードを終了",
|
||||||
|
"bulkActions": "選択したカード上:一括操作メニュー",
|
||||||
|
"globalActions": "ページの空白部分:グローバル操作メニュー(更新の確認、除外モデルの管理)",
|
||||||
|
"scrollPages": "ページをスクロール",
|
||||||
|
"jumpAlphabet": "アルファベットバーへジャンプ",
|
||||||
|
"prevNext": "前 / 次のモデル",
|
||||||
|
"deleteEntry": "削除",
|
||||||
|
"cycleMedia": "メディアを切り替え(ショーケースギャラリーでは [ / ])",
|
||||||
|
"swipeTouch": "タッチデバイスでメディアを切り替え",
|
||||||
|
"closeViewer": "ビューアを閉じる"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "最新の更新",
|
"title": "最新の更新",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "設定&構成",
|
"settings": "設定&構成",
|
||||||
"extensions": "拡張機能",
|
"extensions": "拡張機能",
|
||||||
"newBadge": "新着"
|
"newBadge": "新着"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "新着"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "更新確認",
|
"title": "更新確認",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
|
|
||||||
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
|
|
||||||
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
|
|
||||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
||||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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": "一部のレシピの再インポートに失敗しました",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "{type} 새로고침 중...",
|
||||||
|
"fullRebuilding": "{type} 전체 재구성 중...",
|
||||||
|
"actionRefresh": "새로고침",
|
||||||
|
"actionFullRebuild": "전체 재구성",
|
||||||
|
"actionRefreshLower": "새로고침",
|
||||||
|
"actionRebuildLower": "재구성",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "폴더 스캔 중...",
|
||||||
|
"count_models": "파일 {total}개 발견",
|
||||||
|
"process_models": "모델 처리 중",
|
||||||
|
"reconcile_scan": "변경 사항 확인 중...",
|
||||||
|
"process_new": "새 모델 처리 중",
|
||||||
|
"finalizing": "마무리 중..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "남은 시간 1분 미만",
|
||||||
|
"minutes": "약 {minutes}분 남음",
|
||||||
|
"hours": "약 {hours}시간 {minutes}분 남음"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "일괄 작업",
|
"title": "일괄 작업",
|
||||||
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하세요. 여러 모델을 선택하여 일괄 작업을 수행할 수 있습니다. <span class=\"onboarding-shortcut\">Ctrl+A</span>로 모든 표시된 모델을 선택하세요."
|
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하여 여러 모델을 선택하고 일괄 작업을 수행하세요.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>로 모든 표시된 모델을 선택하고, <span class=\"onboarding-shortcut\">Shift+Click</span>으로 범위를 선택할 수 있습니다.<br>• <span class=\"onboarding-shortcut\">Esc</span> 키를 누르거나 빈 영역을 클릭하면 일괄 모드가 종료됩니다."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "검색 옵션",
|
"title": "검색 옵션",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "컨텍스트 메뉴",
|
"title": "컨텍스트 메뉴",
|
||||||
"content": "<strong>오른쪽 클릭</strong>으로 모델 카드의 추가 작업 메뉴를 사용할 수 있습니다."
|
"content": "모델 카드를 <strong>오른쪽 클릭</strong>하면 이동, 삭제, 메타데이터 편집 같은 카드 작업이 담긴 컨텍스트 메뉴를 사용할 수 있습니다."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "드래그로 선택",
|
||||||
|
"content": "그리드의 빈 영역에서 <strong>마우스 왼쪽 버튼</strong>을 누른 채 드래그하여 여러 카드를 한 번에 선택하는 선택 영역을 그리세요."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "드래그로 정리",
|
||||||
|
"content": "모델 카드를 사이드바의 폴더로 드래그하면 파일이 해당 폴더로 이동합니다. 일괄 모드에서 선택한 여러 카드에도 적용됩니다."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "더 많은 컨텍스트 메뉴",
|
||||||
|
"content": "일괄 모드에서는 <strong>선택한 카드를 오른쪽 클릭</strong>하여 일괄 작업을 사용할 수 있습니다. 페이지의 <strong>빈 영역을 오른쪽 클릭</strong>하면 업데이트 확인이나 제외된 모델 관리 같은 전역 작업을 사용할 수 있습니다."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||||
@@ -451,6 +477,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "모델별 그룹화",
|
"groupByModel": "모델별 그룹화",
|
||||||
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
|
||||||
|
"stickyControls": "작업 표시줄 항상 표시",
|
||||||
|
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
|
||||||
"displayDensity": "표시 밀도",
|
"displayDensity": "표시 밀도",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "기본",
|
"default": "기본",
|
||||||
@@ -786,7 +814,6 @@
|
|||||||
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
|
||||||
"copyAll": "모든 문법 복사",
|
"copyAll": "모든 문법 복사",
|
||||||
"refreshAll": "모든 메타데이터 새로고침",
|
"refreshAll": "모든 메타데이터 새로고침",
|
||||||
"repairMetadata": "선택한 레시피 메타데이터 복구",
|
|
||||||
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"checkUpdates": "선택 항목 업데이트 확인",
|
"checkUpdates": "선택 항목 업데이트 확인",
|
||||||
@@ -842,7 +869,6 @@
|
|||||||
"replacePreview": "미리보기 교체",
|
"replacePreview": "미리보기 교체",
|
||||||
"setContentRating": "콘텐츠 등급 설정",
|
"setContentRating": "콘텐츠 등급 설정",
|
||||||
"moveToFolder": "폴더로 이동",
|
"moveToFolder": "폴더로 이동",
|
||||||
"repairMetadata": "메타데이터 복구",
|
|
||||||
"rematchMetadata": "로컬 모델에 다시 매칭",
|
"rematchMetadata": "로컬 모델에 다시 매칭",
|
||||||
"reimportMetadata": "소스에서 다시 가져오기",
|
"reimportMetadata": "소스에서 다시 가져오기",
|
||||||
"excludeModel": "모델 제외",
|
"excludeModel": "모델 제외",
|
||||||
@@ -1095,13 +1121,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": "레시피를 다시 가져왔습니다",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "클립 스킵",
|
"clipSkip": "클립 스킵",
|
||||||
"valuePlaceholder": "값",
|
"valuePlaceholder": "값",
|
||||||
"add": "추가",
|
"add": "추가",
|
||||||
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
|
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요",
|
||||||
|
"invalidValue": "유효한 숫자를 입력하세요",
|
||||||
|
"saveFailed": "프리셋 매개변수 저장에 실패했습니다",
|
||||||
|
"added": "프리셋 매개변수가 추가되었습니다",
|
||||||
|
"updated": "프리셋 매개변수가 업데이트되었습니다"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "트리거 단어",
|
"label": "트리거 단어",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
|
||||||
"editWord": "트리거 단어 편집",
|
"editWord": "트리거 단어 편집",
|
||||||
"editPlaceholder": "트리거 단어 편집",
|
"editPlaceholder": "트리거 단어 편집",
|
||||||
"copyWord": "트리거 단어 복사",
|
"copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
|
||||||
"deleteWord": "트리거 단어 삭제",
|
"deleteWord": "트리거 단어 삭제",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "사용 가능한 제안이 없습니다",
|
"noSuggestions": "사용 가능한 제안이 없습니다",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "시작하기",
|
"gettingStarted": "시작하기",
|
||||||
"updateVlogs": "업데이트 영상",
|
"updateVlogs": "업데이트 영상",
|
||||||
"documentation": "문서"
|
"documentation": "문서",
|
||||||
|
"shortcuts": "단축키"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "LoRA Manager 시작하기"
|
"title": "LoRA Manager 시작하기",
|
||||||
|
"replayTutorial": "튜토리얼 다시 보기"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "키보드 & 마우스 단축키",
|
||||||
|
"groups": {
|
||||||
|
"general": "일반",
|
||||||
|
"actions": "작업",
|
||||||
|
"selection": "선택 & 일괄 모드",
|
||||||
|
"navigation": "내비게이션",
|
||||||
|
"modelModal": "모델 / 레시피 모달",
|
||||||
|
"mediaViewer": "미디어 뷰어 / 쇼케이스"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "클릭",
|
||||||
|
"drag": "드래그",
|
||||||
|
"rightClick": "오른쪽 클릭",
|
||||||
|
"letter": "문자 키",
|
||||||
|
"swipe": "스와이프"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "검색창으로 포커스 이동",
|
||||||
|
"closeModal": "모달 / 패널 닫기",
|
||||||
|
"openShortcuts": "이 단축키 패널 열기",
|
||||||
|
"refresh": "모델 목록 새로고침",
|
||||||
|
"fetchMetadata": "CivitAI에서 메타데이터 가져오기 (모델 페이지만)",
|
||||||
|
"downloadModel": "모델 다운로드 (모델 페이지만)",
|
||||||
|
"toggleBulkMode": "일괄 모드 전환",
|
||||||
|
"selectAll": "표시된 모든 모델 선택",
|
||||||
|
"rangeSelect": "범위 선택",
|
||||||
|
"marqueeSelect": "드래그로 카드 선택 (빈 그리드 영역에서)",
|
||||||
|
"exitBulkMode": "일괄 모드 종료",
|
||||||
|
"bulkActions": "선택한 카드에서: 일괄 작업 메뉴",
|
||||||
|
"globalActions": "페이지 빈 영역에서: 전역 작업 메뉴 (업데이트 확인, 제외된 모델 관리)",
|
||||||
|
"scrollPages": "페이지 스크롤",
|
||||||
|
"jumpAlphabet": "알파벳 바로 이동",
|
||||||
|
"prevNext": "이전 / 다음 모델",
|
||||||
|
"deleteEntry": "삭제",
|
||||||
|
"cycleMedia": "미디어 전환 (쇼케이스 갤러리에서 [ / ])",
|
||||||
|
"swipeTouch": "터치 기기에서 미디어 전환",
|
||||||
|
"closeViewer": "뷰어 닫기"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "최신 업데이트",
|
"title": "최신 업데이트",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "설정 & 구성",
|
"settings": "설정 & 구성",
|
||||||
"extensions": "확장",
|
"extensions": "확장",
|
||||||
"newBadge": "신규"
|
"newBadge": "신규"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "신규"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "업데이트 확인",
|
"title": "업데이트 확인",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
|
|
||||||
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
|
|
||||||
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
|
|
||||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
||||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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": "일부 레시피를 다시 가져오지 못했습니다",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "МБ",
|
"mb": "МБ",
|
||||||
"gb": "ГБ",
|
"gb": "ГБ",
|
||||||
"tb": "ТБ"
|
"tb": "ТБ"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "Обновление {type}...",
|
||||||
|
"fullRebuilding": "Полная пересборка {type}...",
|
||||||
|
"actionRefresh": "Обновление",
|
||||||
|
"actionFullRebuild": "Полная пересборка",
|
||||||
|
"actionRefreshLower": "обновить",
|
||||||
|
"actionRebuildLower": "пересобрать",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "Сканирование папок...",
|
||||||
|
"count_models": "Найдено файлов: {total}",
|
||||||
|
"process_models": "Обработка моделей",
|
||||||
|
"reconcile_scan": "Проверка изменений...",
|
||||||
|
"process_new": "Обработка новых моделей",
|
||||||
|
"finalizing": "Завершение..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "Осталось меньше минуты",
|
||||||
|
"minutes": "Осталось ~{minutes} мин",
|
||||||
|
"hours": "Осталось ~{hours} ч {minutes} мин"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "Массовые операции",
|
"title": "Массовые операции",
|
||||||
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>. Выберите несколько моделей и выполните пакетные операции. Используйте <span class=\"onboarding-shortcut\">Ctrl+A</span> для выбора всех видимых моделей."
|
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>, чтобы выбрать несколько моделей и выполнить пакетные операции.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> — выбрать все видимые модели, <span class=\"onboarding-shortcut\">Shift+Click</span> — выбрать диапазон.<br>• <span class=\"onboarding-shortcut\">Esc</span> или клик по пустой области выходит из массового режима."
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "Опции поиска",
|
"title": "Опции поиска",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "Контекстное меню",
|
"title": "Контекстное меню",
|
||||||
"content": "<strong>Правый клик</strong> по карточке модели откроет контекстное меню с дополнительными действиями."
|
"content": "<strong>Правый клик</strong> по любой карточке модели открывает контекстное меню с действиями над карточкой, такими как перемещение, удаление или редактирование метаданных."
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "Выделение рамкой",
|
||||||
|
"content": "Удерживайте <strong>левую кнопку мыши</strong> на пустой области сетки и перетащите, чтобы нарисовать рамку, выделяющую сразу несколько карточек."
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "Организация перетаскиванием",
|
||||||
|
"content": "Перетащите карточку модели на папку в боковой панели, чтобы переместить туда файл. Это также работает с несколькими выделенными карточками в массовом режиме."
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "Другие контекстные меню",
|
||||||
|
"content": "В массовом режиме <strong>правый клик по выделенной карточке</strong> открывает меню массовых операций. <strong>Правый клик по пустой области</strong> страницы открывает глобальные действия, такие как проверка обновлений и управление исключёнными моделями."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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": "Повторное сопоставление рецептов с локальными моделями...",
|
||||||
@@ -451,6 +477,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "Группировать по модели",
|
"groupByModel": "Группировать по модели",
|
||||||
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
|
||||||
|
"stickyControls": "Держать панель действий видимой",
|
||||||
|
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
|
||||||
"displayDensity": "Плотность отображения",
|
"displayDensity": "Плотность отображения",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "По умолчанию",
|
"default": "По умолчанию",
|
||||||
@@ -786,7 +814,6 @@
|
|||||||
"setContentRating": "Установить рейтинг контента для всех",
|
"setContentRating": "Установить рейтинг контента для всех",
|
||||||
"copyAll": "Копировать весь синтаксис",
|
"copyAll": "Копировать весь синтаксис",
|
||||||
"refreshAll": "Обновить все метаданные",
|
"refreshAll": "Обновить все метаданные",
|
||||||
"repairMetadata": "Восстановить метаданные для выбранных",
|
|
||||||
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"checkUpdates": "Проверить обновления для выбранных",
|
"checkUpdates": "Проверить обновления для выбранных",
|
||||||
@@ -842,7 +869,6 @@
|
|||||||
"replacePreview": "Заменить превью",
|
"replacePreview": "Заменить превью",
|
||||||
"setContentRating": "Установить рейтинг контента",
|
"setContentRating": "Установить рейтинг контента",
|
||||||
"moveToFolder": "Переместить в папку",
|
"moveToFolder": "Переместить в папку",
|
||||||
"repairMetadata": "Восстановить метаданные",
|
|
||||||
"rematchMetadata": "Сопоставить с локальными моделями",
|
"rematchMetadata": "Сопоставить с локальными моделями",
|
||||||
"reimportMetadata": "Переимпортировать из источника",
|
"reimportMetadata": "Переимпортировать из источника",
|
||||||
"excludeModel": "Исключить модель",
|
"excludeModel": "Исключить модель",
|
||||||
@@ -1095,13 +1121,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": "Рецепт успешно переимпортирован",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "Значение",
|
"valuePlaceholder": "Значение",
|
||||||
"add": "Добавить",
|
"add": "Добавить",
|
||||||
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
|
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y",
|
||||||
|
"invalidValue": "Введите корректное число",
|
||||||
|
"saveFailed": "Не удалось сохранить предустановленный параметр",
|
||||||
|
"added": "Предустановленный параметр добавлен",
|
||||||
|
"updated": "Предустановленный параметр обновлён"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "Триггерные слова",
|
"label": "Триггерные слова",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
|
||||||
"editWord": "Редактировать триггерное слово",
|
"editWord": "Редактировать триггерное слово",
|
||||||
"editPlaceholder": "Редактировать триггерное слово",
|
"editPlaceholder": "Редактировать триггерное слово",
|
||||||
"copyWord": "Копировать триггерное слово",
|
"copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
|
||||||
"deleteWord": "Удалить триггерное слово",
|
"deleteWord": "Удалить триггерное слово",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "Предложения недоступны",
|
"noSuggestions": "Предложения недоступны",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "Начало работы",
|
"gettingStarted": "Начало работы",
|
||||||
"updateVlogs": "Видео обновлений",
|
"updateVlogs": "Видео обновлений",
|
||||||
"documentation": "Документация"
|
"documentation": "Документация",
|
||||||
|
"shortcuts": "Горячие клавиши"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "Начало работы с LoRA Manager"
|
"title": "Начало работы с LoRA Manager",
|
||||||
|
"replayTutorial": "Повторить обучение"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "Горячие клавиши и действия мыши",
|
||||||
|
"groups": {
|
||||||
|
"general": "Общие",
|
||||||
|
"actions": "Действия",
|
||||||
|
"selection": "Выделение и массовый режим",
|
||||||
|
"navigation": "Навигация",
|
||||||
|
"modelModal": "Окно модели / рецепта",
|
||||||
|
"mediaViewer": "Просмотр медиа / Витрина"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "Клик",
|
||||||
|
"drag": "Перетаскивание",
|
||||||
|
"rightClick": "Правый клик",
|
||||||
|
"letter": "Буква",
|
||||||
|
"swipe": "Свайп"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "Переход к поиску",
|
||||||
|
"closeModal": "Закрыть модальное окно / панель",
|
||||||
|
"openShortcuts": "Открыть эту панель горячих клавиш",
|
||||||
|
"refresh": "Обновить список моделей",
|
||||||
|
"fetchMetadata": "Получить метаданные с CivitAI (только на страницах моделей)",
|
||||||
|
"downloadModel": "Загрузить модель (только на страницах моделей)",
|
||||||
|
"toggleBulkMode": "Переключить массовый режим",
|
||||||
|
"selectAll": "Выбрать все видимые модели",
|
||||||
|
"rangeSelect": "Выбор диапазона",
|
||||||
|
"marqueeSelect": "Выделение карточек рамкой (на пустой области сетки)",
|
||||||
|
"exitBulkMode": "Выйти из массового режима",
|
||||||
|
"bulkActions": "На выделенной карточке: меню массовых операций",
|
||||||
|
"globalActions": "На пустой области страницы: меню глобальных действий (проверка обновлений, управление исключёнными моделями)",
|
||||||
|
"scrollPages": "Прокрутка страниц",
|
||||||
|
"jumpAlphabet": "Переход по алфавитной панели",
|
||||||
|
"prevNext": "Предыдущая / следующая модель",
|
||||||
|
"deleteEntry": "Удалить",
|
||||||
|
"cycleMedia": "Переключение медиа ([ / ] в галерее витрины)",
|
||||||
|
"swipeTouch": "Переключение медиа на сенсорных устройствах",
|
||||||
|
"closeViewer": "Закрыть окно просмотра"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "Последние обновления",
|
"title": "Последние обновления",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "Настройки и конфигурация",
|
"settings": "Настройки и конфигурация",
|
||||||
"extensions": "Расширения",
|
"extensions": "Расширения",
|
||||||
"newBadge": "НОВОЕ"
|
"newBadge": "НОВОЕ"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "НОВОЕ"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "Проверить обновления",
|
"title": "Проверить обновления",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
|
|
||||||
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
|
|
||||||
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
|
|
||||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
||||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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": "Не удалось переимпортировать некоторые рецепты",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "正在刷新 {type}...",
|
||||||
|
"fullRebuilding": "正在完全重建 {type}...",
|
||||||
|
"actionRefresh": "刷新",
|
||||||
|
"actionFullRebuild": "完全重建",
|
||||||
|
"actionRefreshLower": "刷新",
|
||||||
|
"actionRebuildLower": "重建",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "正在扫描文件夹...",
|
||||||
|
"count_models": "找到 {total} 个文件",
|
||||||
|
"process_models": "正在处理模型",
|
||||||
|
"reconcile_scan": "正在检查变更...",
|
||||||
|
"process_new": "正在处理新模型",
|
||||||
|
"finalizing": "正在收尾..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "剩余时间不到一分钟",
|
||||||
|
"minutes": "剩余约 {minutes} 分钟",
|
||||||
|
"hours": "剩余约 {hours} 小时 {minutes} 分钟"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "批量操作",
|
"title": "批量操作",
|
||||||
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式。可多选模型并进行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 全选所有可见模型。"
|
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式,可多选模型并执行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 全选所有可见模型,<span class=\"onboarding-shortcut\">Shift+Click</span> 选择一个范围。<br>• 按 <span class=\"onboarding-shortcut\">Esc</span> 或点击空白区域退出批量模式。"
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "搜索选项",
|
"title": "搜索选项",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "右键菜单",
|
"title": "右键菜单",
|
||||||
"content": "<strong>右键点击</strong>任意模型卡片可打开更多操作菜单。"
|
"content": "<strong>右键点击</strong>任意模型卡片,可打开包含移动、删除或编辑元数据等卡片操作的菜单。"
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "拖动框选",
|
||||||
|
"content": "在网格的空白区域按住<strong>鼠标左键</strong>并拖动,绘制一个可同时选中多张卡片的框选区域。"
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "拖放整理",
|
||||||
|
"content": "将模型卡片拖到侧边栏的文件夹上,即可把文件移动到该文件夹。批量模式下选中的多张卡片也可如此操作。"
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "更多右键菜单",
|
||||||
|
"content": "在批量模式下,<strong>右键点击已选中的卡片</strong>可进行批量操作。<strong>右键点击页面空白区域</strong>可使用检查更新、管理已排除的模型等全局操作。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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": "正在将配方重新匹配到本地模型...",
|
||||||
@@ -451,6 +477,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "按模型分组",
|
"groupByModel": "按模型分组",
|
||||||
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
|
||||||
|
"stickyControls": "保持操作栏可见",
|
||||||
|
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
|
||||||
"displayDensity": "显示密度",
|
"displayDensity": "显示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "默认",
|
"default": "默认",
|
||||||
@@ -786,7 +814,6 @@
|
|||||||
"setContentRating": "为所选中设置内容评级",
|
"setContentRating": "为所选中设置内容评级",
|
||||||
"copyAll": "复制所选中语法",
|
"copyAll": "复制所选中语法",
|
||||||
"refreshAll": "刷新所选中元数据",
|
"refreshAll": "刷新所选中元数据",
|
||||||
"repairMetadata": "修复所选中元数据",
|
|
||||||
"rematchMetadata": "将所选中重新匹配到本地模型",
|
"rematchMetadata": "将所选中重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"checkUpdates": "检查所选更新",
|
"checkUpdates": "检查所选更新",
|
||||||
@@ -842,7 +869,6 @@
|
|||||||
"replacePreview": "替换预览",
|
"replacePreview": "替换预览",
|
||||||
"setContentRating": "设置内容评级",
|
"setContentRating": "设置内容评级",
|
||||||
"moveToFolder": "移动到文件夹",
|
"moveToFolder": "移动到文件夹",
|
||||||
"repairMetadata": "修复元数据",
|
|
||||||
"rematchMetadata": "重新匹配到本地模型",
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "从源重新导入",
|
"reimportMetadata": "从源重新导入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
@@ -1095,13 +1121,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": "配方已从源重新导入成功",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "数值",
|
"valuePlaceholder": "数值",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
|
"invalidRange": "无效的范围格式。请使用 x.x-y.y",
|
||||||
|
"invalidValue": "请输入有效的数值",
|
||||||
|
"saveFailed": "保存预设参数失败",
|
||||||
|
"added": "已添加预设参数",
|
||||||
|
"updated": "已更新预设参数"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "触发词",
|
"label": "触发词",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "输入或点击下方建议添加",
|
"addPlaceholder": "输入或点击下方建议添加",
|
||||||
"editWord": "编辑触发词",
|
"editWord": "编辑触发词",
|
||||||
"editPlaceholder": "编辑触发词",
|
"editPlaceholder": "编辑触发词",
|
||||||
"copyWord": "复制触发词",
|
"copyOrEditWord": "单击复制,双击编辑",
|
||||||
"deleteWord": "删除触发词",
|
"deleteWord": "删除触发词",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "暂无建议",
|
"noSuggestions": "暂无建议",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "新手入门",
|
"gettingStarted": "新手入门",
|
||||||
"updateVlogs": "更新日志",
|
"updateVlogs": "更新日志",
|
||||||
"documentation": "文档"
|
"documentation": "文档",
|
||||||
|
"shortcuts": "快捷键"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "LoRA 管理器新手入门"
|
"title": "LoRA 管理器新手入门",
|
||||||
|
"replayTutorial": "重播教程"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "键盘与鼠标快捷键",
|
||||||
|
"groups": {
|
||||||
|
"general": "通用",
|
||||||
|
"actions": "操作",
|
||||||
|
"selection": "选择与批量模式",
|
||||||
|
"navigation": "导航",
|
||||||
|
"modelModal": "模型 / 配方弹窗",
|
||||||
|
"mediaViewer": "媒体查看器 / 示例展示"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "单击",
|
||||||
|
"drag": "拖动",
|
||||||
|
"rightClick": "右键点击",
|
||||||
|
"letter": "字母",
|
||||||
|
"swipe": "滑动"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "聚焦搜索框",
|
||||||
|
"closeModal": "关闭弹窗 / 面板",
|
||||||
|
"openShortcuts": "打开本快捷键面板",
|
||||||
|
"refresh": "刷新模型列表",
|
||||||
|
"fetchMetadata": "从 CivitAI 获取元数据(仅模型页面)",
|
||||||
|
"downloadModel": "下载模型(仅模型页面)",
|
||||||
|
"toggleBulkMode": "切换批量模式",
|
||||||
|
"selectAll": "全选所有可见模型",
|
||||||
|
"rangeSelect": "范围选择",
|
||||||
|
"marqueeSelect": "框选卡片(在网格空白区域)",
|
||||||
|
"exitBulkMode": "退出批量模式",
|
||||||
|
"bulkActions": "在已选中的卡片上:批量操作菜单",
|
||||||
|
"globalActions": "在页面空白区域:全局操作菜单(检查更新、管理已排除的模型)",
|
||||||
|
"scrollPages": "滚动页面",
|
||||||
|
"jumpAlphabet": "字母索引栏跳转",
|
||||||
|
"prevNext": "上一个 / 下一个模型",
|
||||||
|
"deleteEntry": "删除",
|
||||||
|
"cycleMedia": "切换媒体(在示例展示中按 [ / ])",
|
||||||
|
"swipeTouch": "在触屏设备上切换媒体",
|
||||||
|
"closeViewer": "关闭查看器"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "最新更新",
|
"title": "最新更新",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "设置与配置",
|
"settings": "设置与配置",
|
||||||
"extensions": "扩展",
|
"extensions": "扩展",
|
||||||
"newBadge": "新"
|
"newBadge": "新"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "新"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "检查更新",
|
"title": "检查更新",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
|
|
||||||
"repairBulkSkipped": "所选 {total} 个配方无需修复",
|
|
||||||
"repairBulkFailed": "修复所选配方失败:{message}",
|
|
||||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
||||||
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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": "重新导入某些配方失败",
|
||||||
|
|||||||
+90
-26
@@ -50,6 +50,27 @@
|
|||||||
"mb": "MB",
|
"mb": "MB",
|
||||||
"gb": "GB",
|
"gb": "GB",
|
||||||
"tb": "TB"
|
"tb": "TB"
|
||||||
|
},
|
||||||
|
"scanProgress": {
|
||||||
|
"refreshing": "正在重新整理 {type}...",
|
||||||
|
"fullRebuilding": "正在完整重建 {type}...",
|
||||||
|
"actionRefresh": "重新整理",
|
||||||
|
"actionFullRebuild": "完整重建",
|
||||||
|
"actionRefreshLower": "重新整理",
|
||||||
|
"actionRebuildLower": "重建",
|
||||||
|
"stages": {
|
||||||
|
"scan_folders": "正在掃描資料夾...",
|
||||||
|
"count_models": "找到 {total} 個檔案",
|
||||||
|
"process_models": "正在處理模型",
|
||||||
|
"reconcile_scan": "正在檢查變更...",
|
||||||
|
"process_new": "正在處理新模型",
|
||||||
|
"finalizing": "正在收尾..."
|
||||||
|
},
|
||||||
|
"eta": {
|
||||||
|
"lessThanMinute": "剩餘時間不到一分鐘",
|
||||||
|
"minutes": "剩餘約 {minutes} 分鐘",
|
||||||
|
"hours": "剩餘約 {hours} 小時 {minutes} 分鐘"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
@@ -75,7 +96,7 @@
|
|||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"title": "批次操作",
|
"title": "批次操作",
|
||||||
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批次模式。可選取多個模型並執行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 選取所有可見模型。"
|
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批量模式,選取多個模型並執行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 選取所有可見模型,<span class=\"onboarding-shortcut\">Shift+Click</span> 選取一段範圍。<br>• <span class=\"onboarding-shortcut\">Esc</span> 或點擊空白處離開批量模式。"
|
||||||
},
|
},
|
||||||
"searchOptions": {
|
"searchOptions": {
|
||||||
"title": "搜尋選項",
|
"title": "搜尋選項",
|
||||||
@@ -95,7 +116,19 @@
|
|||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
"title": "右鍵選單",
|
"title": "右鍵選單",
|
||||||
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟更多操作選單。"
|
"content": "<strong>右鍵點擊</strong>任一模型卡片,可開啟包含移動、刪除或編輯中繼資料等卡片操作的右鍵選單。"
|
||||||
|
},
|
||||||
|
"marqueeSelect": {
|
||||||
|
"title": "拖曳框選",
|
||||||
|
"content": "在網格空白處按住<strong>滑鼠左鍵</strong>並拖曳,畫出框選範圍,一次選取多張卡片。"
|
||||||
|
},
|
||||||
|
"dragToSidebar": {
|
||||||
|
"title": "拖曳整理",
|
||||||
|
"content": "將模型卡片拖曳到側邊欄的資料夾上,即可將檔案移動到該處。在批量模式下選取多張卡片也可一起拖曳。"
|
||||||
|
},
|
||||||
|
"contextMenus": {
|
||||||
|
"title": "更多右鍵選單",
|
||||||
|
"content": "在批量模式下,<strong>右鍵點擊已選取的卡片</strong>可開啟批量操作選單。<strong>右鍵點擊頁面空白處</strong>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -179,13 +212,6 @@
|
|||||||
"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": "正在將配方重新匹配到本地模型...",
|
||||||
@@ -451,6 +477,8 @@
|
|||||||
"layoutSettings": {
|
"layoutSettings": {
|
||||||
"groupByModel": "按模型分組",
|
"groupByModel": "按模型分組",
|
||||||
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
|
||||||
|
"stickyControls": "保持操作列可見",
|
||||||
|
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
|
||||||
"displayDensity": "顯示密度",
|
"displayDensity": "顯示密度",
|
||||||
"displayDensityOptions": {
|
"displayDensityOptions": {
|
||||||
"default": "預設",
|
"default": "預設",
|
||||||
@@ -786,7 +814,6 @@
|
|||||||
"setContentRating": "為全部設定內容分級",
|
"setContentRating": "為全部設定內容分級",
|
||||||
"copyAll": "複製全部語法",
|
"copyAll": "複製全部語法",
|
||||||
"refreshAll": "刷新全部 metadata",
|
"refreshAll": "刷新全部 metadata",
|
||||||
"repairMetadata": "修復所選中元數據",
|
|
||||||
"rematchMetadata": "將所選中重新匹配到本地模型",
|
"rematchMetadata": "將所選中重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"checkUpdates": "檢查所選更新",
|
"checkUpdates": "檢查所選更新",
|
||||||
@@ -842,7 +869,6 @@
|
|||||||
"replacePreview": "更換預覽圖",
|
"replacePreview": "更換預覽圖",
|
||||||
"setContentRating": "設定內容分級",
|
"setContentRating": "設定內容分級",
|
||||||
"moveToFolder": "移動到資料夾",
|
"moveToFolder": "移動到資料夾",
|
||||||
"repairMetadata": "修復元數據",
|
|
||||||
"rematchMetadata": "重新匹配到本地模型",
|
"rematchMetadata": "重新匹配到本地模型",
|
||||||
"reimportMetadata": "從來源重新匯入",
|
"reimportMetadata": "從來源重新匯入",
|
||||||
"excludeModel": "排除模型",
|
"excludeModel": "排除模型",
|
||||||
@@ -1095,13 +1121,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": "配方已從來源重新匯入成功",
|
||||||
@@ -1593,7 +1612,11 @@
|
|||||||
"clipSkip": "Clip Skip",
|
"clipSkip": "Clip Skip",
|
||||||
"valuePlaceholder": "數值",
|
"valuePlaceholder": "數值",
|
||||||
"add": "新增",
|
"add": "新增",
|
||||||
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
|
"invalidRange": "無效的範圍格式。請使用 x.x-y.y",
|
||||||
|
"invalidValue": "請輸入有效的數值",
|
||||||
|
"saveFailed": "儲存預設參數失敗",
|
||||||
|
"added": "已新增預設參數",
|
||||||
|
"updated": "已更新預設參數"
|
||||||
},
|
},
|
||||||
"triggerWords": {
|
"triggerWords": {
|
||||||
"label": "觸發詞",
|
"label": "觸發詞",
|
||||||
@@ -1604,7 +1627,7 @@
|
|||||||
"addPlaceholder": "輸入或點擊下方建議",
|
"addPlaceholder": "輸入或點擊下方建議",
|
||||||
"editWord": "編輯觸發詞",
|
"editWord": "編輯觸發詞",
|
||||||
"editPlaceholder": "編輯觸發詞",
|
"editPlaceholder": "編輯觸發詞",
|
||||||
"copyWord": "複製觸發詞",
|
"copyOrEditWord": "點擊複製,雙擊編輯",
|
||||||
"deleteWord": "刪除觸發詞",
|
"deleteWord": "刪除觸發詞",
|
||||||
"suggestions": {
|
"suggestions": {
|
||||||
"noSuggestions": "無可用建議",
|
"noSuggestions": "無可用建議",
|
||||||
@@ -1929,10 +1952,52 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"gettingStarted": "快速開始",
|
"gettingStarted": "快速開始",
|
||||||
"updateVlogs": "更新影片",
|
"updateVlogs": "更新影片",
|
||||||
"documentation": "文件"
|
"documentation": "文件",
|
||||||
|
"shortcuts": "快捷鍵"
|
||||||
},
|
},
|
||||||
"gettingStarted": {
|
"gettingStarted": {
|
||||||
"title": "LoRA 管理器快速開始"
|
"title": "LoRA 管理器快速開始",
|
||||||
|
"replayTutorial": "重新播放教學"
|
||||||
|
},
|
||||||
|
"shortcuts": {
|
||||||
|
"title": "鍵盤與滑鼠快捷鍵",
|
||||||
|
"groups": {
|
||||||
|
"general": "一般",
|
||||||
|
"actions": "操作",
|
||||||
|
"selection": "選取與批量模式",
|
||||||
|
"navigation": "導覽",
|
||||||
|
"modelModal": "模型 / 配方彈窗",
|
||||||
|
"mediaViewer": "媒體檢視器 / 範例展示"
|
||||||
|
},
|
||||||
|
"keys": {
|
||||||
|
"click": "點擊",
|
||||||
|
"drag": "拖曳",
|
||||||
|
"rightClick": "右鍵點擊",
|
||||||
|
"letter": "字母鍵",
|
||||||
|
"swipe": "滑動"
|
||||||
|
},
|
||||||
|
"entries": {
|
||||||
|
"focusSearch": "聚焦搜尋欄",
|
||||||
|
"closeModal": "關閉彈窗 / 面板",
|
||||||
|
"openShortcuts": "開啟此快捷鍵面板",
|
||||||
|
"refresh": "重新整理模型列表",
|
||||||
|
"fetchMetadata": "從 CivitAI 擷取中繼資料(僅限模型頁面)",
|
||||||
|
"downloadModel": "下載模型(僅限模型頁面)",
|
||||||
|
"toggleBulkMode": "切換批量模式",
|
||||||
|
"selectAll": "選取所有可見模型",
|
||||||
|
"rangeSelect": "範圍選取",
|
||||||
|
"marqueeSelect": "框選卡片(在網格空白處拖曳)",
|
||||||
|
"exitBulkMode": "離開批量模式",
|
||||||
|
"bulkActions": "在已選取的卡片上:批量操作選單",
|
||||||
|
"globalActions": "在頁面空白處:全域操作選單(檢查更新、管理已排除的模型)",
|
||||||
|
"scrollPages": "捲動頁面",
|
||||||
|
"jumpAlphabet": "字母列跳轉",
|
||||||
|
"prevNext": "上一個 / 下一個模型",
|
||||||
|
"deleteEntry": "刪除",
|
||||||
|
"cycleMedia": "切換媒體(範例展示中的 [ / ])",
|
||||||
|
"swipeTouch": "在觸控裝置上滑動切換媒體",
|
||||||
|
"closeViewer": "關閉檢視器"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"updateVlogs": {
|
"updateVlogs": {
|
||||||
"title": "最新更新",
|
"title": "最新更新",
|
||||||
@@ -1949,7 +2014,8 @@
|
|||||||
"settings": "設定與配置",
|
"settings": "設定與配置",
|
||||||
"extensions": "擴充功能",
|
"extensions": "擴充功能",
|
||||||
"newBadge": "新"
|
"newBadge": "新"
|
||||||
}
|
},
|
||||||
|
"newContentBadge": "新"
|
||||||
},
|
},
|
||||||
"update": {
|
"update": {
|
||||||
"title": "檢查更新",
|
"title": "檢查更新",
|
||||||
@@ -2156,9 +2222,6 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何配方",
|
"noRecipesSelected": "未選取任何配方",
|
||||||
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
|
|
||||||
"repairBulkSkipped": "所選 {total} 個配方無需修復",
|
|
||||||
"repairBulkFailed": "修復所選配方失敗:{message}",
|
|
||||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
||||||
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
||||||
@@ -2166,6 +2229,7 @@
|
|||||||
"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": "重新匯入某些配方失敗",
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ from aiohttp import web
|
|||||||
import jinja2
|
import jinja2
|
||||||
|
|
||||||
from ...config import config
|
from ...config import config
|
||||||
|
from ...services.active_filters_store import (
|
||||||
|
ActiveFiltersStore,
|
||||||
|
active_filters_to_query_kwargs,
|
||||||
|
)
|
||||||
from ...services.download_coordinator import DownloadCoordinator
|
from ...services.download_coordinator import DownloadCoordinator
|
||||||
from ...services.connectivity_guard import (
|
from ...services.connectivity_guard import (
|
||||||
OFFLINE_FRIENDLY_MESSAGE,
|
OFFLINE_FRIENDLY_MESSAGE,
|
||||||
@@ -1595,12 +1599,50 @@ class ModelQueryHandler:
|
|||||||
allow_selling_generated_content.lower() not in ("false", "0", "")
|
allow_selling_generated_content.lower() not in ("false", "0", "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# When requested, merge the manager page's active filters stored
|
||||||
|
# server-side. Explicit query parameters take precedence over the
|
||||||
|
# stored values.
|
||||||
|
use_active_filters = (
|
||||||
|
request.query.get("use_active_filters", "").lower() in ("1", "true")
|
||||||
|
)
|
||||||
|
if use_active_filters:
|
||||||
|
stored = ActiveFiltersStore.get_instance().get_filters(
|
||||||
|
self._service.model_type
|
||||||
|
)
|
||||||
|
injected = active_filters_to_query_kwargs(stored)
|
||||||
|
if folder is None and "folder" in injected:
|
||||||
|
folder = injected["folder"]
|
||||||
|
if "recursive" not in request.query and "recursive" in injected:
|
||||||
|
recursive = injected["recursive"]
|
||||||
|
if not base_models and injected.get("base_models"):
|
||||||
|
base_models = injected["base_models"]
|
||||||
|
if not model_types and injected.get("model_types"):
|
||||||
|
model_types = injected["model_types"]
|
||||||
|
if not tag_filters and injected.get("tags"):
|
||||||
|
tag_filters = injected["tags"]
|
||||||
|
if not auto_tag_filters and injected.get("auto_tags"):
|
||||||
|
auto_tag_filters = injected["auto_tags"]
|
||||||
|
if "tag_logic" not in request.query and injected.get("tag_logic"):
|
||||||
|
injected_logic = str(injected["tag_logic"]).lower()
|
||||||
|
if injected_logic in ("any", "all"):
|
||||||
|
tag_logic = injected_logic
|
||||||
|
if credit_required is None and "credit_required" in injected:
|
||||||
|
credit_required = injected["credit_required"]
|
||||||
|
if (
|
||||||
|
allow_selling_generated_content is None
|
||||||
|
and "allow_selling_generated_content" in injected
|
||||||
|
):
|
||||||
|
allow_selling_generated_content = injected[
|
||||||
|
"allow_selling_generated_content"
|
||||||
|
]
|
||||||
|
|
||||||
# The presence of the recursive param (always sent by the loras
|
# The presence of the recursive param (always sent by the loras
|
||||||
# widget when filter mode is on) signals that the filter pipeline
|
# widget when filter mode is on) signals that the filter pipeline
|
||||||
# must run even when no concrete filter is set, so global settings
|
# must run even when no concrete filter is set, so global settings
|
||||||
# like show_only_sfw stay consistent with the list endpoint.
|
# like show_only_sfw stay consistent with the list endpoint.
|
||||||
apply_filters = (
|
apply_filters = (
|
||||||
"recursive" in request.query
|
use_active_filters
|
||||||
|
or "recursive" in request.query
|
||||||
or folder is not None
|
or folder is not None
|
||||||
or bool(base_models)
|
or bool(base_models)
|
||||||
or bool(model_types)
|
or bool(model_types)
|
||||||
@@ -1634,6 +1676,50 @@ class ModelQueryHandler:
|
|||||||
)
|
)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def update_active_filters(self, request: web.Request) -> web.Response:
|
||||||
|
"""Store the manager page's active filters for this model type."""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Body must be a JSON object"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ActiveFiltersStore.get_instance().set_filters(
|
||||||
|
self._service.model_type, payload
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error updating active filters for %s: %s",
|
||||||
|
self._service.model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_active_filters(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return the stored active filters for this model type."""
|
||||||
|
try:
|
||||||
|
filters = ActiveFiltersStore.get_instance().get_filters(
|
||||||
|
self._service.model_type
|
||||||
|
)
|
||||||
|
return web.json_response({"success": True, "filters": filters})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error getting active filters for %s: %s",
|
||||||
|
self._service.model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class ModelDownloadHandler:
|
class ModelDownloadHandler:
|
||||||
"""Coordinate downloads and progress reporting."""
|
"""Coordinate downloads and progress reporting."""
|
||||||
@@ -3339,6 +3425,8 @@ class ModelHandlerSet:
|
|||||||
"get_model_metadata": self.query.get_model_metadata,
|
"get_model_metadata": self.query.get_model_metadata,
|
||||||
"get_model_description": self.query.get_model_description,
|
"get_model_description": self.query.get_model_description,
|
||||||
"get_relative_paths": self.query.get_relative_paths,
|
"get_relative_paths": self.query.get_relative_paths,
|
||||||
|
"update_active_filters": self.query.update_active_filters,
|
||||||
|
"get_active_filters": self.query.get_active_filters,
|
||||||
"refresh_model_updates": self.updates.refresh_model_updates,
|
"refresh_model_updates": self.updates.refresh_model_updates,
|
||||||
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
|
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
|
||||||
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
"set_model_update_ignore": self.updates.set_model_update_ignore,
|
||||||
|
|||||||
@@ -129,11 +129,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 +791,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 +802,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,
|
||||||
@@ -1128,15 +969,21 @@ class RecipeManagementHandler:
|
|||||||
image_id = extract_civitai_image_id(source_path) if source_path else None
|
image_id = extract_civitai_image_id(source_path) if source_path else None
|
||||||
|
|
||||||
# Local re-import sources: an explicit local source_path, or — when
|
# Local re-import sources: an explicit local source_path, or — when
|
||||||
# no source_path was recorded (drag & drop / file-picker imports) —
|
# no usable source_path was recorded (drag & drop / file-picker
|
||||||
# the recipe's own saved image, which still carries the original
|
# imports, or a dangling path left by an earlier re-import) — the
|
||||||
|
# recipe's own saved image, which still carries the original
|
||||||
# embedded generation metadata next to the recipe metadata block.
|
# embedded generation metadata next to the recipe metadata block.
|
||||||
|
# In the fallback case nothing is persisted as source_path: the
|
||||||
|
# recipe's own previous preview is not an external source, and it
|
||||||
|
# is deleted together with the old recipe below.
|
||||||
local_source = None
|
local_source = None
|
||||||
|
persisted_source_path = ""
|
||||||
if not image_id and source_path and os.path.isfile(source_path):
|
if not image_id and source_path and os.path.isfile(source_path):
|
||||||
local_source = source_path
|
local_source = source_path
|
||||||
|
persisted_source_path = source_path
|
||||||
elif (
|
elif (
|
||||||
not image_id
|
not image_id
|
||||||
and not source_path
|
and not source_path.startswith(("http://", "https://"))
|
||||||
and old_file_path
|
and old_file_path
|
||||||
and os.path.isfile(old_file_path)
|
and os.path.isfile(old_file_path)
|
||||||
):
|
):
|
||||||
@@ -1170,14 +1017,58 @@ class RecipeManagementHandler:
|
|||||||
target_dir=old_folder,
|
target_dir=old_folder,
|
||||||
user_edits=user_edits,
|
user_edits=user_edits,
|
||||||
old_title=old_recipe.get("title", ""),
|
old_title=old_recipe.get("title", ""),
|
||||||
|
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
|
||||||
@@ -1204,14 +1095,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:
|
||||||
@@ -1224,18 +1120,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()
|
||||||
@@ -1256,31 +1140,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,
|
||||||
)
|
)
|
||||||
@@ -1294,6 +1161,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,
|
||||||
*,
|
*,
|
||||||
@@ -1305,6 +1218,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:
|
||||||
@@ -1468,6 +1382,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)
|
||||||
|
|
||||||
@@ -1932,6 +1847,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
|
||||||
@@ -2512,6 +2446,7 @@ class RecipeManagementHandler:
|
|||||||
target_dir: str | None,
|
target_dir: str | None,
|
||||||
user_edits: dict[str, Any],
|
user_edits: dict[str, Any],
|
||||||
old_title: str,
|
old_title: str,
|
||||||
|
persisted_source_path: str,
|
||||||
) -> web.Response:
|
) -> web.Response:
|
||||||
"""Re-import a recipe from a local image file.
|
"""Re-import a recipe from a local image file.
|
||||||
|
|
||||||
@@ -2519,6 +2454,12 @@ class RecipeManagementHandler:
|
|||||||
generation metadata (the appended recipe metadata block is ignored so
|
generation metadata (the appended recipe metadata block is ignored so
|
||||||
the current parser gets a fresh pass), saves a new recipe, then deletes
|
the current parser gets a fresh pass), saves a new recipe, then deletes
|
||||||
the old one.
|
the old one.
|
||||||
|
|
||||||
|
``persisted_source_path`` is the source_path recorded on the new
|
||||||
|
recipe: the external source file when one exists, or empty when the
|
||||||
|
re-import fell back to the recipe's own previous preview image (that
|
||||||
|
file is deleted with the old recipe, so recording it would leave a
|
||||||
|
dangling path that blocks future re-imports).
|
||||||
"""
|
"""
|
||||||
normalized = os.path.normpath(file_path)
|
normalized = os.path.normpath(file_path)
|
||||||
if not os.path.isfile(normalized):
|
if not os.path.isfile(normalized):
|
||||||
@@ -2547,7 +2488,7 @@ class RecipeManagementHandler:
|
|||||||
"base_model": base_model,
|
"base_model": base_model,
|
||||||
"loras": loras,
|
"loras": loras,
|
||||||
"gen_params": gen_params,
|
"gen_params": gen_params,
|
||||||
"source_path": normalized,
|
"source_path": persisted_source_path,
|
||||||
}
|
}
|
||||||
if checkpoint:
|
if checkpoint:
|
||||||
metadata["checkpoint"] = checkpoint
|
metadata["checkpoint"] = checkpoint
|
||||||
@@ -2610,7 +2551,7 @@ class RecipeManagementHandler:
|
|||||||
"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": normalized,
|
"source_path": persisted_source_path,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
|
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
|
||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
|
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
|
||||||
|
RouteDefinition("PUT", "/api/lm/{prefix}/active-filters", "update_active_filters"),
|
||||||
|
RouteDefinition("GET", "/api/lm/{prefix}/active-filters", "get_active_filters"),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
|
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"""In-memory store for the LoRA Manager page's active filters.
|
||||||
|
|
||||||
|
The manager page keeps its filter state in localStorage for its own
|
||||||
|
restoration, but the ComfyUI node autocomplete runs in a potentially
|
||||||
|
different browser/origin (or Electron shell) where that storage is not
|
||||||
|
shared. This store mirrors the active filters server-side so the
|
||||||
|
``/api/lm/{prefix}/relative-paths`` endpoint can inject them into
|
||||||
|
autocomplete searches regardless of which client set them.
|
||||||
|
|
||||||
|
State is process-local and intentionally not persisted; the manager page
|
||||||
|
re-pushes its restored state on load.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Keys copied from the manager page's persisted filter snapshot.
|
||||||
|
_FILTER_KEYS = (
|
||||||
|
"baseModel",
|
||||||
|
"tags",
|
||||||
|
"autoTags",
|
||||||
|
"modelTypes",
|
||||||
|
"tagLogic",
|
||||||
|
"license",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ActiveFiltersStore:
|
||||||
|
"""Process-local store of active filters, keyed by model type."""
|
||||||
|
|
||||||
|
_instance: Optional["ActiveFiltersStore"] = None
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._filters: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_instance(cls) -> "ActiveFiltersStore":
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = cls()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def reset_instance(cls) -> None:
|
||||||
|
"""Drop the singleton (test isolation)."""
|
||||||
|
cls._instance = None
|
||||||
|
|
||||||
|
def set_filters(self, model_type: str, payload: Dict[str, Any]) -> None:
|
||||||
|
"""Replace the stored active filters for a model type.
|
||||||
|
|
||||||
|
Only recognized keys are kept; everything else is discarded.
|
||||||
|
"""
|
||||||
|
filters = payload.get("filters")
|
||||||
|
sanitized: Dict[str, Any] = {
|
||||||
|
"activeFolder": payload.get("activeFolder"),
|
||||||
|
"recursiveSearch": bool(payload.get("recursiveSearch", True)),
|
||||||
|
"filters": (
|
||||||
|
{key: filters[key] for key in _FILTER_KEYS if key in filters}
|
||||||
|
if isinstance(filters, dict)
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
self._filters[model_type] = sanitized
|
||||||
|
|
||||||
|
def get_filters(self, model_type: str) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Return the stored payload for a model type, or None if unset."""
|
||||||
|
return self._filters.get(model_type)
|
||||||
|
|
||||||
|
def clear(self, model_type: str) -> None:
|
||||||
|
self._filters.pop(model_type, None)
|
||||||
|
|
||||||
|
|
||||||
|
def active_filters_to_query_kwargs(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
||||||
|
"""Map a stored active-filters payload to ``search_relative_paths`` kwargs.
|
||||||
|
|
||||||
|
Mirrors the query-param mapping that the ComfyUI autocomplete used to
|
||||||
|
build client-side from localStorage (web/comfyui/autocomplete.js).
|
||||||
|
"""
|
||||||
|
kwargs: Dict[str, Any] = {}
|
||||||
|
if not payload:
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
active_folder = payload.get("activeFolder")
|
||||||
|
recursive = payload.get("recursiveSearch", True)
|
||||||
|
|
||||||
|
if active_folder and active_folder != "null":
|
||||||
|
kwargs["folder"] = active_folder
|
||||||
|
elif not recursive:
|
||||||
|
# Root folder with recursion disabled mirrors the page list,
|
||||||
|
# which matches only root-level files via folder=''.
|
||||||
|
kwargs["folder"] = ""
|
||||||
|
|
||||||
|
filters = payload.get("filters")
|
||||||
|
if isinstance(filters, dict):
|
||||||
|
base_models = filters.get("baseModel")
|
||||||
|
if isinstance(base_models, list):
|
||||||
|
kwargs["base_models"] = [m for m in base_models if m]
|
||||||
|
|
||||||
|
for source_key, target_key in (("tags", "tags"), ("autoTags", "auto_tags")):
|
||||||
|
states = filters.get(source_key)
|
||||||
|
if isinstance(states, dict):
|
||||||
|
mapped = {
|
||||||
|
tag: state
|
||||||
|
for tag, state in states.items()
|
||||||
|
if state in ("include", "exclude")
|
||||||
|
}
|
||||||
|
if mapped:
|
||||||
|
kwargs[target_key] = mapped
|
||||||
|
|
||||||
|
model_types = filters.get("modelTypes")
|
||||||
|
if isinstance(model_types, list):
|
||||||
|
kwargs["model_types"] = [t for t in model_types if t]
|
||||||
|
|
||||||
|
tag_logic = filters.get("tagLogic")
|
||||||
|
if tag_logic:
|
||||||
|
kwargs["tag_logic"] = tag_logic
|
||||||
|
|
||||||
|
license_filter = filters.get("license")
|
||||||
|
if isinstance(license_filter, dict):
|
||||||
|
no_credit = license_filter.get("noCredit")
|
||||||
|
if no_credit == "include":
|
||||||
|
kwargs["credit_required"] = False
|
||||||
|
elif no_credit == "exclude":
|
||||||
|
kwargs["credit_required"] = True
|
||||||
|
allow_selling = license_filter.get("allowSelling")
|
||||||
|
if allow_selling == "include":
|
||||||
|
kwargs["allow_selling_generated_content"] = True
|
||||||
|
elif allow_selling == "exclude":
|
||||||
|
kwargs["allow_selling_generated_content"] = False
|
||||||
|
|
||||||
|
kwargs["recursive"] = recursive
|
||||||
|
return kwargs
|
||||||
@@ -1295,6 +1295,27 @@ class BaseModelService(ABC):
|
|||||||
path_for_sorting,
|
path_for_sorting,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _relative_path_folder_group_sort_key(
|
||||||
|
relative_path: str, include_terms: List[str]
|
||||||
|
) -> tuple:
|
||||||
|
"""Group paths by folder, then sort by relevance within each group.
|
||||||
|
|
||||||
|
Folders are ordered alphabetically (case-insensitive) by their full
|
||||||
|
folder path, with root-level files (empty folder) first. Within a
|
||||||
|
folder, paths keep the relevance ordering of
|
||||||
|
``_relative_path_sort_key``. This keeps same-folder entries together
|
||||||
|
in the autocomplete dropdown instead of interleaving them by filename.
|
||||||
|
"""
|
||||||
|
path_for_sorting = BaseModelService._remove_model_extension(
|
||||||
|
relative_path.lower()
|
||||||
|
)
|
||||||
|
folder = path_for_sorting.rpartition(os.sep)[0]
|
||||||
|
|
||||||
|
return (folder,) + BaseModelService._relative_path_sort_key(
|
||||||
|
relative_path, include_terms
|
||||||
|
)
|
||||||
|
|
||||||
async def search_relative_paths(
|
async def search_relative_paths(
|
||||||
self,
|
self,
|
||||||
search_term: str,
|
search_term: str,
|
||||||
@@ -1404,9 +1425,13 @@ class BaseModelService(ABC):
|
|||||||
):
|
):
|
||||||
matching_paths.append(relative_path)
|
matching_paths.append(relative_path)
|
||||||
|
|
||||||
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
|
# Group by folder (root first, then alphabetically) and sort by
|
||||||
|
# relevance (prefix and earliest hits, then length and alphabetically)
|
||||||
|
# within each folder group.
|
||||||
matching_paths.sort(
|
matching_paths.sort(
|
||||||
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
|
key=lambda relative: self._relative_path_folder_group_sort_key(
|
||||||
|
relative, include_terms
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply offset and limit
|
# Apply offset and limit
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from .service_registry import ServiceRegistry
|
|||||||
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,
|
||||||
*,
|
*,
|
||||||
@@ -1858,6 +1895,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:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
|
|||||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||||
return _catalog_cache or {}
|
return _catalog_cache or {}
|
||||||
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.warning("Failed to fetch model catalog: %s", exc)
|
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||||
return _catalog_cache or {}
|
return _catalog_cache or {}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
|
|||||||
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 []
|
||||||
|
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
|
||||||
|
|||||||
+137
-16
@@ -66,6 +66,14 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
|||||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
# requests (modal open + autocomplete) do not re-walk the model roots.
|
||||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
||||||
|
|
||||||
|
# Maps a scanner model type to the manager page type used in progress
|
||||||
|
# broadcasts (e.g. 'lora' -> 'loras').
|
||||||
|
PAGE_TYPE_MAP = {
|
||||||
|
'lora': 'loras',
|
||||||
|
'checkpoint': 'checkpoints',
|
||||||
|
'embedding': 'embeddings',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _is_pending_delete_path(path: str) -> bool:
|
def _is_pending_delete_path(path: str) -> bool:
|
||||||
"""Return True when any path component is the pending-delete staging dir."""
|
"""Return True when any path component is the pending-delete staging dir."""
|
||||||
@@ -149,6 +157,38 @@ class ModelScanner:
|
|||||||
# Register this service
|
# Register this service
|
||||||
asyncio.create_task(self._register_service())
|
asyncio.create_task(self._register_service())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def page_type(self) -> str:
|
||||||
|
"""Manager page type used in progress broadcasts (e.g. 'loras')."""
|
||||||
|
return PAGE_TYPE_MAP.get(self.model_type, self.model_type)
|
||||||
|
|
||||||
|
async def _broadcast_scan_progress(
|
||||||
|
self,
|
||||||
|
status: str,
|
||||||
|
stage: str,
|
||||||
|
progress: int,
|
||||||
|
full_rebuild: bool,
|
||||||
|
**extra: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast manual-refresh scan progress on the generic WS channel.
|
||||||
|
|
||||||
|
Best-effort only: broadcast failures must never affect the scan itself.
|
||||||
|
"""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
'type': 'scan_progress',
|
||||||
|
'status': status,
|
||||||
|
'model_type': self.model_type,
|
||||||
|
'pageType': self.page_type,
|
||||||
|
'stage': stage,
|
||||||
|
'full_rebuild': full_rebuild,
|
||||||
|
'progress': progress,
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast(payload)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(f"Error broadcasting scan progress for {self.model_type}: {exc}")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cache_version(self) -> int:
|
def cache_version(self) -> int:
|
||||||
"""Monotonic version counter for the in-memory cache.
|
"""Monotonic version counter for the in-memory cache.
|
||||||
@@ -434,12 +474,7 @@ class ModelScanner:
|
|||||||
self._is_initializing = True
|
self._is_initializing = True
|
||||||
|
|
||||||
# Determine the page type based on model type
|
# Determine the page type based on model type
|
||||||
page_type_map = {
|
page_type = self.page_type
|
||||||
'lora': 'loras',
|
|
||||||
'checkpoint': 'checkpoints',
|
|
||||||
'embedding': 'embeddings'
|
|
||||||
}
|
|
||||||
page_type = page_type_map.get(self.model_type, self.model_type)
|
|
||||||
|
|
||||||
# First, try to load from cache
|
# First, try to load from cache
|
||||||
await ws_manager.broadcast_init_progress({
|
await ws_manager.broadcast_init_progress({
|
||||||
@@ -804,7 +839,7 @@ class ModelScanner:
|
|||||||
last_progress_time = time.time()
|
last_progress_time = time.time()
|
||||||
last_progress_percent = 0
|
last_progress_percent = 0
|
||||||
|
|
||||||
async def progress_callback(processed_files: int, expected_total: int) -> None:
|
async def progress_callback(processed_files: int, expected_total: int, current_name: str = '') -> None:
|
||||||
nonlocal last_progress_time, last_progress_percent
|
nonlocal last_progress_time, last_progress_percent
|
||||||
|
|
||||||
if expected_total <= 0:
|
if expected_total <= 0:
|
||||||
@@ -871,32 +906,84 @@ class ModelScanner:
|
|||||||
async def _initialize_cache(self) -> None:
|
async def _initialize_cache(self) -> None:
|
||||||
"""Initialize or refresh the cache"""
|
"""Initialize or refresh the cache"""
|
||||||
self._is_initializing = True # Set flag
|
self._is_initializing = True # Set flag
|
||||||
|
last_progress_percent = 0
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
await self._broadcast_scan_progress('started', 'scan_folders', 0, True)
|
||||||
|
|
||||||
# Manually trigger a symlink rescan during a full rebuild.
|
# Manually trigger a symlink rescan during a full rebuild.
|
||||||
# This ensures that any new symlink mappings are correctly picked up.
|
# This ensures that any new symlink mappings are correctly picked up.
|
||||||
config.rebuild_symlink_cache()
|
config.rebuild_symlink_cache()
|
||||||
|
|
||||||
# Determine the page type based on model type
|
# Count files in a thread so the event loop stays responsive
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
total_files = await loop.run_in_executor(None, self._count_model_files)
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'processing', 'count_models', 1, True,
|
||||||
|
processed=0, total=total_files,
|
||||||
|
)
|
||||||
|
|
||||||
|
last_progress_time = time.time()
|
||||||
|
|
||||||
|
async def progress_callback(processed_files: int, expected_total: int, current_name: str = '') -> None:
|
||||||
|
nonlocal last_progress_time, last_progress_percent
|
||||||
|
|
||||||
|
if expected_total <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
progress_percent = min(99, int(1 + (processed_files / expected_total) * 98))
|
||||||
|
|
||||||
|
if progress_percent <= last_progress_percent:
|
||||||
|
return
|
||||||
|
|
||||||
|
if current_time - last_progress_time <= 0.5 and processed_files != expected_total:
|
||||||
|
return
|
||||||
|
|
||||||
|
last_progress_percent = progress_percent
|
||||||
|
last_progress_time = current_time
|
||||||
|
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'processing', 'process_models', progress_percent, True,
|
||||||
|
processed=processed_files, total=expected_total,
|
||||||
|
current_name=current_name,
|
||||||
|
)
|
||||||
|
|
||||||
# Scan for new data
|
# Scan for new data
|
||||||
scan_result = await self._gather_model_data()
|
scan_result = await self._gather_model_data(
|
||||||
|
total_files=total_files,
|
||||||
|
progress_callback=progress_callback,
|
||||||
|
)
|
||||||
if not self.is_cancelled():
|
if not self.is_cancelled():
|
||||||
|
await self._broadcast_scan_progress('finalizing', 'finalizing', 99, True)
|
||||||
await self._apply_scan_result(scan_result)
|
await self._apply_scan_result(scan_result)
|
||||||
await self._save_persistent_cache(scan_result)
|
await self._save_persistent_cache(scan_result)
|
||||||
await self._sync_download_history(scan_result.raw_data, source='scan')
|
await self._sync_download_history(scan_result.raw_data, source='scan')
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'completed', 'finalizing', 100, True,
|
||||||
|
elapsed_seconds=time.time() - start_time,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
|
||||||
f"found {len(scan_result.raw_data)} models"
|
f"found {len(scan_result.raw_data)} models"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'cancelled', 'process_models', last_progress_percent, True,
|
||||||
|
elapsed_seconds=time.time() - start_time,
|
||||||
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
|
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
|
||||||
f"after {time.time() - start_time:.2f} seconds"
|
f"after {time.time() - start_time:.2f} seconds"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'error', 'process_models', last_progress_percent, True,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
# Ensure cache is at least an empty structure on error
|
# Ensure cache is at least an empty structure on error
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
self._cache = ModelCache(
|
self._cache = ModelCache(
|
||||||
@@ -914,6 +1001,8 @@ class ModelScanner:
|
|||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Starting fast cache reconciliation...")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Starting fast cache reconciliation...")
|
||||||
|
|
||||||
|
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
||||||
|
|
||||||
# Get current cached file paths
|
# Get current cached file paths
|
||||||
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
||||||
@@ -987,6 +1076,10 @@ class ModelScanner:
|
|||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'cancelled', 'reconcile_scan', 0, False,
|
||||||
|
elapsed_seconds=time.time() - start_time,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Process new files in batches
|
# Process new files in batches
|
||||||
@@ -994,10 +1087,14 @@ class ModelScanner:
|
|||||||
if new_files:
|
if new_files:
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Found {len(new_files)} new files to process")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Found {len(new_files)} new files to process")
|
||||||
batch_size = 50
|
batch_size = 50
|
||||||
for i in range(0, len(new_files), batch_size):
|
total_new = len(new_files)
|
||||||
|
processed_new = 0
|
||||||
|
last_progress_time = time.time()
|
||||||
|
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:
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}")
|
||||||
|
processed_new += 1
|
||||||
try:
|
try:
|
||||||
# Find the appropriate root path for this file
|
# Find the appropriate root path for this file
|
||||||
root_path = None
|
root_path = None
|
||||||
@@ -1053,9 +1150,24 @@ class ModelScanner:
|
|||||||
logger.error(f"Could not determine root path for {path}")
|
logger.error(f"Could not determine root path for {path}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding {path} to cache: {e}")
|
logger.error(f"Error adding {path} to cache: {e}")
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
if current_time - last_progress_time > 0.5 or processed_new == total_new:
|
||||||
|
last_progress_time = current_time
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'processing', 'process_new',
|
||||||
|
min(99, int(1 + (processed_new / total_new) * 98)), False,
|
||||||
|
processed=processed_new, total=total_new,
|
||||||
|
current_name=os.path.basename(path),
|
||||||
|
)
|
||||||
|
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'cancelled', 'process_new',
|
||||||
|
min(99, int(1 + (processed_new / total_new) * 98)), False,
|
||||||
|
elapsed_seconds=time.time() - start_time,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Find missing files (in cache but not in filesystem)
|
# Find missing files (in cache but not in filesystem)
|
||||||
@@ -1121,8 +1233,17 @@ class ModelScanner:
|
|||||||
await self._persist_current_cache()
|
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(
|
||||||
|
'completed', 'process_new', 100, False,
|
||||||
|
added=total_added, removed=total_removed,
|
||||||
|
elapsed_seconds=time.time() - start_time,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
|
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
|
||||||
|
await self._broadcast_scan_progress(
|
||||||
|
'error', 'reconcile_scan', 0, False,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
self._is_initializing = False # Unset flag
|
self._is_initializing = False # Unset flag
|
||||||
self.bump_cache_version()
|
self.bump_cache_version()
|
||||||
@@ -1498,7 +1619,7 @@ class ModelScanner:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
total_files: int = 0,
|
total_files: int = 0,
|
||||||
progress_callback: Optional[Callable[[int, int], Awaitable[None]]] = None
|
progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None
|
||||||
) -> CacheBuildResult:
|
) -> CacheBuildResult:
|
||||||
"""Collect metadata for all model files."""
|
"""Collect metadata for all model files."""
|
||||||
|
|
||||||
@@ -1510,11 +1631,11 @@ class ModelScanner:
|
|||||||
processed_real_files: Set[str] = set()
|
processed_real_files: Set[str] = set()
|
||||||
visited_real_dirs: Set[str] = set()
|
visited_real_dirs: Set[str] = set()
|
||||||
|
|
||||||
async def handle_progress() -> None:
|
async def handle_progress(current_name: str = '') -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await progress_callback(processed_files, total_files)
|
await progress_callback(processed_files, total_files, current_name)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
|
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
|
||||||
|
|
||||||
@@ -1580,7 +1701,7 @@ class ModelScanner:
|
|||||||
for tag in result.get('tags') or []:
|
for tag in result.get('tags') or []:
|
||||||
tags_count[tag] = tags_count.get(tag, 0) + 1
|
tags_count[tag] = tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
await handle_progress()
|
await handle_progress(entry.name)
|
||||||
await asyncio.sleep(0)
|
await asyncio.sleep(0)
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+102
-207
@@ -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,
|
||||||
@@ -811,207 +809,6 @@ 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(
|
|
||||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Repair all recipes by enrichment with Civitai and embedded metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
persistence_service: Service for saving updated recipes
|
|
||||||
progress_callback: Optional callback for progress updates
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict summary of repair results
|
|
||||||
"""
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback({"status": "started"})
|
|
||||||
async with self._mutation_lock:
|
|
||||||
cache = await self.get_cached_data()
|
|
||||||
all_recipes = list(cache.raw_data)
|
|
||||||
total = len(all_recipes)
|
|
||||||
repaired_count = 0
|
|
||||||
skipped_count = 0
|
|
||||||
errors_count = 0
|
|
||||||
|
|
||||||
civitai_client = await self._get_civitai_client()
|
|
||||||
self.reset_cancellation()
|
|
||||||
|
|
||||||
for i, recipe in enumerate(all_recipes):
|
|
||||||
if self.is_cancelled():
|
|
||||||
logger.info("Recipe repair cancelled by user")
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback(
|
|
||||||
{
|
|
||||||
"status": "cancelled",
|
|
||||||
"current": i,
|
|
||||||
"total": total,
|
|
||||||
"repaired": repaired_count,
|
|
||||||
"skipped": skipped_count,
|
|
||||||
"errors": errors_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"success": False,
|
|
||||||
"status": "cancelled",
|
|
||||||
"repaired": repaired_count,
|
|
||||||
"skipped": skipped_count,
|
|
||||||
"errors": errors_count,
|
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Report progress
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback(
|
|
||||||
{
|
|
||||||
"status": "processing",
|
|
||||||
"current": i + 1,
|
|
||||||
"total": total,
|
|
||||||
"recipe_name": recipe.get("name", "Unknown"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if await self._repair_single_recipe(recipe, civitai_client):
|
|
||||||
repaired_count += 1
|
|
||||||
else:
|
|
||||||
skipped_count += 1
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error repairing recipe {recipe.get('file_path')}: {e}"
|
|
||||||
)
|
|
||||||
errors_count += 1
|
|
||||||
|
|
||||||
# Final progress update
|
|
||||||
if progress_callback:
|
|
||||||
await progress_callback(
|
|
||||||
{
|
|
||||||
"status": "completed",
|
|
||||||
"repaired": repaired_count,
|
|
||||||
"skipped": skipped_count,
|
|
||||||
"errors": errors_count,
|
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"repaired": repaired_count,
|
|
||||||
"skipped": skipped_count,
|
|
||||||
"errors": errors_count,
|
|
||||||
"total": total,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def repair_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
|
||||||
"""Repair a single recipe by its ID.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recipe_id: ID of the recipe to repair
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict summary of repair result
|
|
||||||
"""
|
|
||||||
async with self._mutation_lock:
|
|
||||||
# Get raw recipe from cache directly to avoid formatted fields
|
|
||||||
cache = await self.get_cached_data()
|
|
||||||
recipe = next(
|
|
||||||
(r for r in cache.raw_data if str(r.get("id", "")) == recipe_id), None
|
|
||||||
)
|
|
||||||
|
|
||||||
if not recipe:
|
|
||||||
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
|
|
||||||
|
|
||||||
civitai_client = await self._get_civitai_client()
|
|
||||||
success = await self._repair_single_recipe(recipe, civitai_client)
|
|
||||||
|
|
||||||
# If successfully repaired, we should return the formatted version for the UI
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"repaired": 1 if success else 0,
|
|
||||||
"skipped": 0 if success else 1,
|
|
||||||
"recipe": await self.get_recipe_by_id(recipe_id) if success else recipe,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _repair_single_recipe(
|
|
||||||
self, recipe: Dict[str, Any], civitai_client: Any
|
|
||||||
) -> bool:
|
|
||||||
"""Internal helper to repair a single recipe object.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
recipe: The recipe dictionary to repair (modified in-place)
|
|
||||||
civitai_client: Authenticated Civitai client
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if recipe was repaired or updated, False if skipped
|
|
||||||
"""
|
|
||||||
# 1. Skip if already at latest repair version
|
|
||||||
if recipe.get("repair_version", 0) >= self.REPAIR_VERSION:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# 1.5 Detect and clear corrupted checkpoint (LoRA data saved as checkpoint).
|
|
||||||
# A checkpoint whose modelVersionId also appears in a LoRA entry is
|
|
||||||
# definitely wrong — the CivitAI import code used to pick
|
|
||||||
# modelVersionIds[0] as the checkpoint, which was often a LoRA.
|
|
||||||
# Clearing it lets the enrichment flow re-resolve the correct
|
|
||||||
# checkpoint from CivitAI image metadata.
|
|
||||||
cp = recipe.get("checkpoint")
|
|
||||||
lora_mvids = {
|
|
||||||
l.get("modelVersionId")
|
|
||||||
for l in recipe.get("loras", [])
|
|
||||||
if l.get("modelVersionId")
|
|
||||||
}
|
|
||||||
if cp and cp.get("modelVersionId") and cp["modelVersionId"] in lora_mvids:
|
|
||||||
cp_mvid = cp["modelVersionId"]
|
|
||||||
logger.info(
|
|
||||||
"Recipe %s: checkpoint modelVersionId %s matches a LoRA — "
|
|
||||||
"clearing corrupted checkpoint and removing matching LoRA entry",
|
|
||||||
recipe.get("id"),
|
|
||||||
cp_mvid,
|
|
||||||
)
|
|
||||||
recipe["checkpoint"] = None
|
|
||||||
recipe["loras"] = [
|
|
||||||
l for l in recipe.get("loras", [])
|
|
||||||
if l.get("modelVersionId") != cp_mvid
|
|
||||||
]
|
|
||||||
|
|
||||||
# 2. Identification: Is repair needed?
|
|
||||||
has_checkpoint = (
|
|
||||||
"checkpoint" in recipe
|
|
||||||
and recipe["checkpoint"]
|
|
||||||
and recipe["checkpoint"].get("name")
|
|
||||||
)
|
|
||||||
gen_params = recipe.get("gen_params", {})
|
|
||||||
has_prompt = bool(gen_params.get("prompt"))
|
|
||||||
|
|
||||||
needs_repair = not has_checkpoint or not has_prompt
|
|
||||||
|
|
||||||
if not needs_repair:
|
|
||||||
# Even if no repair needed, we mark it with version if it was processed
|
|
||||||
# Always update and save because if we are here, the version is old (checked in step 1)
|
|
||||||
recipe["repair_version"] = self.REPAIR_VERSION
|
|
||||||
await self._save_recipe_persistently(recipe)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 3. Use Enricher to repair/enrich
|
|
||||||
try:
|
|
||||||
from ..recipes.enrichment import RecipeEnricher
|
|
||||||
|
|
||||||
updated = await RecipeEnricher.enrich_recipe(recipe, civitai_client)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error enriching recipe {recipe.get('id')}: {e}")
|
|
||||||
updated = False
|
|
||||||
|
|
||||||
# 4. Mark version and save if updated or just marking version
|
|
||||||
# If we updated it, OR if the version is old (which we know it is if we are here), save it.
|
|
||||||
# Actually, if we are here and updated is False, it means we tried to repair but couldn't/didn't need to.
|
|
||||||
# But we still want to mark it as processed so we don't try again until version bump.
|
|
||||||
if updated or recipe.get("repair_version", 0) < self.REPAIR_VERSION:
|
|
||||||
recipe["repair_version"] = self.REPAIR_VERSION
|
|
||||||
await self._save_recipe_persistently(recipe)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
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.
|
||||||
|
|
||||||
@@ -1753,7 +1550,36 @@ class RecipeScanner:
|
|||||||
# Mark initialization as complete regardless of outcome
|
# Mark initialization as complete regardless of outcome
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
|
|
||||||
def _initialize_recipe_cache_sync(self):
|
async def _broadcast_scan_progress(
|
||||||
|
self,
|
||||||
|
status: str,
|
||||||
|
stage: str,
|
||||||
|
progress: int,
|
||||||
|
full_rebuild: bool,
|
||||||
|
**extra: Any,
|
||||||
|
) -> None:
|
||||||
|
"""Broadcast manual-refresh scan progress on the generic WS channel.
|
||||||
|
|
||||||
|
Mirrors ``ModelScanner._broadcast_scan_progress`` so the recipes page
|
||||||
|
can reuse the same frontend contract. Best-effort only: broadcast
|
||||||
|
failures must never affect the scan itself.
|
||||||
|
"""
|
||||||
|
payload: Dict[str, Any] = {
|
||||||
|
'type': 'scan_progress',
|
||||||
|
'status': status,
|
||||||
|
'model_type': 'recipe',
|
||||||
|
'pageType': 'recipes',
|
||||||
|
'stage': stage,
|
||||||
|
'full_rebuild': full_rebuild,
|
||||||
|
'progress': progress,
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast(payload)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.error(f"Error broadcasting scan progress for recipe: {exc}")
|
||||||
|
|
||||||
|
def _initialize_recipe_cache_sync(self, report_progress: bool = False):
|
||||||
"""Synchronous version of recipe cache initialization for thread pool execution.
|
"""Synchronous version of recipe cache initialization for thread pool execution.
|
||||||
|
|
||||||
Uses persistent cache for fast startup when available:
|
Uses persistent cache for fast startup when available:
|
||||||
@@ -1761,8 +1587,14 @@ class RecipeScanner:
|
|||||||
2. Reconcile with filesystem (check mtime/size for changes)
|
2. Reconcile with filesystem (check mtime/size for changes)
|
||||||
3. Fall back to full directory scan if cache miss or reconciliation fails
|
3. Fall back to full directory scan if cache miss or reconciliation fails
|
||||||
4. Persist results for next startup
|
4. Persist results for next startup
|
||||||
|
|
||||||
|
Args:
|
||||||
|
report_progress: When True (manual force-refresh only), broadcast
|
||||||
|
scan_progress messages during the full directory scan. Startup
|
||||||
|
initialization leaves this False and behaves as before.
|
||||||
"""
|
"""
|
||||||
loop = None
|
loop = None
|
||||||
|
scan_start_time: Optional[float] = None
|
||||||
try:
|
try:
|
||||||
# Ensure cache exists to avoid None reference errors
|
# Ensure cache exists to avoid None reference errors
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
@@ -1844,7 +1676,17 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Fall back to full directory scan
|
# Fall back to full directory scan
|
||||||
logger.info("Recipe cache miss: performing full directory scan")
|
logger.info("Recipe cache miss: performing full directory scan")
|
||||||
recipes, json_paths = self._full_directory_scan_sync(recipes_dir)
|
if report_progress:
|
||||||
|
scan_start_time = time.time()
|
||||||
|
# Broadcast from the worker thread via its own event loop,
|
||||||
|
# mirroring ModelScanner._initialize_cache_sync.
|
||||||
|
loop.run_until_complete(
|
||||||
|
self._broadcast_scan_progress('started', 'scan_folders', 0, True)
|
||||||
|
)
|
||||||
|
recipes, json_paths = self._full_directory_scan_sync(
|
||||||
|
recipes_dir,
|
||||||
|
progress_loop=loop if report_progress else None,
|
||||||
|
)
|
||||||
self._json_path_map = json_paths
|
self._json_path_map = json_paths
|
||||||
|
|
||||||
# Update cache with the collected data
|
# Update cache with the collected data
|
||||||
@@ -1858,12 +1700,30 @@ class RecipeScanner:
|
|||||||
recipes, json_paths, self._cache.image_id_map
|
recipes, json_paths, self._cache.image_id_map
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if report_progress:
|
||||||
|
loop.run_until_complete(
|
||||||
|
self._broadcast_scan_progress(
|
||||||
|
'completed', 'finalizing', 100, True,
|
||||||
|
elapsed_seconds=time.time() - (scan_start_time or time.time()),
|
||||||
|
total=len(recipes),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return self._cache
|
return self._cache
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in thread-based recipe cache initialization: {e}")
|
logger.error(f"Error in thread-based recipe cache initialization: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
traceback.print_exc(file=sys.stderr)
|
traceback.print_exc(file=sys.stderr)
|
||||||
|
if report_progress and loop is not None:
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(
|
||||||
|
self._broadcast_scan_progress(
|
||||||
|
'error', 'process_models', 0, True, error=str(e)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - defensive logging
|
||||||
|
logger.error("Error broadcasting recipe scan failure", exc_info=True)
|
||||||
return self._cache if hasattr(self, "_cache") else None
|
return self._cache if hasattr(self, "_cache") else None
|
||||||
finally:
|
finally:
|
||||||
# Clean up the event loop
|
# Clean up the event loop
|
||||||
@@ -2017,12 +1877,16 @@ class RecipeScanner:
|
|||||||
return updated
|
return updated
|
||||||
|
|
||||||
def _full_directory_scan_sync(
|
def _full_directory_scan_sync(
|
||||||
self, recipes_dir: str
|
self,
|
||||||
|
recipes_dir: str,
|
||||||
|
progress_loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||||
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
||||||
"""Perform a full synchronous directory scan for recipes.
|
"""Perform a full synchronous directory scan for recipes.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipes_dir: Path to the recipes directory.
|
recipes_dir: Path to the recipes directory.
|
||||||
|
progress_loop: When set (manual force-refresh only), broadcast
|
||||||
|
scan_progress messages through this thread-local event loop.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (recipes list, json_paths dict).
|
Tuple of (recipes list, json_paths dict).
|
||||||
@@ -2037,6 +1901,17 @@ class RecipeScanner:
|
|||||||
if file.lower().endswith(".recipe.json"):
|
if file.lower().endswith(".recipe.json"):
|
||||||
recipe_files.append(os.path.join(root, file))
|
recipe_files.append(os.path.join(root, file))
|
||||||
|
|
||||||
|
total_files = len(recipe_files)
|
||||||
|
if progress_loop is not None:
|
||||||
|
progress_loop.run_until_complete(
|
||||||
|
self._broadcast_scan_progress(
|
||||||
|
'processing', 'count_models', 1, True,
|
||||||
|
processed=0, total=total_files,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
last_progress_time = time.time()
|
||||||
|
|
||||||
# Process each recipe file
|
# Process each recipe file
|
||||||
for i, recipe_path in enumerate(recipe_files):
|
for i, recipe_path in enumerate(recipe_files):
|
||||||
recipe_data = self._load_recipe_file_sync(recipe_path)
|
recipe_data = self._load_recipe_file_sync(recipe_path)
|
||||||
@@ -2044,6 +1919,23 @@ class RecipeScanner:
|
|||||||
recipe_id = str(recipe_data.get("id", ""))
|
recipe_id = str(recipe_data.get("id", ""))
|
||||||
recipes.append(recipe_data)
|
recipes.append(recipe_data)
|
||||||
json_paths[recipe_id] = recipe_path
|
json_paths[recipe_id] = recipe_path
|
||||||
|
if progress_loop is not None and total_files > 0:
|
||||||
|
processed = i + 1
|
||||||
|
current_time = time.time()
|
||||||
|
# Throttle to one update per 0.5s; always send the final one.
|
||||||
|
if (
|
||||||
|
processed == total_files
|
||||||
|
or current_time - last_progress_time > 0.5
|
||||||
|
):
|
||||||
|
last_progress_time = current_time
|
||||||
|
progress_percent = min(99, int(1 + (processed / total_files) * 98))
|
||||||
|
progress_loop.run_until_complete(
|
||||||
|
self._broadcast_scan_progress(
|
||||||
|
'processing', 'process_models', progress_percent, True,
|
||||||
|
processed=processed, total=total_files,
|
||||||
|
current_name=os.path.basename(recipe_path),
|
||||||
|
)
|
||||||
|
)
|
||||||
# Periodically release GIL so the event loop thread can run
|
# Periodically release GIL so the event loop thread can run
|
||||||
if i % 100 == 0:
|
if i % 100 == 0:
|
||||||
time.sleep(0)
|
time.sleep(0)
|
||||||
@@ -2613,11 +2505,14 @@ class RecipeScanner:
|
|||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
# Run the heavy lifting in a thread pool – same path
|
# Run the heavy lifting in a thread pool – same path
|
||||||
# used by initialize_in_background().
|
# used by initialize_in_background(). Pass
|
||||||
|
# report_progress=True so manual refreshes broadcast
|
||||||
|
# scan_progress updates; startup init keeps it off.
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
cache = await loop.run_in_executor(
|
cache = await loop.run_in_executor(
|
||||||
None,
|
None,
|
||||||
self._initialize_recipe_cache_sync,
|
self._initialize_recipe_cache_sync,
|
||||||
|
True,
|
||||||
)
|
)
|
||||||
if cache is not None:
|
if cache is not None:
|
||||||
self._cache = cache
|
self._cache = cache
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+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",
|
||||||
|
|||||||
+8
-3
@@ -31,9 +31,14 @@ body {
|
|||||||
--header-height: 48px;
|
--header-height: 48px;
|
||||||
--scrollbar-width: 8px;
|
--scrollbar-width: 8px;
|
||||||
|
|
||||||
--shortcut-bg: var(--color-accent-subtle);
|
/* Neutral "keycap" style for keyboard shortcut hints (GitHub/Linear-like).
|
||||||
--shortcut-border: var(--color-accent-border);
|
Derived from --text-muted so it adapts to every theme/preset. */
|
||||||
--shortcut-text: var(--text-primary);
|
--shortcut-bg: color-mix(in oklch, var(--text-muted) 10%, transparent);
|
||||||
|
--shortcut-bg-hover: color-mix(in oklch, var(--text-muted) 16%, transparent);
|
||||||
|
--shortcut-border: color-mix(in oklch, var(--text-muted) 30%, transparent);
|
||||||
|
--shortcut-border-hover: color-mix(in oklch, var(--text-muted) 45%, transparent);
|
||||||
|
--shortcut-text: var(--text-muted);
|
||||||
|
--shortcut-shadow: 0 1.5px 0 color-mix(in oklch, var(--text-muted) 30%, transparent);
|
||||||
|
|
||||||
--lora-accent-transparent: var(--color-accent-transparent);
|
--lora-accent-transparent: var(--color-accent-transparent);
|
||||||
|
|
||||||
|
|||||||
@@ -249,10 +249,10 @@
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 0.68rem;
|
font-size: 0.68rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-muted);
|
color: var(--shortcut-text);
|
||||||
/* Subtle tint derived from text color so it adapts to both light & dark themes */
|
background: var(--shortcut-bg);
|
||||||
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
|
border: 1px solid var(--shortcut-border);
|
||||||
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
|
box-shadow: var(--shortcut-shadow);
|
||||||
border-radius: var(--border-radius-xs, 3px);
|
border-radius: var(--border-radius-xs, 3px);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,4 +65,13 @@
|
|||||||
|
|
||||||
.add-preset-btn:hover {
|
.add-preset-btn:hover {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-preset-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-preset-btn:hover:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
@@ -167,6 +167,29 @@
|
|||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Replay Tutorial button: badge hidden until the button is flagged as new content */
|
||||||
|
.replay-tutorial-btn .new-content-badge {
|
||||||
|
display: none;
|
||||||
|
background-color: rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-tutorial-btn.has-new-content .new-content-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* One-time attention pulse when the button is flagged as new content */
|
||||||
|
@keyframes new-content-glow {
|
||||||
|
0% { box-shadow: 0 0 0 0 oklch(from var(--lora-accent) l c h / 55%); }
|
||||||
|
100% { box-shadow: 0 0 0 16px transparent; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-tutorial-btn.has-new-content {
|
||||||
|
animation: new-content-glow 1.2s ease-out 3;
|
||||||
|
}
|
||||||
|
|
||||||
/* Update video list styles */
|
/* Update video list styles */
|
||||||
.video-list {
|
.video-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -304,4 +327,87 @@
|
|||||||
/* Dark theme adjustments */
|
/* Dark theme adjustments */
|
||||||
[data-theme="dark"] .video-container {
|
[data-theme="dark"] .video-container {
|
||||||
background-color: var(--surface-hover);
|
background-color: var(--surface-hover);
|
||||||
}
|
}
|
||||||
|
/* Replay tutorial button styles */
|
||||||
|
.help-actions {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-tutorial-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
background-color: var(--lora-accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replay-tutorial-btn:hover {
|
||||||
|
background-color: oklch(from var(--lora-accent) l c h / 85%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shortcuts tab styles */
|
||||||
|
.shortcuts-section {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-section h4 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding-left: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list li {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-keys {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-sep {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcuts-list kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--shortcut-text);
|
||||||
|
background: var(--shortcut-bg);
|
||||||
|
border: 1px solid var(--shortcut-border);
|
||||||
|
box-shadow: var(--shortcut-shadow);
|
||||||
|
border-radius: var(--border-radius-xs, 3px);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shortcut-description {
|
||||||
|
font-size: 0.9em;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
+32
-9
@@ -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,36 +218,42 @@
|
|||||||
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: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
padding: 0 3px;
|
padding: 0 4px;
|
||||||
font-size: 11px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 500;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
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);
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group button:hover .shortcut-key {
|
.control-group button:hover .shortcut-key {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
|
background-color: var(--shortcut-bg-hover);
|
||||||
|
border-color: var(--shortcut-border-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .shortcut-key {
|
/* Invert the keycap on active (accent-filled) buttons for contrast.
|
||||||
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
|
Must come after the hover rule above so it wins on active+hover. */
|
||||||
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
|
.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 */
|
||||||
|
|||||||
@@ -205,6 +205,7 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
background: var(--shortcut-bg);
|
background: var(--shortcut-bg);
|
||||||
border: 1px solid var(--shortcut-border);
|
border: 1px solid var(--shortcut-border);
|
||||||
|
box-shadow: var(--shortcut-shadow);
|
||||||
border-radius: var(--border-radius-xs);
|
border-radius: var(--border-radius-xs);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
} from './apiConfig.js';
|
} from './apiConfig.js';
|
||||||
import { resetAndReload } from './modelApiFactory.js';
|
import { resetAndReload } from './modelApiFactory.js';
|
||||||
import { sidebarManager } from '../components/SidebarManager.js';
|
import { sidebarManager } from '../components/SidebarManager.js';
|
||||||
|
// Shared scan ETA helpers live in a dependency-light module so pages that do
|
||||||
|
// not use BaseModelApiClient (e.g. recipes) can reuse them without pulling
|
||||||
|
// this module's import cycle (modelApiFactory -> loraApi -> baseModelApi).
|
||||||
|
import { createScanEtaTracker, formatScanRemainingTime } from '../utils/scanEtaUtils.js';
|
||||||
|
export { createScanEtaTracker, formatScanRemainingTime };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base class for all model API clients
|
* Abstract base class for all model API clients
|
||||||
@@ -507,23 +512,67 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
async refreshModels(fullRebuild = false) {
|
async refreshModels(fullRebuild = false) {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
try {
|
const displayName = this.apiConfig.config.displayName;
|
||||||
state.loadingManager.show(
|
const singularName = this.apiConfig.config.singularName;
|
||||||
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
|
const actionText = translate(
|
||||||
0
|
fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
|
||||||
|
{},
|
||||||
|
fullRebuild ? 'Full rebuild' : 'Refresh'
|
||||||
|
);
|
||||||
|
const actionLowerText = translate(
|
||||||
|
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
|
||||||
|
{},
|
||||||
|
fullRebuild ? 'rebuild' : 'refresh'
|
||||||
|
);
|
||||||
|
const initialMessage = translate(
|
||||||
|
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
|
||||||
|
{ type: displayName },
|
||||||
|
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${displayName}s...`
|
||||||
|
);
|
||||||
|
const etaTracker = createScanEtaTracker();
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
const handleScanProgress = (data) => {
|
||||||
|
if (typeof data.progress === 'number') {
|
||||||
|
state.loadingManager.setProgress(data.progress);
|
||||||
|
}
|
||||||
|
let statusText = translate(
|
||||||
|
`common.scanProgress.stages.${data.stage}`,
|
||||||
|
{ total: data.total },
|
||||||
|
data.stage || ''
|
||||||
);
|
);
|
||||||
|
if (data.status === 'processing' && data.total > 0) {
|
||||||
|
statusText += ` (${data.processed}/${data.total})`;
|
||||||
|
if (data.current_name) {
|
||||||
|
statusText += ` ${data.current_name}`;
|
||||||
|
}
|
||||||
|
const etaText = etaTracker.update(data.processed, data.total);
|
||||||
|
if (etaText) {
|
||||||
|
statusText += ` | ${etaText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.loadingManager.setStatus(statusText);
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager.show(initialMessage, 0);
|
||||||
state.loadingManager.showCancelButton(() => {
|
state.loadingManager.showCancelButton(() => {
|
||||||
this.cancelTask();
|
this.cancelTask();
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Connect to the shared progress channel for live scan updates.
|
||||||
|
// Failure to connect must not block the refresh itself — fall back
|
||||||
|
// to the plain loading indicator.
|
||||||
|
ws = await this._connectScanProgressSocket(handleScanProgress, singularName);
|
||||||
|
|
||||||
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
|
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
|
||||||
url.searchParams.append('full_rebuild', fullRebuild);
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
|
|
||||||
const response = await fetch(url, { signal: abortController.signal });
|
const response = await fetch(url, { signal: abortController.signal });
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
|
throw new Error(`Failed to refresh ${displayName}s: ${response.status} ${response.statusText}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -534,20 +583,69 @@ export class BaseModelApiClient {
|
|||||||
|
|
||||||
resetAndReload(true);
|
resetAndReload(true);
|
||||||
|
|
||||||
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
|
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') {
|
if (error.name === 'AbortError') {
|
||||||
showToast('toast.api.operationCancelled', {}, 'info');
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error('Refresh failed:', error);
|
console.error('Refresh failed:', error);
|
||||||
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
|
showToast('toast.api.refreshFailed', { action: actionLowerText, type: displayName }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the shared fetch-progress WebSocket for scan progress updates.
|
||||||
|
* Returns null when the connection cannot be established (silent fallback).
|
||||||
|
* @param {Function} onScanProgress - Handler for scan_progress messages
|
||||||
|
* @param {string} singularName - Model type filter (e.g. 'lora')
|
||||||
|
* @returns {Promise<WebSocket|null>}
|
||||||
|
*/
|
||||||
|
async _connectScanProgressSocket(onScanProgress, singularName) {
|
||||||
|
let socket = null;
|
||||||
|
try {
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.onopen = resolve;
|
||||||
|
socket.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data);
|
||||||
|
} catch (parseError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only handle scan progress for this client's model type;
|
||||||
|
// other operations share this channel and must be ignored.
|
||||||
|
if (data.type !== 'scan_progress' || data.model_type !== singularName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onScanProgress(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
} catch (error) {
|
||||||
|
if (socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
// Ignore close errors during fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async refreshSingleModelMetadata(filePath) {
|
async refreshSingleModelMetadata(filePath) {
|
||||||
try {
|
try {
|
||||||
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
state.loadingManager.showSimpleLoading('Refreshing metadata...');
|
||||||
@@ -605,6 +703,9 @@ export class BaseModelApiClient {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
// Scan progress shares this channel; it is handled by refreshModels
|
||||||
|
if (data.type === 'scan_progress') return;
|
||||||
|
|
||||||
switch (data.status) {
|
switch (data.status) {
|
||||||
case 'started':
|
case 'started':
|
||||||
loading.setStatus('Starting metadata fetch...');
|
loading.setStatus('Starting metadata fetch...');
|
||||||
|
|||||||
+100
-38
@@ -1,7 +1,12 @@
|
|||||||
import { RecipeCard } from '../components/RecipeCard.js';
|
import { RecipeCard } from '../components/RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
|
||||||
|
import { WS_ENDPOINTS } from './apiConfig.js';
|
||||||
|
// Import from the dependency-light utils module, not baseModelApi.js, to
|
||||||
|
// avoid the baseModelApi <-> modelApiFactory import cycle on this page.
|
||||||
|
import { createScanEtaTracker } from '../utils/scanEtaUtils.js';
|
||||||
|
|
||||||
const RECIPE_ENDPOINTS = {
|
const RECIPE_ENDPOINTS = {
|
||||||
list: '/api/lm/recipes',
|
list: '/api/lm/recipes',
|
||||||
@@ -15,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',
|
||||||
};
|
};
|
||||||
@@ -333,11 +337,53 @@ export async function syncChanges() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshRecipes(fullRebuild = true) {
|
export async function refreshRecipes(fullRebuild = true) {
|
||||||
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
|
const actionText = translate(
|
||||||
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
|
fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
|
||||||
|
{},
|
||||||
|
fullRebuild ? 'Full rebuild' : 'Refresh'
|
||||||
|
);
|
||||||
|
const actionLowerText = translate(
|
||||||
|
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
|
||||||
|
{},
|
||||||
|
fullRebuild ? 'rebuild' : 'refresh'
|
||||||
|
);
|
||||||
|
const initialMessage = translate(
|
||||||
|
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
|
||||||
|
{ type: RECIPE_SIDEBAR_CONFIG.config.displayName },
|
||||||
|
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} Recipes...`
|
||||||
|
);
|
||||||
|
const etaTracker = createScanEtaTracker();
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
const handleScanProgress = (data) => {
|
||||||
|
if (typeof data.progress === 'number') {
|
||||||
|
state.loadingManager.setProgress(data.progress);
|
||||||
|
}
|
||||||
|
let statusText = translate(
|
||||||
|
`common.scanProgress.stages.${data.stage}`,
|
||||||
|
{ total: data.total },
|
||||||
|
data.stage || ''
|
||||||
|
);
|
||||||
|
if (data.status === 'processing' && data.total > 0) {
|
||||||
|
statusText += ` (${data.processed}/${data.total})`;
|
||||||
|
if (data.current_name) {
|
||||||
|
statusText += ` ${data.current_name}`;
|
||||||
|
}
|
||||||
|
const etaText = etaTracker.update(data.processed, data.total);
|
||||||
|
if (etaText) {
|
||||||
|
statusText += ` | ${etaText}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.loadingManager.setStatus(statusText);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
state.loadingManager.show(`${actionLabel}...`, 0);
|
state.loadingManager.show(initialMessage, 0);
|
||||||
|
|
||||||
|
// Connect to the shared progress channel for live scan updates.
|
||||||
|
// Failure to connect must not block the refresh itself — fall back
|
||||||
|
// to the plain loading indicator.
|
||||||
|
ws = await connectScanProgressSocket(handleScanProgress);
|
||||||
|
|
||||||
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
|
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
|
||||||
url.searchParams.append('full_rebuild', fullRebuild);
|
url.searchParams.append('full_rebuild', fullRebuild);
|
||||||
@@ -356,16 +402,64 @@ export async function refreshRecipes(fullRebuild = true) {
|
|||||||
|
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
|
||||||
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
|
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error refreshing recipes:', error);
|
console.error('Error refreshing recipes:', error);
|
||||||
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
|
showToast('toast.api.refreshFailed', { action: actionLowerText, type: 'recipe' }, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
state.loadingManager.restoreProgressBar();
|
state.loadingManager.restoreProgressBar();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the shared fetch-progress WebSocket for recipe scan progress.
|
||||||
|
* Returns null when the connection cannot be established (silent fallback).
|
||||||
|
* @param {Function} onScanProgress - Handler for scan_progress messages
|
||||||
|
* @returns {Promise<WebSocket|null>}
|
||||||
|
*/
|
||||||
|
async function connectScanProgressSocket(onScanProgress) {
|
||||||
|
let socket = null;
|
||||||
|
try {
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
socket.onopen = resolve;
|
||||||
|
socket.onerror = reject;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(event.data);
|
||||||
|
} catch (parseError) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only handle recipe scan progress; other operations share this
|
||||||
|
// channel and must be ignored.
|
||||||
|
if (data.type !== 'scan_progress' || data.model_type !== 'recipe') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onScanProgress(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
} catch (error) {
|
||||||
|
if (socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
// Ignore close errors during fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load more recipes with pagination - updated to work with VirtualScroller
|
* Load more recipes with pagination - updated to work with VirtualScroller
|
||||||
* @param {boolean} resetPage - Whether to reset to the first page
|
* @param {boolean} resetPage - Whether to reset to the first page
|
||||||
@@ -583,38 +677,6 @@ export class RecipeSidebarApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async repairBulkModels(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.repairBulk, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
recipe_ids: recipeIds,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (!response.ok || !result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to repair recipes');
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async rematchBulkModels(filePaths) {
|
async rematchBulkModels(filePaths) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
// Initialize the Checkpoints page
|
// Initialize the Checkpoints page
|
||||||
export class CheckpointsPageManager {
|
export class CheckpointsPageManager {
|
||||||
@@ -32,6 +33,9 @@ export class CheckpointsPageManager {
|
|||||||
// Initialize common page features (including context menus)
|
// Initialize common page features (including context menus)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync(MODEL_TYPES.CHECKPOINT);
|
||||||
|
|
||||||
console.log('Checkpoints Manager initialized');
|
console.log('Checkpoints Manager initialized');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -23,7 +23,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 +40,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 +48,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 +92,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,99 +363,6 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
return `${displayName}s`;
|
return `${displayName}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async repairRecipes(menuItem) {
|
|
||||||
if (this._repairInProgress) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._repairInProgress = true;
|
|
||||||
menuItem?.classList.add('disabled');
|
|
||||||
|
|
||||||
const loadingMessage = translate(
|
|
||||||
'globalContextMenu.repairRecipes.loading',
|
|
||||||
{},
|
|
||||||
'Repairing recipe data...'
|
|
||||||
);
|
|
||||||
|
|
||||||
const progressUI = state.loadingManager?.showEnhancedProgress(loadingMessage);
|
|
||||||
progressUI?.showCancelButton(() => this.cancelRepair());
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/lm/recipes/repair', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (!response.ok || !result.success) {
|
|
||||||
throw new Error(result.error || 'Failed to start repair');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Poll for progress (or wait for WebSocket if preferred, but polling is simpler for this implementation)
|
|
||||||
let isComplete = false;
|
|
||||||
while (!isComplete && this._repairInProgress) {
|
|
||||||
const progressResponse = await fetch('/api/lm/recipes/repair-progress');
|
|
||||||
if (progressResponse.ok) {
|
|
||||||
const progressResult = await progressResponse.json();
|
|
||||||
if (progressResult.success && progressResult.progress) {
|
|
||||||
const p = progressResult.progress;
|
|
||||||
if (p.status === 'processing') {
|
|
||||||
const percent = (p.current / p.total) * 100;
|
|
||||||
progressUI?.updateProgress(percent, p.recipe_name, `${loadingMessage} (${p.current}/${p.total})`);
|
|
||||||
} else if (p.status === 'completed') {
|
|
||||||
isComplete = true;
|
|
||||||
progressUI?.complete(translate(
|
|
||||||
'globalContextMenu.repairRecipes.success',
|
|
||||||
{ count: p.repaired },
|
|
||||||
`Repaired ${p.repaired} recipes.`
|
|
||||||
));
|
|
||||||
showToast('globalContextMenu.repairRecipes.success', { count: p.repaired }, 'success');
|
|
||||||
// Refresh recipes page if active
|
|
||||||
if (window.recipesPage) {
|
|
||||||
window.recipesPage.refresh();
|
|
||||||
}
|
|
||||||
} else if (p.status === 'error') {
|
|
||||||
throw new Error(p.error || 'Repair failed');
|
|
||||||
} else if (p.status === 'cancelled') {
|
|
||||||
isComplete = true;
|
|
||||||
progressUI?.complete(translate(
|
|
||||||
'globalContextMenu.repairRecipes.cancelled',
|
|
||||||
{ count: p.repaired },
|
|
||||||
`Repair cancelled. ${p.repaired} recipes were repaired.`
|
|
||||||
));
|
|
||||||
showToast('globalContextMenu.repairRecipes.cancelled', { count: p.repaired }, 'info');
|
|
||||||
}
|
|
||||||
} else if (progressResponse.status === 404) {
|
|
||||||
// Progress might have finished quickly and been cleaned up
|
|
||||||
isComplete = true;
|
|
||||||
progressUI?.complete();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isComplete) {
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Recipe repair failed:', error);
|
|
||||||
progressUI?.complete(translate('globalContextMenu.repairRecipes.error', { message: error.message }, 'Repair failed: {message}'));
|
|
||||||
showToast('globalContextMenu.repairRecipes.error', { message: error.message }, 'error');
|
|
||||||
} finally {
|
|
||||||
this._repairInProgress = false;
|
|
||||||
menuItem?.classList.remove('disabled');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async cancelRepair() {
|
|
||||||
try {
|
|
||||||
await fetch('/api/lm/recipes/cancel-repair', {
|
|
||||||
method: 'POST',
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to cancel recipe repair:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rematchRecipes(menuItem) {
|
async rematchRecipes(menuItem) {
|
||||||
if (this._rematchInProgress) {
|
if (this._rematchInProgress) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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 { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||||
|
|
||||||
export class RecipeContextMenu extends BaseContextMenu {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -93,10 +94,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 +294,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');
|
||||||
@@ -397,6 +356,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 +396,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
|
||||||
|
|||||||
@@ -2877,12 +2877,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) {
|
||||||
@@ -2991,6 +2993,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 +3006,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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
|
||||||
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
|
||||||
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
|
||||||
|
import { eventManager } from '../../utils/EventManager.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { sidebarManager } from '../SidebarManager.js';
|
import { sidebarManager } from '../SidebarManager.js';
|
||||||
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
|
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
|
||||||
@@ -146,6 +147,62 @@ export class PageControls {
|
|||||||
|
|
||||||
// Page-specific event listeners
|
// Page-specific event listeners
|
||||||
this.initPageSpecificListeners();
|
this.initPageSpecificListeners();
|
||||||
|
|
||||||
|
// Keyboard shortcuts for the actions toolbar (R / F / D)
|
||||||
|
this.registerKeyboardShortcuts();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register keyboard shortcuts for the actions toolbar buttons
|
||||||
|
* (R = refresh, F = fetch metadata, D = download)
|
||||||
|
*/
|
||||||
|
registerKeyboardShortcuts() {
|
||||||
|
eventManager.addHandler('keydown', 'pageControls-actions', (e) => {
|
||||||
|
return this.handleActionShortcut(e);
|
||||||
|
}, {
|
||||||
|
priority: 90,
|
||||||
|
skipWhenModalOpen: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a keydown event for the actions toolbar shortcuts
|
||||||
|
* @param {KeyboardEvent} e
|
||||||
|
* @returns {boolean} True when the event was handled and propagation should stop
|
||||||
|
*/
|
||||||
|
handleActionShortcut(e) {
|
||||||
|
// Plain letters only — leave modified combos (Ctrl/Cmd/Alt) alone
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't hijack keys while typing in a text entry context
|
||||||
|
if (isTypingContext(e.target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionByKey = {
|
||||||
|
r: 'refresh',
|
||||||
|
f: 'fetch',
|
||||||
|
d: 'download'
|
||||||
|
};
|
||||||
|
const action = actionByKey[e.key.toLowerCase()];
|
||||||
|
if (!action) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The button may not exist on this page (e.g. recipes has no
|
||||||
|
// fetch/download) — let other handlers run in that case
|
||||||
|
const button = document.querySelector(`[data-action="${action}"]`);
|
||||||
|
if (!button) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
// Native disabled buttons ignore .click(), so an in-progress
|
||||||
|
// refresh is safe
|
||||||
|
button.click();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
initExcludedViewControls() {
|
initExcludedViewControls() {
|
||||||
|
|||||||
@@ -607,9 +607,11 @@ export function createModelCard(model, modelType) {
|
|||||||
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
|
||||||
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
|
||||||
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
|
||||||
|
// Checkpoint send sets the widget value directly; no append/replace modes.
|
||||||
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
|
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
|
||||||
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
|
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
|
||||||
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
} else if (modelType === MODEL_TYPES.EMBEDDING) {
|
||||||
|
// Embedding send always appends to the prompt; no replace mode.
|
||||||
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
|
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
|
||||||
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
|
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -877,8 +877,9 @@ function renderLoraSpecificContent(lora, escapedWords) {
|
|||||||
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
|
||||||
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
|
<!-- autofill opt-out attrs prevent password managers / email-alias extensions from attaching popups -->
|
||||||
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other">
|
||||||
|
<button class="add-preset-btn" disabled>${translate('modals.model.usageTips.add', {}, 'Add')}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="preset-tags">
|
<div class="preset-tags">
|
||||||
${renderPresetTags(parsePresets(lora.usage_tips))}
|
${renderPresetTags(parsePresets(lora.usage_tips))}
|
||||||
@@ -1086,6 +1087,11 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
|
|
||||||
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
|
||||||
|
|
||||||
|
// Add button stays disabled until both a parameter and a value are provided
|
||||||
|
const updateAddPresetButtonState = () => {
|
||||||
|
addPresetBtn.disabled = !(presetSelector.value && presetValue.value.trim());
|
||||||
|
};
|
||||||
|
|
||||||
presetSelector.addEventListener('change', function () {
|
presetSelector.addEventListener('change', function () {
|
||||||
const selected = this.value;
|
const selected = this.value;
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -1111,12 +1117,16 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
} else {
|
} else {
|
||||||
presetValue.style.display = 'none';
|
presetValue.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
updateAddPresetButtonState();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
presetValue.addEventListener('input', updateAddPresetButtonState);
|
||||||
|
|
||||||
addPresetBtn.addEventListener('click', async function () {
|
addPresetBtn.addEventListener('click', async function () {
|
||||||
const key = presetSelector.value;
|
const key = presetSelector.value;
|
||||||
const value = presetValue.value;
|
const value = presetValue.value.trim();
|
||||||
|
|
||||||
|
// Unreachable via UI while the button is disabled; kept as a safety net
|
||||||
if (!key || !value) return;
|
if (!key || !value) return;
|
||||||
|
|
||||||
const currentPath = resolveFilePath();
|
const currentPath = resolveFilePath();
|
||||||
@@ -1131,9 +1141,11 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
|
||||||
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
|
||||||
|
|
||||||
|
let isUpdate;
|
||||||
if (key === 'strength_range') {
|
if (key === 'strength_range') {
|
||||||
const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/);
|
const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/);
|
||||||
if (rangeMatch) {
|
if (rangeMatch) {
|
||||||
|
isUpdate = 'strength_min' in currentPresets || 'strength_max' in currentPresets;
|
||||||
currentPresets['strength_min'] = parseFloat(rangeMatch[1]);
|
currentPresets['strength_min'] = parseFloat(rangeMatch[1]);
|
||||||
currentPresets['strength_max'] = parseFloat(rangeMatch[2]);
|
currentPresets['strength_max'] = parseFloat(rangeMatch[2]);
|
||||||
} else {
|
} else {
|
||||||
@@ -1141,17 +1153,36 @@ function setupLoraSpecificFields(filePath) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
currentPresets[key] = parseFloat(value);
|
const numericValue = parseFloat(value);
|
||||||
|
if (!Number.isFinite(numericValue)) {
|
||||||
|
showToast('modals.model.usageTips.invalidValue', {}, 'error', 'Please enter a valid number');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isUpdate = key in currentPresets;
|
||||||
|
currentPresets[key] = numericValue;
|
||||||
}
|
}
|
||||||
const newPresetsJson = JSON.stringify(currentPresets);
|
const newPresetsJson = JSON.stringify(currentPresets);
|
||||||
|
|
||||||
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
try {
|
||||||
|
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save preset parameter:', error);
|
||||||
|
showToast('modals.model.usageTips.saveFailed', {}, 'error', 'Failed to save preset parameter');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
presetTags.innerHTML = renderPresetTags(currentPresets);
|
presetTags.innerHTML = renderPresetTags(currentPresets);
|
||||||
|
showToast(
|
||||||
|
isUpdate ? 'modals.model.usageTips.updated' : 'modals.model.usageTips.added',
|
||||||
|
{},
|
||||||
|
'success',
|
||||||
|
isUpdate ? 'Preset parameter updated' : 'Preset parameter added'
|
||||||
|
);
|
||||||
|
|
||||||
presetSelector.value = '';
|
presetSelector.value = '';
|
||||||
presetValue.value = '';
|
presetValue.value = '';
|
||||||
presetValue.style.display = 'none';
|
presetValue.style.display = 'none';
|
||||||
|
addPresetBtn.disabled = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add keydown event for preset value
|
// Add keydown event for preset value
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
|
|||||||
const escapedWord = escapeHtml(word);
|
const escapedWord = escapeHtml(word);
|
||||||
const escapedAttr = escapeAttribute(word);
|
const escapedAttr = escapeAttribute(word);
|
||||||
return `
|
return `
|
||||||
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
|
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
|
||||||
<span class="trigger-word-content">${escapedWord}</span>
|
<span class="trigger-word-content">${escapedWord}</span>
|
||||||
<span class="trigger-word-copy">
|
<span class="trigger-word-copy">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
@@ -455,7 +455,7 @@ function resetTriggerWordsUIState(section) {
|
|||||||
// Restore click-to-copy functionality
|
// Restore click-to-copy functionality
|
||||||
tag.removeEventListener('click', startEditTriggerWord);
|
tag.removeEventListener('click', startEditTriggerWord);
|
||||||
setupDisplayTriggerWordTag(tag);
|
setupDisplayTriggerWordTag(tag);
|
||||||
tag.title = translate('modals.model.triggerWords.copyWord');
|
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
|
||||||
|
|
||||||
// Show copy icon, hide delete button
|
// Show copy icon, hide delete button
|
||||||
if (copyIcon) copyIcon.style.display = '';
|
if (copyIcon) copyIcon.style.display = '';
|
||||||
@@ -503,7 +503,7 @@ function createTriggerWordTag(word, isEditMode = false) {
|
|||||||
const tag = document.createElement('div');
|
const tag = document.createElement('div');
|
||||||
tag.className = 'trigger-word-tag';
|
tag.className = 'trigger-word-tag';
|
||||||
tag.dataset.word = word;
|
tag.dataset.word = word;
|
||||||
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
|
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyOrEditWord');
|
||||||
|
|
||||||
const escapedWord = escapeHtml(word);
|
const escapedWord = escapeHtml(word);
|
||||||
tag.innerHTML = `
|
tag.innerHTML = `
|
||||||
@@ -537,7 +537,7 @@ function setupDisplayTriggerWordTag(tag) {
|
|||||||
|
|
||||||
tag.addEventListener('click', handleDisplayTriggerWordClick);
|
tag.addEventListener('click', handleDisplayTriggerWordClick);
|
||||||
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
|
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
|
||||||
tag.title = translate('modals.model.triggerWords.copyWord');
|
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
import { MODEL_TYPES } from './api/apiConfig.js';
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
// Initialize the Embeddings page
|
// Initialize the Embeddings page
|
||||||
class EmbeddingsPageManager {
|
class EmbeddingsPageManager {
|
||||||
@@ -32,6 +33,9 @@ class EmbeddingsPageManager {
|
|||||||
// Initialize common page features (including context menus)
|
// Initialize common page features (including context menus)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync(MODEL_TYPES.EMBEDDING);
|
||||||
|
|
||||||
console.log('Embeddings Manager initialized');
|
console.log('Embeddings Manager initialized');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
|
|||||||
import { createPageControls } from './components/controls/index.js';
|
import { createPageControls } from './components/controls/index.js';
|
||||||
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
// Initialize the LoRA page
|
// Initialize the LoRA page
|
||||||
export class LoraPageManager {
|
export class LoraPageManager {
|
||||||
@@ -41,6 +42,9 @@ export class LoraPageManager {
|
|||||||
|
|
||||||
// Initialize common page features (including context menus and virtual scroll)
|
// Initialize common page features (including context menus and virtual scroll)
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync('loras');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,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 +104,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 +858,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 +967,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');
|
||||||
|
|||||||
@@ -1,23 +1,16 @@
|
|||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
|
import { onboardingManager } from './OnboardingManager.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages help modal functionality and tutorial update notifications
|
* Manages help modal functionality and tutorial update notifications
|
||||||
*/
|
*/
|
||||||
export class HelpManager {
|
export class HelpManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
|
// Version of the help content the user has seen. Compared against the
|
||||||
this.latestContentTimestamp = new Date('2025-10-11').getTime(); // Will be updated from server or config
|
// data-help-content-version marker rendered into the help modal markup,
|
||||||
|
// so badge state is always derived from the content actually served.
|
||||||
|
this.viewedContentVersion = getStorageItem('help_viewed_content_version', null);
|
||||||
this.isInitialized = false;
|
this.isInitialized = false;
|
||||||
|
|
||||||
// Default latest content data - could be fetched from server
|
|
||||||
this.latestVideoData = {
|
|
||||||
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
|
|
||||||
walkthrough: {
|
|
||||||
id: 'hvKw31YpE-U',
|
|
||||||
title: 'Getting Started with LoRA Manager'
|
|
||||||
},
|
|
||||||
playlistUpdated: true
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,9 +27,6 @@ export class HelpManager {
|
|||||||
// Check if we need to show the badge
|
// Check if we need to show the badge
|
||||||
this.updateHelpBadge();
|
this.updateHelpBadge();
|
||||||
|
|
||||||
// Fetch latest video data (could be implemented to fetch from remote source)
|
|
||||||
this.fetchLatestVideoData();
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
@@ -55,77 +45,147 @@ export class HelpManager {
|
|||||||
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
|
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
|
||||||
tabButtons.forEach(button => {
|
tabButtons.forEach(button => {
|
||||||
button.addEventListener('click', (event) => {
|
button.addEventListener('click', (event) => {
|
||||||
// Remove active class from all buttons and panes
|
this.activateHelpTab(event.currentTarget.getAttribute('data-tab'));
|
||||||
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
|
|
||||||
btn.classList.remove('active');
|
|
||||||
});
|
|
||||||
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
|
|
||||||
pane.classList.remove('active');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add active class to clicked button
|
|
||||||
event.currentTarget.classList.add('active');
|
|
||||||
|
|
||||||
// Show corresponding tab content
|
|
||||||
const tabId = event.currentTarget.getAttribute('data-tab');
|
|
||||||
document.getElementById(tabId).classList.add('active');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Replay tutorial button in the Getting Started tab
|
||||||
|
const replayTutorialBtn = document.getElementById('replayTutorialBtn');
|
||||||
|
if (replayTutorialBtn) {
|
||||||
|
replayTutorialBtn.addEventListener('click', () => {
|
||||||
|
// Close the help modal, then restart the onboarding tutorial
|
||||||
|
if (window.modalManager) {
|
||||||
|
window.modalManager.closeModal('helpModal');
|
||||||
|
}
|
||||||
|
onboardingManager.reset();
|
||||||
|
onboardingManager.startTutorial();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global "?" shortcut opens the help modal on the Shortcuts tab
|
||||||
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if (event.key !== '?') return;
|
||||||
|
if (this.isTypingContext(event.target)) return;
|
||||||
|
if (window.modalManager?.isAnyModalOpen()) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
this.openHelpModal('shortcuts');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the event target is a text entry context where "?" is literal input
|
||||||
|
*/
|
||||||
|
isTypingContext(target) {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
|
||||||
|
const tagName = target.tagName?.toLowerCase();
|
||||||
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Activate a specific help modal tab by its data-tab id
|
||||||
|
* @param {string} tabId - The tab id (matches data-tab and pane element id)
|
||||||
|
*/
|
||||||
|
activateHelpTab(tabId) {
|
||||||
|
const tabButton = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
|
||||||
|
const tabPane = document.getElementById(tabId);
|
||||||
|
if (!tabButton || !tabPane) return;
|
||||||
|
|
||||||
|
// Remove active class from all buttons and panes
|
||||||
|
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
|
||||||
|
pane.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate the requested tab
|
||||||
|
tabButton.classList.add('active');
|
||||||
|
tabPane.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the help modal
|
* Open the help modal
|
||||||
|
* @param {string} [tabId] - Optional tab id to activate after opening
|
||||||
*/
|
*/
|
||||||
openHelpModal() {
|
openHelpModal(tabId) {
|
||||||
// Use modalManager to open the help modal
|
// Use modalManager to open the help modal
|
||||||
if (window.modalManager) {
|
if (!window.modalManager) return;
|
||||||
window.modalManager.toggleModal('helpModal');
|
|
||||||
|
const hadNewContent = this.hasNewContent();
|
||||||
// Add visual indicator to Documentation tab if there's new content
|
|
||||||
this.updateDocumentationTabIndicator();
|
window.modalManager.toggleModal('helpModal');
|
||||||
|
|
||||||
// Update the last viewed timestamp
|
if (tabId) {
|
||||||
this.markContentAsViewed();
|
this.activateHelpTab(tabId);
|
||||||
|
|
||||||
// Hide the badge
|
|
||||||
this.hideHelpBadge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only acknowledge the content as viewed when the user opened the
|
||||||
|
// modal while it actually contained new content. Opening a stale
|
||||||
|
// (pre-upgrade) page must not suppress the badge after a refresh.
|
||||||
|
if (hadNewContent) {
|
||||||
|
this.updateNewContentTabIndicators();
|
||||||
|
this.markContentAsViewed();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the badge
|
||||||
|
this.hideHelpBadge();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add visual indicator to Documentation tab for new content
|
* Add visual indicator to tabs that received new content
|
||||||
*/
|
*/
|
||||||
updateDocumentationTabIndicator() {
|
updateNewContentTabIndicators() {
|
||||||
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]');
|
if (!this.hasNewContent()) return;
|
||||||
if (docTab && this.hasNewContent()) {
|
|
||||||
docTab.classList.add('has-new-content');
|
// Tabs updated in the 2026-09-03 discoverability release:
|
||||||
|
// getting-started (Replay Tutorial button) and shortcuts (new cheat-sheet tab)
|
||||||
|
const NEW_CONTENT_TABS = ['getting-started', 'shortcuts'];
|
||||||
|
NEW_CONTENT_TABS.forEach(tabId => {
|
||||||
|
const tab = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
|
||||||
|
if (tab) {
|
||||||
|
tab.classList.add('has-new-content');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Point the indicator at the specific new element inside the
|
||||||
|
// Getting Started tab, and scroll it into view so it is not lost
|
||||||
|
// below the fold of the modal body.
|
||||||
|
const replayBtn = document.getElementById('replayTutorialBtn');
|
||||||
|
if (replayBtn) {
|
||||||
|
replayBtn.classList.add('has-new-content');
|
||||||
|
const gettingStartedActive = document.querySelector('#getting-started.tab-pane.active');
|
||||||
|
if (gettingStartedActive && typeof replayBtn.scrollIntoView === 'function') {
|
||||||
|
replayBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark content as viewed by saving current timestamp
|
* Mark content as viewed by persisting the version rendered in the DOM.
|
||||||
|
* No-op when the served markup carries no version marker (stale assets),
|
||||||
|
* so viewing old content never suppresses the badge for new content.
|
||||||
*/
|
*/
|
||||||
markContentAsViewed() {
|
markContentAsViewed() {
|
||||||
this.lastViewedTimestamp = Date.now();
|
const currentVersion = this.getCurrentContentVersion();
|
||||||
setStorageItem('help_last_viewed', this.lastViewedTimestamp);
|
if (!currentVersion) return;
|
||||||
|
|
||||||
|
this.viewedContentVersion = currentVersion;
|
||||||
|
setStorageItem('help_viewed_content_version', this.viewedContentVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch latest video data (could be implemented to actually fetch from a remote source)
|
* Read the help content version from the rendered modal markup
|
||||||
|
* @returns {string|null} Version marker, or null if the served markup has none
|
||||||
*/
|
*/
|
||||||
fetchLatestVideoData() {
|
getCurrentContentVersion() {
|
||||||
// In a real implementation, you'd fetch this from your server
|
const marker = document.querySelector('[data-help-content-version]');
|
||||||
// For now, we'll just use the hardcoded data from constructor
|
return marker ? marker.getAttribute('data-help-content-version') : null;
|
||||||
|
|
||||||
// Update the timestamp with the latest data
|
|
||||||
this.latestContentTimestamp = Math.max(this.latestContentTimestamp, this.latestVideoData.timestamp);
|
|
||||||
|
|
||||||
// Check again if we need to show the badge with this new data
|
|
||||||
this.updateHelpBadge();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update help badge visibility based on timestamps
|
* Update help badge visibility based on viewed vs. served content version
|
||||||
*/
|
*/
|
||||||
updateHelpBadge() {
|
updateHelpBadge() {
|
||||||
if (this.hasNewContent()) {
|
if (this.hasNewContent()) {
|
||||||
@@ -134,13 +194,13 @@ export class HelpManager {
|
|||||||
this.hideHelpBadge();
|
this.hideHelpBadge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if there's new content the user hasn't seen
|
* Check if the served help content is newer than what the user has viewed
|
||||||
*/
|
*/
|
||||||
hasNewContent() {
|
hasNewContent() {
|
||||||
// If user has never viewed the help, or the content is newer than last viewed
|
const currentVersion = this.getCurrentContentVersion();
|
||||||
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp;
|
return Boolean(currentVersion) && currentVersion !== this.viewedContentVersion;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export class OnboardingManager {
|
|||||||
{
|
{
|
||||||
target: '.controls .action-buttons [data-action="bulk"]',
|
target: '.controls .action-buttons [data-action="bulk"]',
|
||||||
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
|
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
|
||||||
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'),
|
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span> to select multiple models and perform batch operations.<br>• <span class="onboarding-shortcut">Ctrl/Cmd+A</span> select all visible models, <span class="onboarding-shortcut">Shift+Click</span> select a range.<br>• <span class="onboarding-shortcut">Esc</span> or clicking an empty area exits bulk mode.'),
|
||||||
position: 'bottom'
|
position: 'bottom'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -71,10 +71,30 @@ export class OnboardingManager {
|
|||||||
position: 'top',
|
position: 'top',
|
||||||
customPosition: { top: '20%', left: '50%' }
|
customPosition: { top: '20%', left: '50%' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
target: '.card-grid',
|
||||||
|
title: () => translate('onboarding.steps.marqueeSelect.title', {}, 'Drag to Select'),
|
||||||
|
content: () => translate('onboarding.steps.marqueeSelect.content', {}, 'Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once.'),
|
||||||
|
position: 'top',
|
||||||
|
customPosition: { top: '20%', left: '50%' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '#folderSidebar',
|
||||||
|
title: () => translate('onboarding.steps.dragToSidebar.title', {}, 'Organize by Dragging'),
|
||||||
|
content: () => translate('onboarding.steps.dragToSidebar.content', {}, 'Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode.'),
|
||||||
|
position: 'right'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
target: '.card-grid',
|
target: '.card-grid',
|
||||||
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
|
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
|
||||||
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'),
|
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata.'),
|
||||||
|
position: 'top',
|
||||||
|
customPosition: { top: '20%', left: '50%' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
target: '.card-grid',
|
||||||
|
title: () => translate('onboarding.steps.contextMenus.title', {}, 'More Context Menus'),
|
||||||
|
content: () => translate('onboarding.steps.contextMenus.content', {}, 'In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models.'),
|
||||||
position: 'top',
|
position: 'top',
|
||||||
customPosition: { top: '20%', left: '50%' }
|
customPosition: { top: '20%', left: '50%' }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,61 @@
|
|||||||
|
/**
|
||||||
|
* Mirrors the manager page's active filter state to the backend's in-memory
|
||||||
|
* store, so the ComfyUI-side autocomplete can apply it even when the manager
|
||||||
|
* page and ComfyUI run in different browsers/origins (localStorage is not
|
||||||
|
* shared there).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getStorageItem, setActiveFiltersListener } from './storageHelpers.js';
|
||||||
|
import { debounce } from './debounce.js';
|
||||||
|
|
||||||
|
const SYNC_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
const debouncedPushByPage = {};
|
||||||
|
|
||||||
|
function buildActiveFiltersPayload(pageType) {
|
||||||
|
const activeFolder = getStorageItem(`${pageType}_activeFolder`);
|
||||||
|
const recursiveSearch = getStorageItem(`${pageType}_recursiveSearch`, true);
|
||||||
|
const filters = getStorageItem(`${pageType}_filters`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// null stays null; legacy "null" string is normalized to null
|
||||||
|
activeFolder: activeFolder && activeFolder !== 'null' ? activeFolder : null,
|
||||||
|
recursiveSearch: recursiveSearch !== false,
|
||||||
|
filters: filters && typeof filters === 'object' ? filters : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pushActiveFilters(pageType) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/lm/${pageType}/active-filters`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(buildActiveFiltersPayload(pageType)),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}: HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncActiveFilters(pageType) {
|
||||||
|
if (!debouncedPushByPage[pageType]) {
|
||||||
|
debouncedPushByPage[pageType] = debounce(() => {
|
||||||
|
pushActiveFilters(pageType);
|
||||||
|
}, SYNC_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
debouncedPushByPage[pageType]();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the storage listener and push the current (restored) state once.
|
||||||
|
* The initial push covers server restarts, where the backend store is empty
|
||||||
|
* until the manager page re-publishes its localStorage-restored filters.
|
||||||
|
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings'
|
||||||
|
*/
|
||||||
|
export function initActiveFiltersSync(pageType) {
|
||||||
|
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
|
||||||
|
pushActiveFilters(pageType);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { translate } from './i18nHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a remaining-time estimate for scan progress display.
|
||||||
|
* @param {number} remainingMs - Estimated remaining time in milliseconds
|
||||||
|
* @returns {string} Localized ETA text
|
||||||
|
*/
|
||||||
|
export function formatScanRemainingTime(remainingMs) {
|
||||||
|
if (remainingMs < 60000) {
|
||||||
|
return translate('common.scanProgress.eta.lessThanMinute', {}, 'Less than a minute remaining');
|
||||||
|
}
|
||||||
|
if (remainingMs < 3600000) {
|
||||||
|
const minutes = Math.round(remainingMs / 60000);
|
||||||
|
return translate('common.scanProgress.eta.minutes', { minutes }, `~${minutes} min remaining`);
|
||||||
|
}
|
||||||
|
const hours = Math.floor(remainingMs / 3600000);
|
||||||
|
const minutes = Math.round((remainingMs % 3600000) / 60000);
|
||||||
|
return translate('common.scanProgress.eta.hours', { hours, minutes }, `~${hours} hr ${minutes} min remaining`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an ETA tracker for scan progress. Uses an exponential moving
|
||||||
|
* average (0.7/0.3) over the observed per-file processing time, mirroring
|
||||||
|
* the estimator in components/initialization.js.
|
||||||
|
* @returns {{ update: (processed: number, total: number) => (string|null) }}
|
||||||
|
*/
|
||||||
|
export function createScanEtaTracker() {
|
||||||
|
let startTime = null;
|
||||||
|
let lastProcessed = 0;
|
||||||
|
let averageMsPerFile = null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
/**
|
||||||
|
* Update with the latest counters.
|
||||||
|
* @returns {string|null} Localized ETA text, or null when not applicable
|
||||||
|
*/
|
||||||
|
update(processed, total) {
|
||||||
|
if (!total || total <= 0 || processed >= total) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
if (startTime === null) {
|
||||||
|
// First sample only anchors the timer; not enough data yet
|
||||||
|
startTime = now;
|
||||||
|
lastProcessed = processed;
|
||||||
|
return translate('initialization.estimatingTime', {}, 'Estimating time...');
|
||||||
|
}
|
||||||
|
if (processed > lastProcessed) {
|
||||||
|
const msPerFile = (now - startTime) / processed;
|
||||||
|
averageMsPerFile = averageMsPerFile === null
|
||||||
|
? msPerFile
|
||||||
|
: averageMsPerFile * 0.7 + msPerFile * 0.3;
|
||||||
|
lastProcessed = processed;
|
||||||
|
}
|
||||||
|
if (averageMsPerFile === null) {
|
||||||
|
return translate('initialization.estimatingTime', {}, 'Estimating time...');
|
||||||
|
}
|
||||||
|
return formatScanRemainingTime((total - lastProcessed) * averageMsPerFile);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,31 @@
|
|||||||
// Namespace prefix for all localStorage keys
|
// Namespace prefix for all localStorage keys
|
||||||
const STORAGE_PREFIX = 'lora_manager_';
|
const STORAGE_PREFIX = 'lora_manager_';
|
||||||
|
|
||||||
|
// Matches keys that carry the manager page's active filter state
|
||||||
|
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
|
||||||
|
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/;
|
||||||
|
|
||||||
|
let activeFiltersListener = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a listener invoked with the page type whenever one of the
|
||||||
|
* active-filter storage keys changes. Used to mirror filter state to the
|
||||||
|
* backend so the ComfyUI-side autocomplete can pick it up across
|
||||||
|
* browsers/origins where localStorage is not shared.
|
||||||
|
* @param {function(string): void} listener
|
||||||
|
*/
|
||||||
|
export function setActiveFiltersListener(listener) {
|
||||||
|
activeFiltersListener = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyActiveFiltersChanged(key) {
|
||||||
|
if (!activeFiltersListener) return;
|
||||||
|
const match = ACTIVE_FILTER_KEY_PATTERN.exec(key);
|
||||||
|
if (match) {
|
||||||
|
activeFiltersListener(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an item from localStorage with namespace support and fallback to legacy keys
|
* Get an item from localStorage with namespace support and fallback to legacy keys
|
||||||
* @param {string} key - The key without prefix
|
* @param {string} key - The key without prefix
|
||||||
@@ -51,13 +76,15 @@ export function getStorageItem(key, defaultValue = null) {
|
|||||||
*/
|
*/
|
||||||
export function setStorageItem(key, value) {
|
export function setStorageItem(key, value) {
|
||||||
const prefixedKey = STORAGE_PREFIX + key;
|
const prefixedKey = STORAGE_PREFIX + key;
|
||||||
|
|
||||||
// Convert objects and arrays to JSON strings
|
// Convert objects and arrays to JSON strings
|
||||||
if (typeof value === 'object' && value !== null) {
|
if (typeof value === 'object' && value !== null) {
|
||||||
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
localStorage.setItem(prefixedKey, JSON.stringify(value));
|
||||||
} else {
|
} else {
|
||||||
localStorage.setItem(prefixedKey, value);
|
localStorage.setItem(prefixedKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyActiveFiltersChanged(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -67,6 +94,8 @@ export function setStorageItem(key, value) {
|
|||||||
export function removeStorageItem(key) {
|
export function removeStorageItem(key) {
|
||||||
localStorage.removeItem(STORAGE_PREFIX + key);
|
localStorage.removeItem(STORAGE_PREFIX + key);
|
||||||
localStorage.removeItem(key); // Also remove legacy key
|
localStorage.removeItem(key); // Also remove legacy key
|
||||||
|
|
||||||
|
notifyActiveFiltersChanged(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -311,6 +311,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
|
|||||||
toast.append(closeBtn);
|
toast.append(closeBtn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the event target is a text-entry context (input, textarea,
|
||||||
|
* select, or contenteditable) where single-letter shortcuts should be treated
|
||||||
|
* as literal input.
|
||||||
|
* @param {EventTarget|null} target - The DOM event target
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isTypingContext(target) {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
|
||||||
|
const tagName = target.tagName?.toLowerCase();
|
||||||
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
|
}
|
||||||
|
|
||||||
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}"]`);
|
||||||
|
|||||||
@@ -54,8 +54,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>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
||||||
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
|
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span><span>{{ t('common.actions.refresh') }}</span> <kbd class="shortcut-key">R</kbd></span></button>
|
||||||
<button class="dropdown-toggle" aria-label="Show refresh options">
|
<button class="dropdown-toggle" aria-label="Show refresh options">
|
||||||
<i class="fas fa-caret-down"></i>
|
<i class="fas fa-caret-down"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -78,11 +78,11 @@
|
|||||||
|
|
||||||
{% if page_id != 'recipes' %}
|
{% if page_id != 'recipes' %}
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
|
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span><span>{{ t('loras.controls.fetch.action') }}</span> <kbd class="shortcut-key">F</kbd></span></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button data-action="download" title="{{ t('loras.controls.download.title') }}">
|
<button data-action="download" title="{{ t('loras.controls.download.title') }}">
|
||||||
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
|
<i class="fas fa-cloud-download-alt"></i> <span><span>{{ t('loras.controls.download.action') }}</span> <kbd class="shortcut-key">D</kbd></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
|
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <kbd class="shortcut-key">B</kbd></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
<div id="helpModal" class="modal">
|
<div id="helpModal" class="modal" data-help-content-version="2026-09-03">
|
||||||
<div class="modal-content help-modal">
|
<div class="modal-content help-modal">
|
||||||
<button class="close" onclick="modalManager.closeModal('helpModal')">×</button>
|
<button class="close" onclick="modalManager.closeModal('helpModal')">×</button>
|
||||||
<div class="help-header">
|
<div class="help-header">
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
<button class="tab-btn active" data-tab="getting-started">{{ t('help.tabs.gettingStarted') }}</button>
|
<button class="tab-btn active" data-tab="getting-started">{{ t('help.tabs.gettingStarted') }}</button>
|
||||||
<button class="tab-btn" data-tab="update-vlogs">{{ t('help.tabs.updateVlogs') }}</button>
|
<button class="tab-btn" data-tab="update-vlogs">{{ t('help.tabs.updateVlogs') }}</button>
|
||||||
<button class="tab-btn" data-tab="documentation">{{ t('help.tabs.documentation') }}</button>
|
<button class="tab-btn" data-tab="documentation">{{ t('help.tabs.documentation') }}</button>
|
||||||
|
<button class="tab-btn" data-tab="shortcuts">{{ t('help.tabs.shortcuts') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="help-content">
|
<div class="help-content">
|
||||||
@@ -39,6 +40,13 @@
|
|||||||
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
|
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="help-actions">
|
||||||
|
<button id="replayTutorialBtn" class="replay-tutorial-btn">
|
||||||
|
<i class="fas fa-graduation-cap"></i>
|
||||||
|
<span>{{ t('help.gettingStarted.replayTutorial') }}</span>
|
||||||
|
<span class="new-content-badge">{{ t('help.newContentBadge') }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Update Vlogs Tab -->
|
<!-- Update Vlogs Tab -->
|
||||||
@@ -136,6 +144,126 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Shortcuts Tab -->
|
||||||
|
<div class="tab-pane" id="shortcuts">
|
||||||
|
<h3>{{ t('help.shortcuts.title') }}</h3>
|
||||||
|
|
||||||
|
<div class="shortcuts-section">
|
||||||
|
<h4><i class="fas fa-keyboard"></i> {{ t('help.shortcuts.groups.general') }}</h4>
|
||||||
|
<ul class="shortcuts-list">
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>F</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.focusSearch') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Esc</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeModal') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>?</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.openShortcuts') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-section">
|
||||||
|
<h4><i class="fas fa-bolt"></i> {{ t('help.shortcuts.groups.actions') }}</h4>
|
||||||
|
<ul class="shortcuts-list">
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>R</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.refresh') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>F</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.fetchMetadata') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>D</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.downloadModel') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-section">
|
||||||
|
<h4><i class="fas fa-object-group"></i> {{ t('help.shortcuts.groups.selection') }}</h4>
|
||||||
|
<ul class="shortcuts-list">
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>B</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.toggleBulkMode') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>A</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.selectAll') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Shift</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.click') }}</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.rangeSelect') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.drag') }}</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.marqueeSelect') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Esc</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.exitBulkMode') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.bulkActions') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.globalActions') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-section">
|
||||||
|
<h4><i class="fas fa-arrows-alt-v"></i> {{ t('help.shortcuts.groups.navigation') }}</h4>
|
||||||
|
<ul class="shortcuts-list">
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>PageUp</kbd><span class="shortcut-sep">/</span><kbd>PageDown</kbd><span class="shortcut-sep">/</span><kbd>Home</kbd><span class="shortcut-sep">/</span><kbd>End</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.scrollPages') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Alt</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.letter') }}</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.jumpAlphabet') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-section">
|
||||||
|
<h4><i class="fas fa-window-restore"></i> {{ t('help.shortcuts.groups.modelModal') }}</h4>
|
||||||
|
<ul class="shortcuts-list">
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>←</kbd><span class="shortcut-sep">/</span><kbd>→</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.prevNext') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Delete</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.deleteEntry') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="shortcuts-section">
|
||||||
|
<h4><i class="fas fa-images"></i> {{ t('help.shortcuts.groups.mediaViewer') }}</h4>
|
||||||
|
<ul class="shortcuts-list">
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>←</kbd><span class="shortcut-sep">/</span><kbd>→</kbd><span class="shortcut-sep">/</span><kbd>[</kbd><span class="shortcut-sep">/</span><kbd>]</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.cycleMedia') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.swipe') }}</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.swipeTouch') }}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span class="shortcut-keys"><kbd>Esc</kbd></span>
|
||||||
|
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeViewer') }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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;">
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
BASE_MODEL_API_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
I18N_MODULE,
|
||||||
|
STORAGE_MODULE,
|
||||||
|
API_CONFIG_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
SIDEBAR_MANAGER_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const showMock = vi.fn();
|
||||||
|
const showCancelButtonMock = vi.fn();
|
||||||
|
const hideMock = vi.fn();
|
||||||
|
const restoreProgressBarMock = vi.fn();
|
||||||
|
const setProgressMock = vi.fn();
|
||||||
|
const setStatusMock = vi.fn();
|
||||||
|
const resetAndReloadMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
loadingManager: {
|
||||||
|
show: showMock,
|
||||||
|
showCancelButton: showCancelButtonMock,
|
||||||
|
hide: hideMock,
|
||||||
|
restoreProgressBar: restoreProgressBarMock,
|
||||||
|
setProgress: setProgressMock,
|
||||||
|
setStatus: setStatusMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_MODULE, () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => {
|
||||||
|
if (fallback) {
|
||||||
|
return Object.entries(params || {}).reduce(
|
||||||
|
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STORAGE_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn(),
|
||||||
|
getSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
saveMapToStorage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_CONFIG_MODULE, () => ({
|
||||||
|
getCompleteApiConfig: vi.fn(() => ({
|
||||||
|
endpoints: { scan: '/api/lm/loras/scan' },
|
||||||
|
config: { displayName: 'LoRA', singularName: 'lora' },
|
||||||
|
})),
|
||||||
|
getCurrentModelType: vi.fn(() => 'loras'),
|
||||||
|
isValidModelType: vi.fn(() => true),
|
||||||
|
DOWNLOAD_ENDPOINTS: {},
|
||||||
|
HF_ENDPOINTS: {},
|
||||||
|
WS_ENDPOINTS: { fetchProgress: '/ws/fetch-progress' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
resetAndReload: resetAndReloadMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||||
|
sidebarManager: { refresh: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static instances = [];
|
||||||
|
static failNextConnection = false;
|
||||||
|
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.onopen = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.onmessage = null;
|
||||||
|
this.close = vi.fn();
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
const shouldFail = FakeWebSocket.failNextConnection;
|
||||||
|
FakeWebSocket.failNextConnection = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (shouldFail) {
|
||||||
|
this.onerror?.(new Error('connection refused'));
|
||||||
|
} else {
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(data) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||||
|
class TestClient extends BaseModelApiClient {}
|
||||||
|
return new TestClient('loras');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('BaseModelApiClient.refreshModels scan progress', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
showToastMock.mockReset();
|
||||||
|
showMock.mockReset();
|
||||||
|
showCancelButtonMock.mockReset();
|
||||||
|
hideMock.mockReset();
|
||||||
|
restoreProgressBarMock.mockReset();
|
||||||
|
setProgressMock.mockReset();
|
||||||
|
setStatusMock.mockReset();
|
||||||
|
resetAndReloadMock.mockReset();
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
FakeWebSocket.failNextConnection = false;
|
||||||
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockFetchPending() {
|
||||||
|
let resolveFetch;
|
||||||
|
global.fetch = vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; }));
|
||||||
|
return {
|
||||||
|
resolveOk: (payload = { status: 'success' }) =>
|
||||||
|
resolveFetch({ ok: true, json: async () => payload }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRefresh(client, fullRebuild = false) {
|
||||||
|
const promise = client.refreshModels(fullRebuild);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(FakeWebSocket.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
const socket = FakeWebSocket.instances[0];
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(socket.onmessage).toBeTruthy();
|
||||||
|
});
|
||||||
|
return { promise, socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows scan progress updates from the WebSocket channel', async () => {
|
||||||
|
const fetchControl = mockFetchPending();
|
||||||
|
const client = await createClient();
|
||||||
|
const { promise, socket } = await startRefresh(client);
|
||||||
|
|
||||||
|
expect(socket.url).toBe(`ws://${window.location.host}/ws/fetch-progress`);
|
||||||
|
|
||||||
|
socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'started',
|
||||||
|
stage: 'scan_folders',
|
||||||
|
model_type: 'lora',
|
||||||
|
pageType: 'loras',
|
||||||
|
full_rebuild: false,
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'processing',
|
||||||
|
stage: 'process_models',
|
||||||
|
model_type: 'lora',
|
||||||
|
pageType: 'loras',
|
||||||
|
full_rebuild: false,
|
||||||
|
progress: 50,
|
||||||
|
processed: 5,
|
||||||
|
total: 10,
|
||||||
|
current_name: 'style.safetensors',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setProgressMock).toHaveBeenCalledWith(0);
|
||||||
|
expect(setProgressMock).toHaveBeenCalledWith(50);
|
||||||
|
const lastStatus = setStatusMock.mock.calls.at(-1)[0];
|
||||||
|
expect(lastStatus).toContain('(5/10)');
|
||||||
|
expect(lastStatus).toContain('style.safetensors');
|
||||||
|
// First ETA sample only anchors the timer
|
||||||
|
expect(lastStatus).toContain('Estimating time...');
|
||||||
|
|
||||||
|
fetchControl.resolveOk();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.refreshComplete',
|
||||||
|
{ action: 'Refresh' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(socket.close).toHaveBeenCalled();
|
||||||
|
expect(hideMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores messages for other types or other model types', async () => {
|
||||||
|
const fetchControl = mockFetchPending();
|
||||||
|
const client = await createClient();
|
||||||
|
const { promise, socket } = await startRefresh(client);
|
||||||
|
|
||||||
|
socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'processing',
|
||||||
|
stage: 'process_models',
|
||||||
|
model_type: 'checkpoint',
|
||||||
|
progress: 33,
|
||||||
|
processed: 1,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
socket.emit({
|
||||||
|
type: 'example_images_progress',
|
||||||
|
status: 'running',
|
||||||
|
model_type: 'lora',
|
||||||
|
progress: 66,
|
||||||
|
processed: 2,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(setProgressMock).not.toHaveBeenCalled();
|
||||||
|
expect(setStatusMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchControl.resolveOk();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain loading when the WebSocket connection fails', async () => {
|
||||||
|
FakeWebSocket.failNextConnection = true;
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: 'success' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
await client.refreshModels(true);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalled();
|
||||||
|
const [url] = global.fetch.mock.calls[0];
|
||||||
|
expect(url.searchParams.get('full_rebuild')).toBe('true');
|
||||||
|
expect(showMock).toHaveBeenCalledWith('Full rebuild LoRAs...', 0);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.refreshComplete',
|
||||||
|
{ action: 'Full rebuild' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('computes an ETA with EMA smoothing once enough samples arrive', async () => {
|
||||||
|
const fetchControl = mockFetchPending();
|
||||||
|
let now = 1000;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const { promise, socket } = await startRefresh(client);
|
||||||
|
|
||||||
|
const emitProcessing = (processed, total) => socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'processing',
|
||||||
|
stage: 'process_models',
|
||||||
|
model_type: 'lora',
|
||||||
|
progress: Math.floor((processed / total) * 100),
|
||||||
|
processed,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
|
||||||
|
// First sample anchors the timer
|
||||||
|
emitProcessing(1, 10);
|
||||||
|
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('Estimating time...');
|
||||||
|
|
||||||
|
// 100s elapsed for 2 files -> 50s per file -> 400s remaining -> ~7 min
|
||||||
|
now = 101000;
|
||||||
|
emitProcessing(2, 10);
|
||||||
|
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~7 min remaining');
|
||||||
|
|
||||||
|
// 110s elapsed for 4 files -> EMA = 50000*0.7 + 27500*0.3 = 43250ms/file
|
||||||
|
// remaining 6 files -> 259.5s -> ~4 min
|
||||||
|
now = 111000;
|
||||||
|
emitProcessing(4, 10);
|
||||||
|
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~4 min remaining');
|
||||||
|
|
||||||
|
fetchControl.resolveOk();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the cancelled toast when the server reports cancellation', async () => {
|
||||||
|
const fetchControl = mockFetchPending();
|
||||||
|
const client = await createClient();
|
||||||
|
const { promise } = await startRefresh(client);
|
||||||
|
|
||||||
|
fetchControl.resolveOk({ status: 'cancelled' });
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
|
||||||
|
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('createScanEtaTracker / formatScanRemainingTime', () => {
|
||||||
|
it('estimates remaining time from EMA of per-file cost', async () => {
|
||||||
|
const { createScanEtaTracker } = await import(BASE_MODEL_API_MODULE);
|
||||||
|
let now = 0;
|
||||||
|
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
||||||
|
|
||||||
|
const tracker = createScanEtaTracker();
|
||||||
|
expect(tracker.update(1, 10)).toBe('Estimating time...');
|
||||||
|
|
||||||
|
now = 60000; // 60s for 3 files -> 20s/file -> 7 * 20s = 140s -> ~2 min
|
||||||
|
expect(tracker.update(3, 10)).toBe('~2 min remaining');
|
||||||
|
|
||||||
|
now = 61000; // tiny delta keeps EMA near 20s/file
|
||||||
|
expect(tracker.update(4, 10)).toBe('~2 min remaining');
|
||||||
|
|
||||||
|
// Done: no ETA
|
||||||
|
expect(tracker.update(10, 10)).toBeNull();
|
||||||
|
expect(tracker.update(0, 0)).toBeNull();
|
||||||
|
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats hours and sub-minute remainders', async () => {
|
||||||
|
const { formatScanRemainingTime } = await import(BASE_MODEL_API_MODULE);
|
||||||
|
expect(formatScanRemainingTime(30000)).toBe('Less than a minute remaining');
|
||||||
|
expect(formatScanRemainingTime(5 * 60000)).toBe('~5 min remaining');
|
||||||
|
expect(formatScanRemainingTime(3600000 + 30 * 60000)).toBe('~1 hr 30 min remaining');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
|
show: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
setProgress: vi.fn(),
|
||||||
|
setStatus: vi.fn(),
|
||||||
|
}));
|
||||||
|
const virtualScrollerMock = vi.hoisted(() => ({
|
||||||
|
refreshWithData: vi.fn(),
|
||||||
|
}));
|
||||||
|
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
|
||||||
|
const etaUpdateMock = vi.hoisted(() => vi.fn(() => 'ETA soon'));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||||
|
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: {
|
||||||
|
loadingManager: loadingManagerMock,
|
||||||
|
virtualScroller: virtualScrollerMock,
|
||||||
|
},
|
||||||
|
getCurrentPageState: getCurrentPageStateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: vi.fn((key, params, fallback) => {
|
||||||
|
if (fallback) {
|
||||||
|
return Object.entries(params || {}).reduce(
|
||||||
|
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
|
captureScrollPosition: vi.fn(),
|
||||||
|
restoreScrollPosition: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
WS_ENDPOINTS: { fetchProgress: '/ws/fetch-progress' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/scanEtaUtils.js', () => ({
|
||||||
|
createScanEtaTracker: () => ({ update: etaUpdateMock }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { refreshRecipes } from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static instances = [];
|
||||||
|
static failNextConnection = false;
|
||||||
|
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.onopen = null;
|
||||||
|
this.onerror = null;
|
||||||
|
this.onmessage = null;
|
||||||
|
this.close = vi.fn();
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
const shouldFail = FakeWebSocket.failNextConnection;
|
||||||
|
FakeWebSocket.failNextConnection = false;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (shouldFail) {
|
||||||
|
this.onerror?.(new Error('connection refused'));
|
||||||
|
} else {
|
||||||
|
this.onopen?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(data) {
|
||||||
|
this.onmessage?.({ data: JSON.stringify(data) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('refreshRecipes scan progress', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
getCurrentPageStateMock.mockReturnValue({
|
||||||
|
pageSize: 50,
|
||||||
|
currentPage: 1,
|
||||||
|
hasMore: true,
|
||||||
|
isLoading: false,
|
||||||
|
sortBy: 'date:desc',
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
activeFolder: null,
|
||||||
|
searchOptions: { recursive: true },
|
||||||
|
customFilter: { active: false },
|
||||||
|
filters: {},
|
||||||
|
});
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
FakeWebSocket.failNextConnection = false;
|
||||||
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockFetchPendingScan() {
|
||||||
|
let resolveScan;
|
||||||
|
global.fetch = vi.fn((input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes('/scan')) {
|
||||||
|
return new Promise((resolve) => { resolveScan = resolve; });
|
||||||
|
}
|
||||||
|
// Recipe list reload after the scan completes
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ items: [], total: 0, total_pages: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
resolveOk: (payload = { status: 'success' }) =>
|
||||||
|
resolveScan({ ok: true, json: async () => payload }),
|
||||||
|
resolveNotOk: () =>
|
||||||
|
resolveScan({ ok: false, status: 500, statusText: 'Server Error' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRefresh(fullRebuild = true) {
|
||||||
|
const promise = refreshRecipes(fullRebuild);
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(FakeWebSocket.instances.length).toBe(1);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
const socket = FakeWebSocket.instances[0];
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(socket.onmessage).toBeTruthy();
|
||||||
|
});
|
||||||
|
return { promise, socket };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows scan progress updates from the WebSocket channel', async () => {
|
||||||
|
const fetchControl = mockFetchPendingScan();
|
||||||
|
const { promise, socket } = await startRefresh();
|
||||||
|
|
||||||
|
expect(socket.url).toBe(`ws://${window.location.host}/ws/fetch-progress`);
|
||||||
|
|
||||||
|
socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'started',
|
||||||
|
stage: 'scan_folders',
|
||||||
|
model_type: 'recipe',
|
||||||
|
pageType: 'recipes',
|
||||||
|
full_rebuild: true,
|
||||||
|
progress: 0,
|
||||||
|
});
|
||||||
|
socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'processing',
|
||||||
|
stage: 'process_models',
|
||||||
|
model_type: 'recipe',
|
||||||
|
pageType: 'recipes',
|
||||||
|
full_rebuild: true,
|
||||||
|
progress: 50,
|
||||||
|
processed: 5,
|
||||||
|
total: 10,
|
||||||
|
current_name: 'style.recipe.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadingManagerMock.setProgress).toHaveBeenCalledWith(0);
|
||||||
|
expect(loadingManagerMock.setProgress).toHaveBeenCalledWith(50);
|
||||||
|
const lastStatus = loadingManagerMock.setStatus.mock.calls.at(-1)[0];
|
||||||
|
expect(lastStatus).toContain('(5/10)');
|
||||||
|
expect(lastStatus).toContain('style.recipe.json');
|
||||||
|
expect(lastStatus).toContain('ETA soon');
|
||||||
|
expect(etaUpdateMock).toHaveBeenCalledWith(5, 10);
|
||||||
|
|
||||||
|
fetchControl.resolveOk();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.refreshComplete',
|
||||||
|
{ action: 'Full rebuild' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(socket.close).toHaveBeenCalled();
|
||||||
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores messages for other types or other model types', async () => {
|
||||||
|
const fetchControl = mockFetchPendingScan();
|
||||||
|
const { promise, socket } = await startRefresh();
|
||||||
|
|
||||||
|
socket.emit({
|
||||||
|
type: 'scan_progress',
|
||||||
|
status: 'processing',
|
||||||
|
stage: 'process_models',
|
||||||
|
model_type: 'lora',
|
||||||
|
progress: 33,
|
||||||
|
processed: 1,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
socket.emit({
|
||||||
|
type: 'example_images_progress',
|
||||||
|
status: 'running',
|
||||||
|
model_type: 'recipe',
|
||||||
|
progress: 66,
|
||||||
|
processed: 2,
|
||||||
|
total: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadingManagerMock.setProgress).not.toHaveBeenCalled();
|
||||||
|
expect(loadingManagerMock.setStatus).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fetchControl.resolveOk();
|
||||||
|
await promise;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to plain loading when the WebSocket connection fails', async () => {
|
||||||
|
FakeWebSocket.failNextConnection = true;
|
||||||
|
global.fetch = vi.fn((input) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url.includes('/scan')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ status: 'success' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ items: [], total: 0, total_pages: 0 }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await refreshRecipes(false);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalled();
|
||||||
|
const [url] = global.fetch.mock.calls[0];
|
||||||
|
expect(url.searchParams.get('full_rebuild')).toBe('false');
|
||||||
|
expect(loadingManagerMock.show).toHaveBeenCalledWith('Refreshing Recipes...', 0);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.refreshComplete',
|
||||||
|
{ action: 'Refresh' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the cancelled toast when the server reports cancellation', async () => {
|
||||||
|
const fetchControl = mockFetchPendingScan();
|
||||||
|
const { promise } = await startRefresh();
|
||||||
|
|
||||||
|
fetchControl.resolveOk({ status: 'cancelled' });
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.api.refreshComplete',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports refresh failures through the error toast', async () => {
|
||||||
|
const fetchControl = mockFetchPendingScan();
|
||||||
|
const { promise } = await startRefresh();
|
||||||
|
|
||||||
|
fetchControl.resolveNotOk();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.refreshFailed',
|
||||||
|
{ action: 'rebuild', type: 'recipe' },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
API_MODULE,
|
||||||
|
APP_MODULE,
|
||||||
|
CARET_HELPER_MODULE,
|
||||||
|
PREVIEW_COMPONENT_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,
|
||||||
|
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
|
||||||
|
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
|
||||||
|
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchApiMock = vi.fn();
|
||||||
|
const settingGetMock = vi.fn();
|
||||||
|
const caretHelperInstance = {
|
||||||
|
getBeforeCursor: vi.fn(() => ''),
|
||||||
|
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: {
|
||||||
|
fetchApi: fetchApiMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: {
|
||||||
|
canvas: {
|
||||||
|
ds: { scale: 1 },
|
||||||
|
},
|
||||||
|
extensionManager: {
|
||||||
|
setting: {
|
||||||
|
get: settingGetMock,
|
||||||
|
set: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registerExtension: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(CARET_HELPER_MODULE, () => ({
|
||||||
|
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
|
||||||
|
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function createAutoComplete(modelType, activeFiltersEnabled) {
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return activeFiltersEnabled;
|
||||||
|
}
|
||||||
|
if (key === 'loramanager.autocomplete_append_comma') return false;
|
||||||
|
if (key === 'loramanager.autocomplete_auto_format') return false;
|
||||||
|
if (key === 'loramanager.autocomplete_accept_key') return 'both';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, relative_paths: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, modelType, { debounceDelay: 0, showPreview: false });
|
||||||
|
|
||||||
|
input.value = 'example';
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
return autoComplete;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AutoComplete active-filters flag', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
|
||||||
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
|
fetchApiMock.mockReset();
|
||||||
|
settingGetMock.mockReset();
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReset();
|
||||||
|
caretHelperInstance.getCursorOffset.mockReset();
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends use_active_filters for loras when the setting is enabled', async () => {
|
||||||
|
await createAutoComplete('loras', true);
|
||||||
|
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith(
|
||||||
|
'/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the flag when the setting is disabled', async () => {
|
||||||
|
await createAutoComplete('loras', false);
|
||||||
|
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits the flag for non-lora model types even when enabled', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({ success: true, words: [] }),
|
||||||
|
});
|
||||||
|
await createAutoComplete('prompt', true);
|
||||||
|
|
||||||
|
for (const call of fetchApiMock.mock.calls) {
|
||||||
|
expect(call[0]).not.toContain('use_active_filters');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not read filter state from localStorage anymore', async () => {
|
||||||
|
localStorage.setItem('lora_manager_loras_activeFolder', 'SD_XL');
|
||||||
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ baseModel: ['SDXL 1.0'] }));
|
||||||
|
|
||||||
|
await createAutoComplete('loras', true);
|
||||||
|
|
||||||
|
for (const call of fetchApiMock.mock.calls) {
|
||||||
|
expect(call[0]).not.toContain('folder=');
|
||||||
|
expect(call[0]).not.toContain('base_model=');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const typeLorasSlashCommand = async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = '/';
|
||||||
|
input.selectionStart = 1;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
return autoComplete;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('shows the active-filters state below the loras slash command list', async () => {
|
||||||
|
await typeLorasSlashCommand();
|
||||||
|
|
||||||
|
const footer = document.querySelector('.lm-autocomplete-command-footer');
|
||||||
|
expect(footer).not.toBeNull();
|
||||||
|
expect(footer.textContent).toContain('Active Filters Search: OFF');
|
||||||
|
expect(footer.textContent).toContain('/activefilters to enable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows how to disable active-filters search in the footer when it is on', async () => {
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
await typeLorasSlashCommand();
|
||||||
|
|
||||||
|
const footer = document.querySelector('.lm-autocomplete-command-footer');
|
||||||
|
expect(footer).not.toBeNull();
|
||||||
|
expect(footer.textContent).toContain('Active Filters Search: ON');
|
||||||
|
expect(footer.textContent).toContain('/noactivefilters to disable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a dismissible first-run hint on loras suggestions and remembers dismissal', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
relative_paths: ['models/example.safetensors'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const triggerSearch = async () => {
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'example';
|
||||||
|
input.selectionStart = 7;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
return autoComplete;
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoComplete = await triggerSearch();
|
||||||
|
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
|
||||||
|
expect(hint).not.toBeNull();
|
||||||
|
expect(hint.textContent).toContain('/activefilters');
|
||||||
|
|
||||||
|
hint.querySelector('button').click();
|
||||||
|
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||||
|
expect(localStorage.getItem('lm:activefilters-tip-dismissed')).toBe('1');
|
||||||
|
// A fresh instance no longer shows the hint once dismissed
|
||||||
|
const autoComplete2 = await triggerSearch();
|
||||||
|
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show the loras first-run hint when active-filters search is already on', async () => {
|
||||||
|
settingGetMock.mockImplementation((key) => {
|
||||||
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
success: true,
|
||||||
|
relative_paths: ['models/example.safetensors'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = document.createElement('textarea');
|
||||||
|
input.value = 'example';
|
||||||
|
input.selectionStart = 7;
|
||||||
|
document.body.append(input);
|
||||||
|
|
||||||
|
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
|
||||||
|
|
||||||
|
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
const autoComplete = new AutoComplete(input, 'loras', {
|
||||||
|
debounceDelay: 0,
|
||||||
|
showPreview: false,
|
||||||
|
minChars: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await vi.runOnlyPendingTimersAsync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1789,7 +1789,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('appends active filter params to loras autocomplete requests when enabled', async () => {
|
it('sends only the use_active_filters flag when enabled (filters resolved server-side)', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1799,12 +1799,11 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
return undefined;
|
return undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Stored manager-page filters must NOT leak into the request URL; the
|
||||||
|
// backend injects them from its server-side store.
|
||||||
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
|
||||||
baseModel: ['SD 1.5'],
|
baseModel: ['SD 1.5'],
|
||||||
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
|
tags: { anime: 'include', nsfw: 'exclude' },
|
||||||
autoTags: { I2V: 'include' },
|
|
||||||
modelTypes: ['standard'],
|
|
||||||
tagLogic: 'all',
|
|
||||||
license: { noCredit: 'include', allowSelling: 'exclude' },
|
license: { noCredit: 'include', allowSelling: 'exclude' },
|
||||||
}));
|
}));
|
||||||
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
|
||||||
@@ -1830,19 +1829,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
|
expect(calledUrl).toBe('/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true');
|
||||||
expect(calledUrl).toContain('folder=MyLoras');
|
|
||||||
expect(calledUrl).toContain('recursive=true');
|
|
||||||
expect(calledUrl).toContain('tag_include=anime');
|
|
||||||
expect(calledUrl).toContain('tag_exclude=nsfw');
|
|
||||||
expect(calledUrl).toContain('tag_exclude=__no_tags__');
|
|
||||||
expect(calledUrl).toContain('auto_tag_include=I2V');
|
|
||||||
expect(calledUrl).toContain('tag_logic=all');
|
|
||||||
expect(calledUrl).toContain('credit_required=false');
|
|
||||||
expect(calledUrl).toContain('allow_selling_generated_content=false');
|
|
||||||
const parsed = new URL(calledUrl, 'https://example.com');
|
|
||||||
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
|
|
||||||
expect(parsed.searchParams.get('model_type')).toBe('standard');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
|
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
|
||||||
@@ -1870,10 +1857,12 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends the filter-pipeline signal even when no filters are stored', async () => {
|
it('sends the filter-pipeline flag even when no filters are stored', async () => {
|
||||||
// Regression: with filter mode on but no folder/filters stored, the request
|
// Regression: with filter mode on but no folder/filters stored, the request
|
||||||
// carried no params, so the backend skipped the filter pipeline and global
|
// carried no signal, so the backend skipped the filter pipeline and global
|
||||||
// settings like show_only_sfw diverged from the list endpoint.
|
// settings like show_only_sfw diverged from the list endpoint. The flag
|
||||||
|
// makes the backend run the pipeline (injecting nothing when its store
|
||||||
|
// is empty).
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1907,10 +1896,13 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('recursive=true');
|
expect(calledUrl).toContain('use_active_filters=true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('omits folder param when active folder is root and recursion is enabled', async () => {
|
it('leaves folder params to the backend when active folder is root with recursion enabled', async () => {
|
||||||
|
// The root-folder/recursion semantics now live server-side (see
|
||||||
|
// active_filters_store.active_filters_to_query_kwargs); the client only
|
||||||
|
// sends the flag.
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1948,10 +1940,12 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).not.toContain('folder=');
|
expect(calledUrl).not.toContain('folder=');
|
||||||
expect(calledUrl).toContain('recursive=true');
|
expect(calledUrl).toContain('use_active_filters=true');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
|
it('leaves the root+non-recursive folder mapping to the backend', async () => {
|
||||||
|
// Root with recursion disabled maps to folder='' server-side (mirroring
|
||||||
|
// the page list); the client no longer encodes this in the URL.
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -1988,15 +1982,14 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('folder=');
|
expect(calledUrl).not.toContain('folder=');
|
||||||
expect(calledUrl).toContain('recursive=false');
|
expect(calledUrl).toContain('use_active_filters=true');
|
||||||
const parsed = new URL(calledUrl, 'https://example.com');
|
|
||||||
expect(parsed.searchParams.get('folder')).toBe('');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the active folder even when no filter-panel filters are set', async () => {
|
it('sends the flag even when only a folder is stored (no filter-panel filters)', async () => {
|
||||||
// Regression: folder was skipped when lora_manager_loras_filters was
|
// Regression: folder was skipped when lora_manager_loras_filters was
|
||||||
// missing because the filters key gate returned early.
|
// missing because the filters key gate returned early. The flag is now
|
||||||
|
// unconditional, and the backend injects the folder from its store.
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
|
|
||||||
settingGetMock.mockImplementation((key) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
@@ -2029,8 +2022,8 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const calledUrl = fetchApiMock.mock.calls[0][0];
|
const calledUrl = fetchApiMock.mock.calls[0][0];
|
||||||
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
|
expect(calledUrl).toContain('use_active_filters=true');
|
||||||
expect(calledUrl).toContain('recursive=true');
|
expect(calledUrl).not.toContain('folder=');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('discoverability hints', () => {
|
describe('discoverability hints', () => {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
API_MODULE,
|
||||||
|
APP_MODULE,
|
||||||
|
CARET_HELPER_MODULE,
|
||||||
|
PREVIEW_COMPONENT_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,
|
||||||
|
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
|
||||||
|
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.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(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(CARET_HELPER_MODULE, () => ({
|
||||||
|
TextAreaCaretHelper: vi.fn(() => ({
|
||||||
|
getBeforeCursor: vi.fn(() => ''),
|
||||||
|
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
|
||||||
|
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const METADATA_NAME = '__lm_autocomplete_meta_text';
|
||||||
|
|
||||||
|
function makeMetadataValue() {
|
||||||
|
return {
|
||||||
|
version: 1,
|
||||||
|
textWidgetName: 'text',
|
||||||
|
lastAccepted: {
|
||||||
|
start: 0,
|
||||||
|
end: 6,
|
||||||
|
insertedText: '1girl ',
|
||||||
|
textSnapshot: 'old prompt text, 1girl ',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('stripAutocompleteLastAccepted', () => {
|
||||||
|
let stripAutocompleteLastAccepted;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
stripAutocompleteLastAccepted = module.stripAutocompleteLastAccepted;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes lastAccepted while keeping the metadata base fields', () => {
|
||||||
|
const value = makeMetadataValue();
|
||||||
|
const stripped = stripAutocompleteLastAccepted(value);
|
||||||
|
|
||||||
|
expect(stripped).toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
expect('lastAccepted' in stripped).toBe(false);
|
||||||
|
// Original value must not be mutated
|
||||||
|
expect(value.lastAccepted).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns values without lastAccepted as-is (same reference)', () => {
|
||||||
|
const value = { version: 1, textWidgetName: 'text' };
|
||||||
|
expect(stripAutocompleteLastAccepted(value)).toBe(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns non-object values as-is', () => {
|
||||||
|
expect(stripAutocompleteLastAccepted(null)).toBe(null);
|
||||||
|
expect(stripAutocompleteLastAccepted(undefined)).toBe(undefined);
|
||||||
|
expect(stripAutocompleteLastAccepted('text')).toBe('text');
|
||||||
|
expect(stripAutocompleteLastAccepted([1, 2])).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('stripAutocompleteMetadataFromPromptResult', () => {
|
||||||
|
let stripResult;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const module = await import(AUTOCOMPLETE_MODULE);
|
||||||
|
stripResult = module.stripAutocompleteMetadataFromPromptResult;
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeWorkflowNode() {
|
||||||
|
const metadataValue = makeMetadataValue();
|
||||||
|
return {
|
||||||
|
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
|
||||||
|
widgets_values: ['current text', metadataValue],
|
||||||
|
widgets_values_named: {
|
||||||
|
text: 'current text',
|
||||||
|
[METADATA_NAME]: metadataValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('strips lastAccepted from workflow widgets_values using __lm_widget_ids alignment', () => {
|
||||||
|
const result = {
|
||||||
|
workflow: { nodes: [makeWorkflowNode()] },
|
||||||
|
output: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const returned = stripResult(result);
|
||||||
|
|
||||||
|
expect(returned).toBe(result);
|
||||||
|
expect(result.workflow.nodes[0].widgets_values[1])
|
||||||
|
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips lastAccepted from widgets_values_named and leaves other widgets untouched', () => {
|
||||||
|
const result = {
|
||||||
|
workflow: { nodes: [makeWorkflowNode()] },
|
||||||
|
output: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
stripResult(result);
|
||||||
|
|
||||||
|
const node = result.workflow.nodes[0];
|
||||||
|
expect(node.widgets_values_named[METADATA_NAME])
|
||||||
|
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
expect(node.widgets_values_named.text).toBe('current text');
|
||||||
|
expect(node.widgets_values[0]).toBe('current text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles null entries in widgets_values (bypass compatibility padding)', () => {
|
||||||
|
const node = makeWorkflowNode();
|
||||||
|
node.properties.__lm_widget_ids = ['text', 'seed', METADATA_NAME];
|
||||||
|
node.widgets_values = ['current text', null, makeMetadataValue()];
|
||||||
|
const result = { workflow: { nodes: [node] }, output: {} };
|
||||||
|
|
||||||
|
stripResult(result);
|
||||||
|
|
||||||
|
expect(result.workflow.nodes[0].widgets_values[1]).toBe(null);
|
||||||
|
expect(result.workflow.nodes[0].widgets_values[2])
|
||||||
|
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still strips widgets_values_named when __lm_widget_ids is missing (legacy files)', () => {
|
||||||
|
const node = makeWorkflowNode();
|
||||||
|
delete node.properties;
|
||||||
|
const arrayValue = node.widgets_values[1];
|
||||||
|
const result = { workflow: { nodes: [node] }, output: {} };
|
||||||
|
|
||||||
|
stripResult(result);
|
||||||
|
|
||||||
|
// Array entries cannot be located without widget ids — left untouched
|
||||||
|
expect(result.workflow.nodes[0].widgets_values[1]).toBe(arrayValue);
|
||||||
|
expect(result.workflow.nodes[0].widgets_values_named[METADATA_NAME])
|
||||||
|
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips lastAccepted from output (API prompt) inputs', () => {
|
||||||
|
const result = {
|
||||||
|
workflow: { nodes: [] },
|
||||||
|
output: {
|
||||||
|
'7': {
|
||||||
|
class_type: 'Prompt (LoraManager)',
|
||||||
|
inputs: {
|
||||||
|
text: 'current text',
|
||||||
|
[METADATA_NAME]: makeMetadataValue(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stripResult(result);
|
||||||
|
|
||||||
|
const inputs = result.output['7'].inputs;
|
||||||
|
expect(inputs[METADATA_NAME]).toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
expect(inputs.text).toBe('current text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips lastAccepted inside subgraph definitions', () => {
|
||||||
|
const result = {
|
||||||
|
workflow: {
|
||||||
|
nodes: [],
|
||||||
|
definitions: {
|
||||||
|
subgraphs: [{ nodes: [makeWorkflowNode()] }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
output: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
stripResult(result);
|
||||||
|
|
||||||
|
const subgraphNode = result.workflow.definitions.subgraphs[0].nodes[0];
|
||||||
|
expect(subgraphNode.widgets_values_named[METADATA_NAME])
|
||||||
|
.toEqual({ version: 1, textWidgetName: 'text' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves results without lastAccepted unchanged', () => {
|
||||||
|
const metadataValue = { version: 1, textWidgetName: 'text' };
|
||||||
|
const result = {
|
||||||
|
workflow: {
|
||||||
|
nodes: [{
|
||||||
|
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
|
||||||
|
widgets_values: ['abc', metadataValue],
|
||||||
|
widgets_values_named: { text: 'abc', [METADATA_NAME]: metadataValue },
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
'1': { inputs: { text: 'abc', [METADATA_NAME]: metadataValue } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
stripResult(result);
|
||||||
|
|
||||||
|
expect(result.workflow.nodes[0].widgets_values[1]).toBe(metadataValue);
|
||||||
|
expect(result.output['1'].inputs[METADATA_NAME]).toBe(metadataValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tolerates malformed results', () => {
|
||||||
|
expect(stripResult(null)).toBe(null);
|
||||||
|
expect(stripResult(undefined)).toBe(undefined);
|
||||||
|
expect(stripResult({})).toEqual({});
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
workflow: { nodes: [null, { widgets_values: null }] },
|
||||||
|
output: { '1': { inputs: null }, '2': {} },
|
||||||
|
};
|
||||||
|
expect(() => stripResult(result)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
|
const {
|
||||||
|
APP_MODULE,
|
||||||
|
API_MODULE,
|
||||||
|
UTILS_MODULE,
|
||||||
|
SETTINGS_MODULE,
|
||||||
|
LORA_LOADER_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
|
||||||
|
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
|
||||||
|
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
|
||||||
|
SETTINGS_MODULE: new URL("../../../web/comfyui/settings.js", import.meta.url).pathname,
|
||||||
|
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const extensionState = { current: null };
|
||||||
|
const registerExtensionMock = vi.fn((extension) => {
|
||||||
|
extensionState.current = extension;
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: {
|
||||||
|
registerExtension: registerExtensionMock,
|
||||||
|
graph: {},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(UTILS_MODULE, () => ({
|
||||||
|
collectActiveLorasFromChain: vi.fn(),
|
||||||
|
updateConnectedTriggerWords: vi.fn(),
|
||||||
|
mergeLoras: vi.fn(),
|
||||||
|
chainCallback: (proto, property, callback) => {
|
||||||
|
proto[property] = callback;
|
||||||
|
},
|
||||||
|
getAllGraphNodes: vi.fn(),
|
||||||
|
getNodeFromGraph: vi.fn(),
|
||||||
|
getWidgetByName: vi.fn(),
|
||||||
|
getWidgetSerializedValue: vi.fn(),
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const getActiveFiltersPreferenceMock = vi.fn();
|
||||||
|
const setSettingValueMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(SETTINGS_MODULE, () => ({
|
||||||
|
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
|
||||||
|
"loramanager.lora_active_filters_autocomplete",
|
||||||
|
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
|
||||||
|
setLoraManagerSettingValue: setSettingValueMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function registerNodeType(comfyClass) {
|
||||||
|
await import(LORA_LOADER_MODULE);
|
||||||
|
const extension = extensionState.current;
|
||||||
|
expect(extension).toBeDefined();
|
||||||
|
const nodeType = { comfyClass, prototype: {} };
|
||||||
|
await extension.beforeRegisterNodeDef(nodeType, {}, {});
|
||||||
|
return nodeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMenuOption(nodeType, enabled) {
|
||||||
|
getActiveFiltersPreferenceMock.mockReturnValue(enabled);
|
||||||
|
const options = [];
|
||||||
|
nodeType.prototype.getExtraMenuOptions(null, options);
|
||||||
|
return options.find(
|
||||||
|
(option) =>
|
||||||
|
option &&
|
||||||
|
typeof option.content === "string" &&
|
||||||
|
option.content.startsWith("Active Filters Search:")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Lora Loader active-filters context menu", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
extensionState.current = null;
|
||||||
|
registerExtensionMock.mockClear();
|
||||||
|
showToastMock.mockClear();
|
||||||
|
getActiveFiltersPreferenceMock.mockReset();
|
||||||
|
setSettingValueMock.mockReset();
|
||||||
|
setSettingValueMock.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Lora Loader (LoraManager)",
|
||||||
|
"Lora Stacker (LoraManager)",
|
||||||
|
"WanVideo Lora Select (LoraManager)",
|
||||||
|
"Create Hook LoRA (LoraManager)",
|
||||||
|
])("adds the toggle entry to the %s context menu", async (comfyClass) => {
|
||||||
|
const nodeType = await registerNodeType(comfyClass);
|
||||||
|
|
||||||
|
const option = getMenuOption(nodeType, false);
|
||||||
|
expect(option).toBeDefined();
|
||||||
|
expect(option.content).toContain("Active Filters Search: OFF");
|
||||||
|
expect(option.content).toContain("/activefilters to enable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the disable hint when active-filters search is on", async () => {
|
||||||
|
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
|
||||||
|
|
||||||
|
const option = getMenuOption(nodeType, true);
|
||||||
|
expect(option.content).toContain("Active Filters Search: ON");
|
||||||
|
expect(option.content).toContain("/noactivefilters to disable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles the setting and toasts feedback", async () => {
|
||||||
|
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
|
||||||
|
|
||||||
|
const enableOption = getMenuOption(nodeType, false);
|
||||||
|
await enableOption.callback();
|
||||||
|
|
||||||
|
expect(setSettingValueMock).toHaveBeenCalledWith(
|
||||||
|
"loramanager.lora_active_filters_autocomplete",
|
||||||
|
true
|
||||||
|
);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ summary: "Active Filters Search Enabled" })
|
||||||
|
);
|
||||||
|
|
||||||
|
const disableOption = getMenuOption(nodeType, true);
|
||||||
|
await disableOption.callback();
|
||||||
|
|
||||||
|
expect(setSettingValueMock).toHaveBeenCalledWith(
|
||||||
|
"loramanager.lora_active_filters_autocomplete",
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ summary: "Active Filters Search Disabled" })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -43,6 +43,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|||||||
showToast: showToastMock,
|
showToast: showToastMock,
|
||||||
openCivitaiByMetadata: openCivitaiByMetadataMock,
|
openCivitaiByMetadata: openCivitaiByMetadataMock,
|
||||||
updatePanelPositions: updatePanelPositionsMock,
|
updatePanelPositions: updatePanelPositionsMock,
|
||||||
|
// Faithful stand-in for the real helper in uiHelpers.js
|
||||||
|
isTypingContext: (target) => {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
const tagName = target.tagName?.toLowerCase();
|
||||||
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||||
@@ -1200,4 +1206,87 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
|
|||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PageControls action keyboard shortcuts', () => {
|
||||||
|
async function setupLorasControls() {
|
||||||
|
renderControlsDom('loras');
|
||||||
|
const stateModule = await import('../../../static/js/state/index.js');
|
||||||
|
stateModule.initPageState('loras');
|
||||||
|
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
|
||||||
|
return new LorasControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function keydownEvent(key, { target = document.body, ...init } = {}) {
|
||||||
|
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
|
||||||
|
Object.defineProperty(event, 'target', { value: target });
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('registers a pageControls-actions keydown handler with the event manager', async () => {
|
||||||
|
await setupLorasControls();
|
||||||
|
|
||||||
|
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
|
||||||
|
const keydownHandlers = eventManager.handlers.get('keydown') || [];
|
||||||
|
expect(keydownHandlers.some((h) => h.source === 'pageControls-actions')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers refresh, fetch, and download via the R / F / D keys', async () => {
|
||||||
|
const controls = await setupLorasControls();
|
||||||
|
|
||||||
|
expect(controls.handleActionShortcut(keydownEvent('r'))).toBe(true);
|
||||||
|
expect(refreshModelsMock).toHaveBeenCalledWith(false);
|
||||||
|
|
||||||
|
expect(controls.handleActionShortcut(keydownEvent('f'))).toBe(true);
|
||||||
|
expect(fetchCivitaiMetadataMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(controls.handleActionShortcut(keydownEvent('d'))).toBe(true);
|
||||||
|
expect(downloadManagerMock.showDownloadModal).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles a real keydown dispatched on the document', async () => {
|
||||||
|
await setupLorasControls();
|
||||||
|
|
||||||
|
const event = new KeyboardEvent('keydown', { key: 'r', bubbles: true, cancelable: true });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
expect(refreshModelsMock).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores R / F / D while typing in an input', async () => {
|
||||||
|
const controls = await setupLorasControls();
|
||||||
|
|
||||||
|
const input = document.getElementById('searchInput');
|
||||||
|
for (const key of ['r', 'f', 'd']) {
|
||||||
|
const event = keydownEvent(key, { target: input });
|
||||||
|
expect(controls.handleActionShortcut(event)).toBe(false);
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(refreshModelsMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
|
||||||
|
expect(downloadManagerMock.showDownloadModal).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores R / F / D when combined with modifier keys', async () => {
|
||||||
|
const controls = await setupLorasControls();
|
||||||
|
|
||||||
|
const event = keydownEvent('r', { ctrlKey: true });
|
||||||
|
expect(controls.handleActionShortcut(event)).toBe(false);
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
expect(refreshModelsMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes the event through when the action button does not exist', async () => {
|
||||||
|
const controls = await setupLorasControls();
|
||||||
|
|
||||||
|
// Recipes page has no fetch/download buttons
|
||||||
|
document.querySelector('[data-action="fetch"]').closest('.control-group').remove();
|
||||||
|
|
||||||
|
const event = keydownEvent('f');
|
||||||
|
expect(controls.handleActionShortcut(event)).toBe(false);
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -13,6 +13,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
|||||||
showToast: vi.fn(),
|
showToast: vi.fn(),
|
||||||
openCivitaiByMetadata: vi.fn(),
|
openCivitaiByMetadata: vi.fn(),
|
||||||
updatePanelPositions: vi.fn(),
|
updatePanelPositions: vi.fn(),
|
||||||
|
// Faithful stand-in for the real helper in uiHelpers.js
|
||||||
|
isTypingContext: (target) => {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
const tagName = target.tagName?.toLowerCase();
|
||||||
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -153,6 +153,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 +181,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 +292,57 @@ describe('RecipeModal resource item interactions', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders a download action (not 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, not Reconnect.
|
||||||
|
expect(item.querySelector('.missing-badge')).not.toBeNull();
|
||||||
|
expect(item.querySelector('.lora-download')).not.toBeNull();
|
||||||
|
expect(item.querySelector('.lora-reconnect')).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
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, it, beforeEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const HELP_MANAGER_MODULE = new URL('../../../static/js/managers/HelpManager.js', import.meta.url).pathname;
|
||||||
|
const VIEWED_KEY = 'lora_manager_help_viewed_content_version';
|
||||||
|
|
||||||
|
function setupDom({ versionMarker = null } = {}) {
|
||||||
|
const markerAttr = versionMarker ? ` data-help-content-version="${versionMarker}"` : '';
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div class="help-toggle" id="helpToggleBtn">
|
||||||
|
<span class="update-badge"></span>
|
||||||
|
</div>
|
||||||
|
<div id="helpModal" class="modal"${markerAttr}>
|
||||||
|
<div class="help-tabs">
|
||||||
|
<button class="tab-btn" data-tab="getting-started"></button>
|
||||||
|
<button class="tab-btn" data-tab="shortcuts"></button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane active" id="getting-started">
|
||||||
|
<button id="replayTutorialBtn" class="replay-tutorial-btn"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('HelpManager content-version badge logic', () => {
|
||||||
|
let HelpManager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
({ HelpManager } = await import(HELP_MANAGER_MODULE));
|
||||||
|
});
|
||||||
|
|
||||||
|
function badgeIsVisible() {
|
||||||
|
return document.querySelector('#helpToggleBtn .update-badge').classList.contains('visible');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('has no new content when the served markup carries no version marker', () => {
|
||||||
|
setupDom({ versionMarker: null });
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
expect(manager.hasNewContent()).toBe(false);
|
||||||
|
manager.updateHelpBadge();
|
||||||
|
expect(badgeIsVisible()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has new content when a version marker exists and nothing has been viewed yet', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
expect(manager.hasNewContent()).toBe(true);
|
||||||
|
manager.updateHelpBadge();
|
||||||
|
expect(badgeIsVisible()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has no new content once the stored viewed version matches the marker', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
localStorage.setItem(VIEWED_KEY, '2026-09-03');
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
expect(manager.hasNewContent()).toBe(false);
|
||||||
|
manager.updateHelpBadge();
|
||||||
|
expect(badgeIsVisible()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has new content again when the marker moves to a newer version', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
localStorage.setItem(VIEWED_KEY, '2025-10-11');
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
expect(manager.hasNewContent()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markContentAsViewed persists the DOM marker version', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.markContentAsViewed();
|
||||||
|
|
||||||
|
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
|
||||||
|
expect(manager.hasNewContent()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('markContentAsViewed is a no-op without a version marker (stale assets)', () => {
|
||||||
|
setupDom({ versionMarker: null });
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.markContentAsViewed();
|
||||||
|
|
||||||
|
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening the help modal without new content does not mark it as viewed', () => {
|
||||||
|
// Regression test: on a stale (pre-upgrade) page the user may open the
|
||||||
|
// help modal before refreshing; that must not suppress the badge for
|
||||||
|
// the new content they have not seen yet.
|
||||||
|
setupDom({ versionMarker: null });
|
||||||
|
window.modalManager = { toggleModal: vi.fn() };
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.openHelpModal();
|
||||||
|
|
||||||
|
expect(localStorage.getItem(VIEWED_KEY)).toBeNull();
|
||||||
|
expect(manager.hasNewContent()).toBe(false);
|
||||||
|
delete window.modalManager;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opening the help modal with new content marks it as viewed and hides the badge', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
window.modalManager = { toggleModal: vi.fn() };
|
||||||
|
const manager = new HelpManager();
|
||||||
|
manager.updateHelpBadge();
|
||||||
|
expect(badgeIsVisible()).toBe(true);
|
||||||
|
|
||||||
|
manager.openHelpModal();
|
||||||
|
|
||||||
|
expect(localStorage.getItem(VIEWED_KEY)).toBe('2026-09-03');
|
||||||
|
expect(badgeIsVisible()).toBe(false);
|
||||||
|
delete window.modalManager;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds new-content indicators to the getting-started and shortcuts tabs', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.updateNewContentTabIndicators();
|
||||||
|
|
||||||
|
expect(document.querySelector('.help-tabs .tab-btn[data-tab="getting-started"]').classList.contains('has-new-content')).toBe(true);
|
||||||
|
expect(document.querySelector('.help-tabs .tab-btn[data-tab="shortcuts"]').classList.contains('has-new-content')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flags the Replay Tutorial button and scrolls it into view', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
const replayBtn = document.getElementById('replayTutorialBtn');
|
||||||
|
replayBtn.scrollIntoView = vi.fn();
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.updateNewContentTabIndicators();
|
||||||
|
|
||||||
|
expect(replayBtn.classList.contains('has-new-content')).toBe(true);
|
||||||
|
expect(replayBtn.scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not flag the Replay Tutorial button when the content is not new', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
localStorage.setItem(VIEWED_KEY, '2026-09-03');
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.updateNewContentTabIndicators();
|
||||||
|
|
||||||
|
expect(document.getElementById('replayTutorialBtn').classList.contains('has-new-content')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not scroll the Replay Tutorial button when the getting-started tab is inactive', () => {
|
||||||
|
setupDom({ versionMarker: '2026-09-03' });
|
||||||
|
document.getElementById('getting-started').classList.remove('active');
|
||||||
|
const replayBtn = document.getElementById('replayTutorialBtn');
|
||||||
|
replayBtn.scrollIntoView = vi.fn();
|
||||||
|
const manager = new HelpManager();
|
||||||
|
|
||||||
|
manager.updateNewContentTabIndicators();
|
||||||
|
|
||||||
|
expect(replayBtn.scrollIntoView).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
import { setStorageItem, removeStorageItem, setActiveFiltersListener } from '../../../static/js/utils/storageHelpers.js';
|
||||||
|
import { initActiveFiltersSync, pushActiveFilters } from '../../../static/js/utils/activeFiltersSync.js';
|
||||||
|
|
||||||
|
const okResponse = () => ({ ok: true, status: 200 });
|
||||||
|
|
||||||
|
describe('activeFiltersSync', () => {
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock = vi.fn(() => Promise.resolve(okResponse()));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes current state immediately on init', async () => {
|
||||||
|
setStorageItem('loras_activeFolder', 'SD_XL');
|
||||||
|
setStorageItem('loras_recursiveSearch', false);
|
||||||
|
setStorageItem('loras_filters', { baseModel: ['SDXL 1.0'], tags: { anime: 'include' } });
|
||||||
|
|
||||||
|
initActiveFiltersSync('loras');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [url, options] = fetchMock.mock.calls[0];
|
||||||
|
expect(url).toBe('/api/lm/loras/active-filters');
|
||||||
|
expect(options.method).toBe('PUT');
|
||||||
|
expect(JSON.parse(options.body)).toEqual({
|
||||||
|
activeFolder: 'SD_XL',
|
||||||
|
recursiveSearch: false,
|
||||||
|
filters: { baseModel: ['SDXL 1.0'], tags: { anime: 'include' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('syncs with debounce when a filter key changes', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
initActiveFiltersSync('loras');
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
setStorageItem('loras_activeFolder', 'anime');
|
||||||
|
setStorageItem('loras_activeFolder', 'anime/sub');
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.activeFolder).toBe('anime/sub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not sync for unrelated storage keys', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
initActiveFiltersSync('loras');
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
setStorageItem('loras_sort', 'name');
|
||||||
|
setStorageItem('theme', 'dark');
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(1000);
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pushes null filters after the filters key is removed', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
setStorageItem('loras_filters', { baseModel: ['Pony'] });
|
||||||
|
initActiveFiltersSync('loras');
|
||||||
|
fetchMock.mockClear();
|
||||||
|
|
||||||
|
removeStorageItem('loras_filters');
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.filters).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes the legacy "null" folder string to null', async () => {
|
||||||
|
localStorage.setItem('lora_manager_loras_activeFolder', 'null');
|
||||||
|
|
||||||
|
await pushActiveFilters('loras');
|
||||||
|
|
||||||
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
||||||
|
expect(body.activeFolder).toBeNull();
|
||||||
|
expect(body.recursiveSearch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns instead of throwing when the request fails', async () => {
|
||||||
|
fetchMock.mockRejectedValue(new Error('network down'));
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await expect(pushActiveFilters('loras')).resolves.toBeUndefined();
|
||||||
|
expect(warnSpy).toHaveBeenCalled();
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('storageHelpers active-filter listener', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
setActiveFiltersListener(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies with the page type for filter keys', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
setActiveFiltersListener(listener);
|
||||||
|
|
||||||
|
setStorageItem('loras_activeFolder', 'a');
|
||||||
|
setStorageItem('checkpoints_recursiveSearch', true);
|
||||||
|
removeStorageItem('embeddings_filters');
|
||||||
|
|
||||||
|
expect(listener.mock.calls.map((call) => call[0])).toEqual([
|
||||||
|
'loras',
|
||||||
|
'checkpoints',
|
||||||
|
'embeddings',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores non-filter keys', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
setActiveFiltersListener(listener);
|
||||||
|
|
||||||
|
setStorageItem('loras_sort', 'name');
|
||||||
|
removeStorageItem('version_info');
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -42,7 +42,6 @@ def sample_recipe_data() -> Dict[str, Any]:
|
|||||||
"created_date": 1700000000.0,
|
"created_date": 1700000000.0,
|
||||||
"modified": 1700000100.0,
|
"modified": 1700000100.0,
|
||||||
"favorite": False,
|
"favorite": False,
|
||||||
"repair_version": 1,
|
|
||||||
"preview_nsfw_level": 0,
|
"preview_nsfw_level": 0,
|
||||||
"loras": [
|
"loras": [
|
||||||
{"hash": "lora1hash", "file_name": "test_lora1", "strength": 0.8},
|
{"hash": "lora1hash", "file_name": "test_lora1", "strength": 0.8},
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from multidict import MultiDict
|
||||||
|
|
||||||
|
from py.routes.handlers.model_handlers import ModelQueryHandler
|
||||||
|
from py.services.active_filters_store import ActiveFiltersStore
|
||||||
|
|
||||||
|
|
||||||
|
class DummyService:
|
||||||
|
model_type = "loras"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def search_relative_paths(self, search, limit, offset, **kwargs):
|
||||||
|
self.calls.append((search, limit, offset, kwargs))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def make_handler(service=None):
|
||||||
|
return ModelQueryHandler(
|
||||||
|
service=service or DummyService(), logger=logging.getLogger(__name__)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_request(query=None, body=None, raise_on_json=False):
|
||||||
|
async def json_body():
|
||||||
|
if raise_on_json:
|
||||||
|
raise ValueError("bad json")
|
||||||
|
return body
|
||||||
|
|
||||||
|
return SimpleNamespace(
|
||||||
|
query=MultiDict(query or {}),
|
||||||
|
json=json_body,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_store():
|
||||||
|
ActiveFiltersStore.reset_instance()
|
||||||
|
yield
|
||||||
|
ActiveFiltersStore.reset_instance()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_active_filters_stores_sanitized_payload():
|
||||||
|
handler = make_handler()
|
||||||
|
response = await handler.update_active_filters(
|
||||||
|
make_request(
|
||||||
|
body={
|
||||||
|
"activeFolder": "SD_XL",
|
||||||
|
"recursiveSearch": False,
|
||||||
|
"filters": {"baseModel": ["SDXL 1.0"], "rogue": "dropped"},
|
||||||
|
"rogue": "dropped",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
stored = ActiveFiltersStore.get_instance().get_filters("loras")
|
||||||
|
assert stored == {
|
||||||
|
"activeFolder": "SD_XL",
|
||||||
|
"recursiveSearch": False,
|
||||||
|
"filters": {"baseModel": ["SDXL 1.0"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_active_filters_rejects_invalid_json():
|
||||||
|
handler = make_handler()
|
||||||
|
response = await handler.update_active_filters(
|
||||||
|
make_request(raise_on_json=True)
|
||||||
|
)
|
||||||
|
assert response.status == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_active_filters_rejects_non_object_body():
|
||||||
|
handler = make_handler()
|
||||||
|
response = await handler.update_active_filters(make_request(body=["not", "dict"]))
|
||||||
|
assert response.status == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_active_filters_returns_stored_payload():
|
||||||
|
ActiveFiltersStore.get_instance().set_filters(
|
||||||
|
"loras", {"activeFolder": "anime", "recursiveSearch": True, "filters": None}
|
||||||
|
)
|
||||||
|
handler = make_handler()
|
||||||
|
response = await handler.get_active_filters(make_request())
|
||||||
|
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["filters"]["activeFolder"] == "anime"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_active_filters_returns_null_when_unset():
|
||||||
|
handler = make_handler()
|
||||||
|
response = await handler.get_active_filters(make_request())
|
||||||
|
|
||||||
|
payload = json.loads(response.text)
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["filters"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_relative_paths_injects_stored_active_filters():
|
||||||
|
ActiveFiltersStore.get_instance().set_filters(
|
||||||
|
"loras",
|
||||||
|
{
|
||||||
|
"activeFolder": "SD_XL",
|
||||||
|
"recursiveSearch": False,
|
||||||
|
"filters": {
|
||||||
|
"baseModel": ["SDXL 1.0"],
|
||||||
|
"tags": {"anime": "include"},
|
||||||
|
"tagLogic": "all",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = DummyService()
|
||||||
|
handler = make_handler(service)
|
||||||
|
|
||||||
|
response = await handler.get_relative_paths(
|
||||||
|
make_request({"search": "cartoon", "use_active_filters": "true"})
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
_, _, _, kwargs = service.calls[0]
|
||||||
|
assert kwargs["folder"] == "SD_XL"
|
||||||
|
assert kwargs["recursive"] is False
|
||||||
|
assert kwargs["base_models"] == ["SDXL 1.0"]
|
||||||
|
assert kwargs["tags"] == {"anime": "include"}
|
||||||
|
assert kwargs["tag_logic"] == "all"
|
||||||
|
assert kwargs["apply_filters"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_relative_paths_explicit_params_take_precedence():
|
||||||
|
ActiveFiltersStore.get_instance().set_filters(
|
||||||
|
"loras",
|
||||||
|
{
|
||||||
|
"activeFolder": "SD_XL",
|
||||||
|
"recursiveSearch": True,
|
||||||
|
"filters": {"baseModel": ["SDXL 1.0"]},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
service = DummyService()
|
||||||
|
handler = make_handler(service)
|
||||||
|
|
||||||
|
await handler.get_relative_paths(
|
||||||
|
make_request(
|
||||||
|
{
|
||||||
|
"search": "cartoon",
|
||||||
|
"use_active_filters": "true",
|
||||||
|
"folder": "pony",
|
||||||
|
"base_model": "Pony",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, _, kwargs = service.calls[0]
|
||||||
|
assert kwargs["folder"] == "pony"
|
||||||
|
assert kwargs["base_models"] == ["Pony"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_relative_paths_empty_store_still_runs_filter_pipeline():
|
||||||
|
service = DummyService()
|
||||||
|
handler = make_handler(service)
|
||||||
|
|
||||||
|
await handler.get_relative_paths(
|
||||||
|
make_request({"search": "cartoon", "use_active_filters": "true"})
|
||||||
|
)
|
||||||
|
|
||||||
|
_, _, _, kwargs = service.calls[0]
|
||||||
|
assert kwargs["apply_filters"] is True
|
||||||
|
assert kwargs["folder"] is None
|
||||||
|
assert kwargs["base_models"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_relative_paths_without_flag_ignores_store():
|
||||||
|
ActiveFiltersStore.get_instance().set_filters(
|
||||||
|
"loras", {"activeFolder": "SD_XL", "recursiveSearch": True, "filters": None}
|
||||||
|
)
|
||||||
|
service = DummyService()
|
||||||
|
handler = make_handler(service)
|
||||||
|
|
||||||
|
await handler.get_relative_paths(make_request({"search": "cartoon"}))
|
||||||
|
|
||||||
|
_, _, _, kwargs = service.calls[0]
|
||||||
|
assert kwargs["folder"] is None
|
||||||
|
assert kwargs["apply_filters"] is False
|
||||||
@@ -226,15 +226,6 @@ _REMATCH_ROUTE_DEFS = {
|
|||||||
("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
|
("GET", "/api/lm/recipes/rematch-progress", "get_rematch_progress"),
|
||||||
}
|
}
|
||||||
|
|
||||||
_REPAIR_ROUTE_DEFS = {
|
|
||||||
("POST", "/api/lm/recipes/repair", "repair_recipes"),
|
|
||||||
("POST", "/api/lm/recipes/cancel-repair", "cancel_repair"),
|
|
||||||
("POST", "/api/lm/recipe/{recipe_id}/repair", "repair_recipe"),
|
|
||||||
("POST", "/api/lm/recipes/repair-bulk", "repair_recipes_bulk"),
|
|
||||||
("GET", "/api/lm/recipes/repair-progress", "get_repair_progress"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_rematch_route_definitions_registered():
|
def test_rematch_route_definitions_registered():
|
||||||
registered = {
|
registered = {
|
||||||
(d.method, d.path, d.handler_name)
|
(d.method, d.path, d.handler_name)
|
||||||
@@ -243,14 +234,6 @@ def test_rematch_route_definitions_registered():
|
|||||||
assert _REMATCH_ROUTE_DEFS <= registered
|
assert _REMATCH_ROUTE_DEFS <= registered
|
||||||
|
|
||||||
|
|
||||||
def test_repair_route_definitions_still_registered():
|
|
||||||
registered = {
|
|
||||||
(d.method, d.path, d.handler_name)
|
|
||||||
for d in recipe_route_registrar.ROUTE_DEFINITIONS
|
|
||||||
}
|
|
||||||
assert _REPAIR_ROUTE_DEFS <= registered
|
|
||||||
|
|
||||||
|
|
||||||
def test_rematch_handler_names_resolve_in_to_route_mapping(monkeypatch: pytest.MonkeyPatch):
|
def test_rematch_handler_names_resolve_in_to_route_mapping(monkeypatch: pytest.MonkeyPatch):
|
||||||
"""Oracle R1-F4: register_routes KeyErrors at startup if to_route_mapping
|
"""Oracle R1-F4: register_routes KeyErrors at startup if to_route_mapping
|
||||||
lacks any name present in ROUTE_DEFINITIONS, so the real handler set must
|
lacks any name present in ROUTE_DEFINITIONS, so the real handler set must
|
||||||
|
|||||||
@@ -1874,20 +1874,14 @@ async def test_create_from_example_does_not_recompute_stored_autov3(
|
|||||||
def _clean_recipe_run_progress_state():
|
def _clean_recipe_run_progress_state():
|
||||||
"""Keep the shared WS manager run-state isolated between tests."""
|
"""Keep the shared WS manager run-state isolated between tests."""
|
||||||
ws_manager._recipe_rematch_progress = None
|
ws_manager._recipe_rematch_progress = None
|
||||||
ws_manager._recipe_repair_progress = None
|
|
||||||
yield
|
yield
|
||||||
ws_manager._recipe_rematch_progress = None
|
ws_manager._recipe_rematch_progress = None
|
||||||
ws_manager._recipe_repair_progress = None
|
|
||||||
|
|
||||||
|
|
||||||
def _set_rematch_running(status: str = "processing") -> None:
|
def _set_rematch_running(status: str = "processing") -> None:
|
||||||
ws_manager._recipe_rematch_progress = {"status": status}
|
ws_manager._recipe_rematch_progress = {"status": status}
|
||||||
|
|
||||||
|
|
||||||
def _set_repair_running(status: str = "processing") -> None:
|
|
||||||
ws_manager._recipe_repair_progress = {"status": status}
|
|
||||||
|
|
||||||
|
|
||||||
async def test_rematch_recipes_starts_background_run(monkeypatch, tmp_path: Path) -> None:
|
async def test_rematch_recipes_starts_background_run(monkeypatch, tmp_path: Path) -> None:
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
response = await harness.client.post("/api/lm/recipes/rematch")
|
response = await harness.client.post("/api/lm/recipes/rematch")
|
||||||
@@ -1911,15 +1905,6 @@ async def test_rematch_recipes_409_when_rematch_running(monkeypatch, tmp_path: P
|
|||||||
assert "already in progress" in payload["error"].lower()
|
assert "already in progress" in payload["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
async def test_rematch_recipes_409_when_repair_running(monkeypatch, tmp_path: Path) -> None:
|
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
|
||||||
_set_repair_running()
|
|
||||||
response = await harness.client.post("/api/lm/recipes/rematch")
|
|
||||||
payload = await response.json()
|
|
||||||
assert response.status == 409
|
|
||||||
assert payload["success"] is False
|
|
||||||
assert "already in progress" in payload["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_rematch_recipe_409_when_rematch_running(monkeypatch, tmp_path: Path) -> None:
|
async def test_rematch_recipe_409_when_rematch_running(monkeypatch, tmp_path: Path) -> None:
|
||||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
@@ -2222,12 +2207,94 @@ async def test_reimport_without_source_path_falls_back_to_recipe_file(
|
|||||||
# The already-optimized preview image must be stored verbatim.
|
# The already-optimized preview image must be stored verbatim.
|
||||||
assert harness.persistence.save_calls[-1]["skip_optimize"] is True
|
assert harness.persistence.save_calls[-1]["skip_optimize"] is True
|
||||||
assert harness.persistence.save_calls[-1]["image_bytes"] == b"fake-image"
|
assert harness.persistence.save_calls[-1]["image_bytes"] == b"fake-image"
|
||||||
|
# The fallback source is the recipe's own previous preview, which gets
|
||||||
|
# deleted with the old recipe — it must not be recorded as source_path.
|
||||||
|
assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == ""
|
||||||
# User edits (title, tags) are carried over to the new recipe.
|
# User edits (title, tags) are carried over to the new recipe.
|
||||||
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec"
|
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec"
|
||||||
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
|
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
|
||||||
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
|
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reimport_with_dangling_source_path_falls_back_to_recipe_file(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A source_path pointing to a deleted file (left by an earlier re-import)
|
||||||
|
must not block re-import: fall back to the recipe's own saved image and
|
||||||
|
clear the dangling source_path."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
recipe_file = harness.tmp_dir / "recipes" / "rec3.webp"
|
||||||
|
recipe_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
recipe_file.write_bytes(b"fake-image")
|
||||||
|
|
||||||
|
harness.scanner.recipes["rec3"] = {
|
||||||
|
"id": "rec3",
|
||||||
|
"title": "Dangling source",
|
||||||
|
"file_path": str(recipe_file),
|
||||||
|
"tags": [],
|
||||||
|
# Dangling local path: the file no longer exists.
|
||||||
|
"source_path": str(harness.tmp_dir / "recipes" / "deleted.webp"),
|
||||||
|
}
|
||||||
|
harness.analysis.result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "new-rec-3", "loras": []},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
harness.persistence.save_result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "new-rec-3"}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await harness.client.post("/api/lm/recipe/rec3/reimport")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["recipe_id"] == "new-rec-3"
|
||||||
|
assert harness.analysis.local_calls == [str(recipe_file)]
|
||||||
|
assert harness.persistence.delete_calls == ["rec3"]
|
||||||
|
# The dangling path is not carried over to the new recipe.
|
||||||
|
assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reimport_with_accessible_local_source_keeps_source_path(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""When the recorded source_path is an existing external file, it remains
|
||||||
|
the source of truth and stays recorded on the new recipe."""
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
source_file = harness.tmp_dir / "imports" / "original.png"
|
||||||
|
source_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
source_file.write_bytes(b"original-image")
|
||||||
|
recipe_file = harness.tmp_dir / "recipes" / "rec4.webp"
|
||||||
|
recipe_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
recipe_file.write_bytes(b"fake-image")
|
||||||
|
|
||||||
|
harness.scanner.recipes["rec4"] = {
|
||||||
|
"id": "rec4",
|
||||||
|
"title": "External source",
|
||||||
|
"file_path": str(recipe_file),
|
||||||
|
"tags": [],
|
||||||
|
"source_path": str(source_file),
|
||||||
|
}
|
||||||
|
harness.analysis.result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "new-rec-4", "loras": []},
|
||||||
|
status=200,
|
||||||
|
)
|
||||||
|
harness.persistence.save_result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "new-rec-4"}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await harness.client.post("/api/lm/recipe/rec4/reimport")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
# The external source file is re-parsed, not the recipe preview.
|
||||||
|
assert harness.analysis.local_calls == [str(source_file)]
|
||||||
|
assert harness.persistence.save_calls[-1]["metadata"]["source_path"] == str(
|
||||||
|
source_file
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def test_reimport_without_any_source_returns_400(
|
async def test_reimport_without_any_source_returns_400(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -2274,3 +2341,147 @@ async def test_get_recipe_detail_includes_recipe_json_path(
|
|||||||
assert response.status == 200
|
assert response.status == 200
|
||||||
payload = await response.json()
|
payload = await response.json()
|
||||||
assert "recipe_json_path" not in payload
|
assert "recipe_json_path" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reimport_with_extension_payload_uses_payload_path(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""A re-import carrying the companion extension's metadata payload must
|
||||||
|
use the payload-based import engine (caller-supplied LoRAs) instead of
|
||||||
|
the legacy CivitAI image URL import, and report loras_count."""
|
||||||
|
provider_calls: list[str | int] = []
|
||||||
|
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, model_version_id):
|
||||||
|
provider_calls.append(model_version_id)
|
||||||
|
return {}, None
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.enrichment.get_default_metadata_provider",
|
||||||
|
fake_get_default_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
old_file = harness.tmp_dir / "recipes" / "sub" / "rec-ext.webp"
|
||||||
|
harness.scanner.recipes["rec-ext"] = {
|
||||||
|
"id": "rec-ext",
|
||||||
|
"title": "Old title",
|
||||||
|
"file_path": str(old_file),
|
||||||
|
"tags": ["tag1"],
|
||||||
|
"source_path": "https://civitai.com/images/12345",
|
||||||
|
}
|
||||||
|
harness.civitai.image_info["12345"] = {
|
||||||
|
"id": 12345,
|
||||||
|
"url": "https://image.civitai.com/x/y/original=true/pic.png",
|
||||||
|
"type": "image",
|
||||||
|
}
|
||||||
|
harness.persistence.save_result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "new-rec-ext"}, status=200
|
||||||
|
)
|
||||||
|
# The freshly saved recipe as the scanner would see it (for loras_count).
|
||||||
|
harness.scanner.recipes["new-rec-ext"] = {
|
||||||
|
"id": "new-rec-ext",
|
||||||
|
"loras": [{"file_name": "Painterly"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = [
|
||||||
|
{
|
||||||
|
"type": "lora",
|
||||||
|
"modelId": 20,
|
||||||
|
"modelVersionId": 44,
|
||||||
|
"modelName": "Painterly",
|
||||||
|
"modelVersionName": "v2",
|
||||||
|
"weight": 0.5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
# The extension only issues GET requests (per its API convention).
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipe/rec-ext/reimport",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/12345",
|
||||||
|
"name": "Extension Recipe",
|
||||||
|
"resources": json.dumps(resources),
|
||||||
|
"gen_params": json.dumps({"prompt": "from extension"}),
|
||||||
|
"base_model": "Flux",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["old_recipe_id"] == "rec-ext"
|
||||||
|
assert payload["recipe_id"] == "new-rec-ext"
|
||||||
|
assert payload["loras_count"] == 1
|
||||||
|
|
||||||
|
save_call = harness.persistence.save_calls[-1]
|
||||||
|
# Caller-supplied payload data wins: name, LoRAs, gen params.
|
||||||
|
assert save_call["name"] == "Extension Recipe"
|
||||||
|
assert save_call["metadata"]["loras"][0]["file_name"] == "Painterly"
|
||||||
|
assert save_call["metadata"]["loras"][0]["weight"] == 0.5
|
||||||
|
assert save_call["metadata"]["gen_params"]["prompt"] == "from extension"
|
||||||
|
# Reimport semantics: original source_path and folder are preserved.
|
||||||
|
assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345"
|
||||||
|
assert save_call["target_dir"] == str(harness.tmp_dir / "recipes" / "sub")
|
||||||
|
# The old recipe is deleted and user edits carried over.
|
||||||
|
assert harness.persistence.delete_calls == ["rec-ext"]
|
||||||
|
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec-ext"
|
||||||
|
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
|
||||||
|
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reimport_with_malformed_payload_falls_back_to_legacy(
|
||||||
|
monkeypatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Malformed resources JSON must be treated as "no payload": the legacy
|
||||||
|
source-URL import runs and the request still succeeds."""
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.enrichment.get_default_metadata_provider",
|
||||||
|
fake_get_default_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.scanner.recipes["rec-bad"] = {
|
||||||
|
"id": "rec-bad",
|
||||||
|
"title": "Broken payload",
|
||||||
|
"file_path": str(harness.tmp_dir / "recipes" / "rec-bad.webp"),
|
||||||
|
"tags": [],
|
||||||
|
"source_path": "https://civitai.com/images/12345",
|
||||||
|
}
|
||||||
|
harness.civitai.image_info["12345"] = {
|
||||||
|
"id": 12345,
|
||||||
|
"url": "https://image.civitai.com/x/y/original=true/pic.png",
|
||||||
|
"type": "image",
|
||||||
|
}
|
||||||
|
harness.persistence.save_result = SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": "legacy-new"}, status=200
|
||||||
|
)
|
||||||
|
harness.scanner.recipes["legacy-new"] = {"id": "legacy-new", "loras": []}
|
||||||
|
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipe/rec-bad/reimport",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/12345",
|
||||||
|
"name": "Ignored Name",
|
||||||
|
"resources": "{not valid json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["recipe_id"] == "legacy-new"
|
||||||
|
assert payload["loras_count"] == 0
|
||||||
|
|
||||||
|
save_call = harness.persistence.save_calls[-1]
|
||||||
|
# Legacy URL path: the payload name is ignored and the title is
|
||||||
|
# derived from the (empty) metadata, and no caller LoRAs are used.
|
||||||
|
assert save_call["name"] == "Civitai Image 12345"
|
||||||
|
assert save_call["metadata"]["loras"] == []
|
||||||
|
assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345"
|
||||||
|
assert harness.persistence.delete_calls == ["rec-bad"]
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.active_filters_store import (
|
||||||
|
ActiveFiltersStore,
|
||||||
|
active_filters_to_query_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_store():
|
||||||
|
ActiveFiltersStore.reset_instance()
|
||||||
|
yield
|
||||||
|
ActiveFiltersStore.reset_instance()
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_roundtrip():
|
||||||
|
store = ActiveFiltersStore.get_instance()
|
||||||
|
payload = {
|
||||||
|
"activeFolder": "SD_XL",
|
||||||
|
"recursiveSearch": False,
|
||||||
|
"filters": {"baseModel": ["SDXL 1.0"], "tags": {"anime": "include"}},
|
||||||
|
}
|
||||||
|
store.set_filters("loras", payload)
|
||||||
|
|
||||||
|
assert store.get_filters("loras") == payload
|
||||||
|
assert store.get_filters("checkpoints") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_sanitizes_payload():
|
||||||
|
store = ActiveFiltersStore.get_instance()
|
||||||
|
store.set_filters(
|
||||||
|
"loras",
|
||||||
|
{
|
||||||
|
"activeFolder": "anime",
|
||||||
|
"recursiveSearch": True,
|
||||||
|
"filters": {"baseModel": [], "unexpected": "dropped"},
|
||||||
|
"extra": "dropped",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
stored = store.get_filters("loras")
|
||||||
|
assert stored == {
|
||||||
|
"activeFolder": "anime",
|
||||||
|
"recursiveSearch": True,
|
||||||
|
"filters": {"baseModel": []},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_non_dict_filters_become_none():
|
||||||
|
store = ActiveFiltersStore.get_instance()
|
||||||
|
store.set_filters("loras", {"activeFolder": None, "filters": "garbage"})
|
||||||
|
|
||||||
|
assert store.get_filters("loras")["filters"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_clear():
|
||||||
|
store = ActiveFiltersStore.get_instance()
|
||||||
|
store.set_filters("loras", {"activeFolder": "x"})
|
||||||
|
store.clear("loras")
|
||||||
|
|
||||||
|
assert store.get_filters("loras") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_empty_payload():
|
||||||
|
assert active_filters_to_query_kwargs(None) == {}
|
||||||
|
assert active_filters_to_query_kwargs({}) == {}
|
||||||
|
assert active_filters_to_query_kwargs({"activeFolder": None}) == {"recursive": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_folder():
|
||||||
|
assert active_filters_to_query_kwargs(
|
||||||
|
{"activeFolder": "SD_XL", "recursiveSearch": True}
|
||||||
|
) == {"folder": "SD_XL", "recursive": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_root_folder_non_recursive():
|
||||||
|
# Root folder with recursion disabled matches only root-level files
|
||||||
|
assert active_filters_to_query_kwargs(
|
||||||
|
{"activeFolder": None, "recursiveSearch": False}
|
||||||
|
) == {"folder": "", "recursive": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_legacy_null_string_folder():
|
||||||
|
assert active_filters_to_query_kwargs(
|
||||||
|
{"activeFolder": "null", "recursiveSearch": True}
|
||||||
|
) == {"recursive": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_full_filters():
|
||||||
|
kwargs = active_filters_to_query_kwargs(
|
||||||
|
{
|
||||||
|
"activeFolder": "anime",
|
||||||
|
"recursiveSearch": True,
|
||||||
|
"filters": {
|
||||||
|
"baseModel": ["SDXL 1.0", "Pony"],
|
||||||
|
"tags": {"anime": "include", "3d": "exclude", "junk": "ignored"},
|
||||||
|
"autoTags": {"cute": "include"},
|
||||||
|
"modelTypes": ["LoRA"],
|
||||||
|
"tagLogic": "all",
|
||||||
|
"license": {"noCredit": "include", "allowSelling": "exclude"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert kwargs == {
|
||||||
|
"folder": "anime",
|
||||||
|
"recursive": True,
|
||||||
|
"base_models": ["SDXL 1.0", "Pony"],
|
||||||
|
"tags": {"anime": "include", "3d": "exclude"},
|
||||||
|
"auto_tags": {"cute": "include"},
|
||||||
|
"model_types": ["LoRA"],
|
||||||
|
"tag_logic": "all",
|
||||||
|
"credit_required": False,
|
||||||
|
"allow_selling_generated_content": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mapping_license_exclude_variants():
|
||||||
|
kwargs = active_filters_to_query_kwargs(
|
||||||
|
{
|
||||||
|
"filters": {
|
||||||
|
"license": {"noCredit": "exclude", "allowSelling": "include"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert kwargs["credit_required"] is True
|
||||||
|
assert kwargs["allow_selling_generated_content"] is True
|
||||||
@@ -818,3 +818,44 @@ async def test_get_model_by_hash_rejects_empty_placeholder_without_request(downl
|
|||||||
assert result is None
|
assert result is None
|
||||||
assert error == "Model not found"
|
assert error == "Model not found"
|
||||||
assert requested == []
|
assert requested == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_version_file_mini_returns_payload(downloader):
|
||||||
|
"""The mini endpoint returns the raw stored filename (#1100)."""
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
assert method == "GET"
|
||||||
|
assert url.endswith("/model-versions/mini/3284136")
|
||||||
|
assert kwargs.get("params") == {"modelFileId": 3168412}
|
||||||
|
assert use_auth is True
|
||||||
|
return True, {"fileName": "CyberRealistic_zit_v8.0_bf16.safetensors"}
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
result = await client.get_version_file_mini(3284136, 3168412)
|
||||||
|
|
||||||
|
assert result == {"fileName": "CyberRealistic_zit_v8.0_bf16.safetensors"}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_version_file_mini_returns_none_on_failure(downloader):
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
return False, "Model file 2 not found in version 1"
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
assert await client.get_version_file_mini(1, 2) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_get_version_file_mini_propagates_rate_limit(downloader):
|
||||||
|
client = await CivitaiClient.get_instance()
|
||||||
|
|
||||||
|
async def fake_make_request(method, url, use_auth=True, **kwargs):
|
||||||
|
return False, RateLimitError("limited", retry_after=1.0)
|
||||||
|
|
||||||
|
downloader.make_request = fake_make_request
|
||||||
|
|
||||||
|
with pytest.raises(RateLimitError):
|
||||||
|
await client.get_version_file_mini(1, 2)
|
||||||
|
|||||||
@@ -2098,3 +2098,174 @@ async def test_discard_cleared_downloads_stops_tracking_and_preserves_files(
|
|||||||
# Partial files are preserved for a future resume from disk.
|
# Partial files are preserved for a future resume from disk.
|
||||||
assert save_path.exists()
|
assert save_path.exists()
|
||||||
assert control_path.exists()
|
assert control_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_uses_raw_file_name_from_mini_endpoint(
|
||||||
|
monkeypatch, scanners, metadata_provider, tmp_path
|
||||||
|
):
|
||||||
|
"""#1100: when the REST name is rewritten ("{model}_{version}"), the raw
|
||||||
|
stored filename from the mini endpoint wins for the on-disk name."""
|
||||||
|
manager = DownloadManager()
|
||||||
|
get_settings_manager().settings["default_unet_root"] = str(tmp_path / "unet")
|
||||||
|
metadata_provider.payload = {
|
||||||
|
"id": 3284136,
|
||||||
|
"model": {"type": "Checkpoint", "tags": ["realistic"]},
|
||||||
|
"baseModel": "ZImageTurbo",
|
||||||
|
"creator": {"username": "Author"},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": 3168412,
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"name": "cyberrealisticZImage_v80.safetensors",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/models/3284136?fileId=3168412",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
metadata_provider.get_version_file_mini = AsyncMock(
|
||||||
|
return_value={"fileName": "CyberRealistic_zit_v8.0_bf16.safetensors"}
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_execute_download(self, **kwargs):
|
||||||
|
captured["download_urls"] = kwargs["download_urls"]
|
||||||
|
captured["file_path"] = kwargs["metadata"].file_path
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=3284136,
|
||||||
|
save_dir=str(tmp_path),
|
||||||
|
use_default_paths=True,
|
||||||
|
progress_callback=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True, result
|
||||||
|
metadata_provider.get_version_file_mini.assert_awaited_once_with(3284136, 3168412)
|
||||||
|
assert captured["file_path"].endswith("CyberRealistic_zit_v8.0_bf16.safetensors")
|
||||||
|
# The file's own pinned downloadUrl is untouched.
|
||||||
|
assert captured["download_urls"] == [
|
||||||
|
"https://civitai.com/api/download/models/3284136?fileId=3168412"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_falls_back_to_rest_name_when_mini_fails(
|
||||||
|
monkeypatch, scanners, metadata_provider, tmp_path
|
||||||
|
):
|
||||||
|
"""A failed/absent mini lookup must keep the previous behavior."""
|
||||||
|
manager = DownloadManager()
|
||||||
|
metadata_provider.payload = {
|
||||||
|
"id": 42,
|
||||||
|
"model": {"type": "Checkpoint", "tags": ["fantasy"]},
|
||||||
|
"baseModel": "BaseModel",
|
||||||
|
"creator": {"username": "Author"},
|
||||||
|
"files": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"type": "Model",
|
||||||
|
"primary": True,
|
||||||
|
"name": "rewritten_v10.safetensors",
|
||||||
|
"downloadUrl": "https://example.invalid/file.safetensors",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
metadata_provider.get_version_file_mini = AsyncMock(return_value=None)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_execute_download(self, **kwargs):
|
||||||
|
captured["file_path"] = kwargs["metadata"].file_path
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=42,
|
||||||
|
save_dir=str(tmp_path),
|
||||||
|
use_default_paths=True,
|
||||||
|
progress_callback=None,
|
||||||
|
source=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert captured["file_path"].endswith("rewritten_v10.safetensors")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_download_skips_mini_lookup_for_civarchive_source(
|
||||||
|
monkeypatch, scanners, metadata_provider, tmp_path
|
||||||
|
):
|
||||||
|
"""CivArchive already serves raw stored names — no mini call."""
|
||||||
|
manager = DownloadManager()
|
||||||
|
mini_mock = AsyncMock(return_value={"fileName": "should_not_be_used.safetensors"})
|
||||||
|
metadata_provider.get_version_file_mini = mini_mock
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
download_manager,
|
||||||
|
"get_metadata_provider",
|
||||||
|
AsyncMock(return_value=metadata_provider),
|
||||||
|
)
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_execute_download(self, **kwargs):
|
||||||
|
captured["file_path"] = kwargs["metadata"].file_path
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
DownloadManager, "_execute_download", fake_execute_download, raising=False
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await manager.download_from_civitai(
|
||||||
|
model_version_id=99,
|
||||||
|
save_dir=str(tmp_path),
|
||||||
|
use_default_paths=True,
|
||||||
|
progress_callback=None,
|
||||||
|
source="civarchive",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
mini_mock.assert_not_called()
|
||||||
|
assert captured["file_path"].endswith("file.safetensors")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_raw_file_name_edge_cases():
|
||||||
|
"""_fetch_raw_file_name never raises and strips path components."""
|
||||||
|
manager = DownloadManager()
|
||||||
|
|
||||||
|
provider = SimpleNamespace()
|
||||||
|
|
||||||
|
# Missing version id / file id short-circuit before any provider call.
|
||||||
|
provider.get_version_file_mini = AsyncMock()
|
||||||
|
assert await manager._fetch_raw_file_name(provider, None, 1) is None
|
||||||
|
assert await manager._fetch_raw_file_name(provider, 1, None) is None
|
||||||
|
provider.get_version_file_mini.assert_not_called()
|
||||||
|
|
||||||
|
# Provider without the method (older mocks / non-CivitAI providers).
|
||||||
|
assert await manager._fetch_raw_file_name(object(), 1, 2) is None
|
||||||
|
|
||||||
|
# Non-dict payload, empty fileName.
|
||||||
|
provider.get_version_file_mini = AsyncMock(return_value="oops")
|
||||||
|
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
|
||||||
|
provider.get_version_file_mini = AsyncMock(return_value={"fileName": " "})
|
||||||
|
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
|
||||||
|
|
||||||
|
# Path components are stripped defensively.
|
||||||
|
provider.get_version_file_mini = AsyncMock(
|
||||||
|
return_value={"fileName": "../evil/model.safetensors"}
|
||||||
|
)
|
||||||
|
assert await manager._fetch_raw_file_name(provider, 1, 2) == "model.safetensors"
|
||||||
|
|
||||||
|
# Provider exceptions degrade to None.
|
||||||
|
provider.get_version_file_mini = AsyncMock(side_effect=RuntimeError("boom"))
|
||||||
|
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ from unittest import mock
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from py.services.llm_service import LLMService
|
from py.services import llm_service as llm_module
|
||||||
from py.services.errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
|
from py.services.errors import LLMNotConfiguredError, LLMRateLimitError, LLMResponseError
|
||||||
|
from py.services.llm_service import LLMService, fetch_ollama_models
|
||||||
|
|
||||||
|
|
||||||
class MockSettings:
|
class MockSettings:
|
||||||
@@ -314,3 +315,61 @@ class TestLLMServiceChatCompletionJson:
|
|||||||
system_prompt="test",
|
system_prompt="test",
|
||||||
user_prompt="test",
|
user_prompt="test",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockGetSession:
|
||||||
|
"""Minimal aiohttp session mock supporting get() for catalog tests."""
|
||||||
|
|
||||||
|
def __init__(self, response):
|
||||||
|
self._response = response
|
||||||
|
|
||||||
|
def get(self, url):
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, *args):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CorruptJsonResponse(MockResponse):
|
||||||
|
"""Response whose body cannot be decoded as UTF-8 (like the issue's 0x9a byte)."""
|
||||||
|
|
||||||
|
async def json(self):
|
||||||
|
raise UnicodeDecodeError("utf-8", b"\x9a", 0, 1, "invalid start byte")
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelCatalog:
|
||||||
|
"""Tests for _load_model_catalog / fetch_ollama_models error handling."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_catalog_cache(self):
|
||||||
|
"""Reset the module-level catalog cache around each test."""
|
||||||
|
llm_module._catalog_cache = None
|
||||||
|
llm_module._model_output_limits = {}
|
||||||
|
yield
|
||||||
|
llm_module._catalog_cache = None
|
||||||
|
llm_module._model_output_limits = {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_model_catalog_falls_back_on_unicode_decode_error(self):
|
||||||
|
"""Corrupted catalog body must not raise — fall back to an empty dict."""
|
||||||
|
response = CorruptJsonResponse(200)
|
||||||
|
session = MockGetSession(response)
|
||||||
|
|
||||||
|
with mock.patch("aiohttp.ClientSession", return_value=session):
|
||||||
|
catalog = await llm_module._load_model_catalog()
|
||||||
|
|
||||||
|
assert catalog == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_ollama_models_falls_back_on_unicode_decode_error(self):
|
||||||
|
"""Corrupted Ollama response must not raise — fall back to an empty list."""
|
||||||
|
response = CorruptJsonResponse(200)
|
||||||
|
session = MockGetSession(response)
|
||||||
|
|
||||||
|
with mock.patch("aiohttp.ClientSession", return_value=session):
|
||||||
|
models = await fetch_ollama_models("http://localhost:11434/v1")
|
||||||
|
|
||||||
|
assert models == []
|
||||||
|
|||||||
@@ -207,3 +207,62 @@ async def test_retry_helper_retries_normally_for_small_retry_after(monkeypatch):
|
|||||||
result, _ = await helper.run("test", succeeding)
|
result, _ = await helper.run("test", succeeding)
|
||||||
assert result == {"ok": True}
|
assert result == {"ok": True}
|
||||||
assert calls == 2 # Retried once (small retry_after)
|
assert calls == 2 # Retried once (small retry_after)
|
||||||
|
|
||||||
|
|
||||||
|
class MiniCapableProvider(ModelMetadataProvider):
|
||||||
|
"""Provider that serves raw file names via the mini endpoint (#1100)."""
|
||||||
|
|
||||||
|
def __init__(self, payload=None) -> None:
|
||||||
|
self.payload = payload
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
async def get_model_by_hash(self, model_hash: str):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def get_model_versions(self, model_id: str):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version(self, model_id=None, version_id=None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_model_version_info(self, version_id: str):
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
async def get_user_models(self, username: str, cursor=None):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_version_file_mini(self, version_id: int, file_id: int):
|
||||||
|
self.calls.append((version_id, file_id))
|
||||||
|
return self.payload
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_base_provider_get_version_file_mini_defaults_to_none():
|
||||||
|
provider = TrackingProvider()
|
||||||
|
assert await provider.get_version_file_mini(1, 2) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fallback_get_version_file_mini_returns_first_hit():
|
||||||
|
primary = TrackingProvider() # base default: None
|
||||||
|
secondary = MiniCapableProvider({"fileName": "raw.safetensors"})
|
||||||
|
|
||||||
|
fallback = FallbackMetadataProvider(
|
||||||
|
[("primary", primary), ("secondary", secondary)],
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fallback.get_version_file_mini(10, 20)
|
||||||
|
|
||||||
|
assert result == {"fileName": "raw.safetensors"}
|
||||||
|
assert secondary.calls == [(10, 20)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rate_limit_retrying_provider_delegates_get_version_file_mini():
|
||||||
|
inner = MiniCapableProvider({"fileName": "raw.safetensors"})
|
||||||
|
wrapper = RateLimitRetryingProvider(inner, label="inner")
|
||||||
|
|
||||||
|
result = await wrapper.get_version_file_mini(10, 20)
|
||||||
|
|
||||||
|
assert result == {"fileName": "raw.safetensors"}
|
||||||
|
assert inner.calls == [(10, 20)]
|
||||||
|
|||||||
@@ -30,10 +30,14 @@ from py.utils.models import BaseModelMetadata
|
|||||||
class RecordingWebSocketManager:
|
class RecordingWebSocketManager:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.payloads: List[Dict[str, Any]] = []
|
self.payloads: List[Dict[str, Any]] = []
|
||||||
|
self.broadcasts: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
async def broadcast_init_progress(self, payload: Dict[str, Any]) -> None:
|
async def broadcast_init_progress(self, payload: Dict[str, Any]) -> None:
|
||||||
self.payloads.append(payload)
|
self.payloads.append(payload)
|
||||||
|
|
||||||
|
async def broadcast(self, payload: Dict[str, Any]) -> None:
|
||||||
|
self.broadcasts.append(payload)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_path(path: Path) -> str:
|
def _normalize_path(path: Path) -> str:
|
||||||
return str(path).replace(os.sep, "/")
|
return str(path).replace(os.sep, "/")
|
||||||
@@ -1395,3 +1399,185 @@ async def test_get_all_folders_invalidated_after_move(tmp_path: Path):
|
|||||||
assert "new" in all_folders
|
assert "new" in all_folders
|
||||||
assert "new/deep" in all_folders
|
assert "new/deep" in all_folders
|
||||||
assert set(cache.folders) <= set(all_folders)
|
assert set(cache.folders) <= set(all_folders)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_cache_broadcasts_scan_progress(tmp_path: Path, monkeypatch):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
|
||||||
|
ws_stub = RecordingWebSocketManager()
|
||||||
|
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages, "expected scan_progress broadcasts"
|
||||||
|
|
||||||
|
started = messages[0]
|
||||||
|
assert started["type"] == "scan_progress"
|
||||||
|
assert started["status"] == "started"
|
||||||
|
assert started["stage"] == "scan_folders"
|
||||||
|
assert started["progress"] == 0
|
||||||
|
assert started["model_type"] == "dummy"
|
||||||
|
assert started["pageType"] == "dummy"
|
||||||
|
assert started["full_rebuild"] is True
|
||||||
|
|
||||||
|
count_messages = [m for m in messages if m["stage"] == "count_models"]
|
||||||
|
assert count_messages and count_messages[0]["total"] == 3
|
||||||
|
|
||||||
|
process_messages = [
|
||||||
|
m for m in messages
|
||||||
|
if m["stage"] == "process_models" and m["status"] == "processing"
|
||||||
|
]
|
||||||
|
assert process_messages, "expected at least one process_models update"
|
||||||
|
final_process = process_messages[-1]
|
||||||
|
assert final_process["processed"] == 3
|
||||||
|
assert final_process["total"] == 3
|
||||||
|
assert final_process["current_name"].endswith(".txt")
|
||||||
|
for message in process_messages:
|
||||||
|
assert 0 < message["progress"] <= 99
|
||||||
|
|
||||||
|
stages = [m["stage"] for m in messages]
|
||||||
|
assert "finalizing" in stages
|
||||||
|
completed = messages[-1]
|
||||||
|
assert completed["status"] == "completed"
|
||||||
|
assert completed["progress"] == 100
|
||||||
|
assert completed["elapsed_seconds"] >= 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_cache_broadcasts_cancelled(tmp_path: Path, monkeypatch):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
|
||||||
|
ws_stub = RecordingWebSocketManager()
|
||||||
|
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
original_process = DummyScanner._process_model_file
|
||||||
|
|
||||||
|
async def cancelling_process(self, file_path, root_path, **kwargs):
|
||||||
|
scanner.cancel_task()
|
||||||
|
return await original_process(self, file_path, root_path, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(DummyScanner, "_process_model_file", cancelling_process)
|
||||||
|
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages[0]["status"] == "started"
|
||||||
|
assert messages[-1]["status"] == "cancelled"
|
||||||
|
assert messages[-1]["elapsed_seconds"] >= 0
|
||||||
|
assert not any(m["status"] == "completed" for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_initialize_cache_broadcasts_error(tmp_path: Path, monkeypatch):
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
|
||||||
|
ws_stub = RecordingWebSocketManager()
|
||||||
|
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
async def raising_gather(**_kwargs):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(scanner, "_gather_model_data", raising_gather)
|
||||||
|
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages[0]["status"] == "started"
|
||||||
|
assert messages[-1]["status"] == "error"
|
||||||
|
assert messages[-1]["error"] == "boom"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_cache_broadcasts_scan_progress(tmp_path: Path, monkeypatch):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
ws_stub = RecordingWebSocketManager()
|
||||||
|
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
new_file = tmp_path / "three.txt"
|
||||||
|
new_file.write_text("three", encoding="utf-8")
|
||||||
|
|
||||||
|
await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages, "expected scan_progress broadcasts"
|
||||||
|
|
||||||
|
started = messages[0]
|
||||||
|
assert started["type"] == "scan_progress"
|
||||||
|
assert started["status"] == "started"
|
||||||
|
assert started["stage"] == "reconcile_scan"
|
||||||
|
assert started["progress"] == 0
|
||||||
|
assert started["full_rebuild"] is False
|
||||||
|
|
||||||
|
process_messages = [
|
||||||
|
m for m in messages
|
||||||
|
if m["stage"] == "process_new" and m["status"] == "processing"
|
||||||
|
]
|
||||||
|
assert process_messages, "expected process_new progress updates"
|
||||||
|
assert process_messages[-1]["processed"] == 1
|
||||||
|
assert process_messages[-1]["total"] == 1
|
||||||
|
assert process_messages[-1]["current_name"] == "three.txt"
|
||||||
|
|
||||||
|
completed = messages[-1]
|
||||||
|
assert completed["status"] == "completed"
|
||||||
|
assert completed["progress"] == 100
|
||||||
|
assert completed["added"] == 1
|
||||||
|
assert completed["removed"] == 0
|
||||||
|
assert completed["elapsed_seconds"] >= 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_cache_broadcasts_cancelled(tmp_path: Path, monkeypatch):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
ws_stub = RecordingWebSocketManager()
|
||||||
|
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
new_file = tmp_path / "four.txt"
|
||||||
|
new_file.write_text("four", encoding="utf-8")
|
||||||
|
|
||||||
|
original_process = DummyScanner._process_model_file
|
||||||
|
|
||||||
|
async def cancelling_process(self, file_path, root_path, **kwargs):
|
||||||
|
scanner.cancel_task()
|
||||||
|
return await original_process(self, file_path, root_path, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(DummyScanner, "_process_model_file", cancelling_process)
|
||||||
|
|
||||||
|
await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages[0]["status"] == "started"
|
||||||
|
assert messages[-1]["status"] == "cancelled"
|
||||||
|
assert messages[-1]["elapsed_seconds"] >= 0
|
||||||
|
assert not any(m["status"] == "completed" for m in messages)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconcile_cache_broadcasts_error(tmp_path: Path, monkeypatch):
|
||||||
|
_create_files(tmp_path)
|
||||||
|
scanner = DummyScanner(tmp_path)
|
||||||
|
await scanner._initialize_cache()
|
||||||
|
|
||||||
|
ws_stub = RecordingWebSocketManager()
|
||||||
|
monkeypatch.setattr(model_scanner, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
def raising_walk(*_args, **_kwargs):
|
||||||
|
raise RuntimeError("walk failed")
|
||||||
|
|
||||||
|
monkeypatch.setattr(model_scanner.os, "walk", raising_walk)
|
||||||
|
|
||||||
|
await scanner._reconcile_cache()
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages[0]["status"] == "started"
|
||||||
|
assert messages[-1]["status"] == "error"
|
||||||
|
assert messages[-1]["error"] == "walk failed"
|
||||||
|
|||||||
@@ -1,327 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import asyncio
|
|
||||||
from typing import Any, Dict
|
|
||||||
from unittest.mock import AsyncMock, MagicMock
|
|
||||||
from py.services.recipe_scanner import RecipeScanner
|
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
# We define these here to help with spec= if needed
|
|
||||||
class MockCivitaiClient:
|
|
||||||
async def get_image_info(self, image_id, source_url=None):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class MockPersistenceService:
|
|
||||||
async def save_recipe(self, recipe):
|
|
||||||
pass
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_civitai_client():
|
|
||||||
client = MagicMock(spec=MockCivitaiClient)
|
|
||||||
client.get_image_info = AsyncMock()
|
|
||||||
return client
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def mock_metadata_provider():
|
|
||||||
provider = MagicMock()
|
|
||||||
provider.get_model_version_info = AsyncMock(return_value=(None, None))
|
|
||||||
provider.get_model_by_hash = AsyncMock(return_value=(None, None))
|
|
||||||
return provider
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def recipe_scanner():
|
|
||||||
lora_scanner = MagicMock()
|
|
||||||
lora_scanner.get_cached_data = AsyncMock(return_value=SimpleNamespace(raw_data=[]))
|
|
||||||
|
|
||||||
scanner = RecipeScanner(lora_scanner=lora_scanner)
|
|
||||||
return scanner
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def setup_scanner(recipe_scanner, mock_civitai_client, mock_metadata_provider, monkeypatch):
|
|
||||||
monkeypatch.setattr(recipe_scanner, "_get_civitai_client", AsyncMock(return_value=mock_civitai_client))
|
|
||||||
|
|
||||||
# Wrap the real method with a mock so we can check calls but still execute it
|
|
||||||
real_save = recipe_scanner._save_recipe_persistently
|
|
||||||
mock_save = AsyncMock(side_effect=real_save)
|
|
||||||
monkeypatch.setattr(recipe_scanner, "_save_recipe_persistently", mock_save)
|
|
||||||
|
|
||||||
monkeypatch.setattr("py.recipes.enrichment.get_default_metadata_provider", AsyncMock(return_value=mock_metadata_provider))
|
|
||||||
|
|
||||||
# Mock get_recipe_json_path to avoid file system issues in tests
|
|
||||||
recipe_scanner.get_recipe_json_path = AsyncMock(return_value="/tmp/test_recipe.json")
|
|
||||||
# Mock open to avoid actual file writing
|
|
||||||
monkeypatch.setattr("builtins.open", MagicMock())
|
|
||||||
monkeypatch.setattr("json.dump", MagicMock())
|
|
||||||
monkeypatch.setattr("os.path.exists", MagicMock(return_value=False)) # avoid EXIF logic
|
|
||||||
|
|
||||||
return recipe_scanner, mock_civitai_client, mock_metadata_provider
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_skip_up_to_date(setup_scanner):
|
|
||||||
recipe_scanner, _, _ = setup_scanner
|
|
||||||
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[
|
|
||||||
{"id": "r1", "repair_version": RecipeScanner.REPAIR_VERSION, "title": "Up to date"}
|
|
||||||
])
|
|
||||||
|
|
||||||
# Run
|
|
||||||
results = await recipe_scanner.repair_all_recipes()
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
assert results["repaired"] == 0
|
|
||||||
assert results["skipped"] == 1
|
|
||||||
recipe_scanner._save_recipe_persistently.assert_not_called()
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_with_enriched_checkpoint_id(setup_scanner):
|
|
||||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
|
||||||
|
|
||||||
recipe = {
|
|
||||||
"id": "r1",
|
|
||||||
"title": "Old Recipe",
|
|
||||||
"source_path": "https://civitai.com/images/12345",
|
|
||||||
"checkpoint": None,
|
|
||||||
"gen_params": {"prompt": ""}
|
|
||||||
}
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
|
||||||
|
|
||||||
# Mock image info returning modelVersionId
|
|
||||||
mock_civitai_client.get_image_info.return_value = {
|
|
||||||
"modelVersionId": 5678,
|
|
||||||
"meta": {"prompt": "a beautiful forest", "Checkpoint": "basic_name.safetensors"}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mock metadata provider returning full info
|
|
||||||
mock_metadata_provider.get_model_version_info.return_value = ({
|
|
||||||
"id": 5678,
|
|
||||||
"modelId": 1234,
|
|
||||||
"name": "v1.0",
|
|
||||||
"model": {"name": "Full Model Name", "type": "Checkpoint"},
|
|
||||||
"baseModel": "SDXL 1.0",
|
|
||||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
|
||||||
"files": [{"type": "Model", "hashes": {"SHA256": "ABCDEF"}, "name": "full_filename.safetensors"}]
|
|
||||||
}, None)
|
|
||||||
|
|
||||||
# Run
|
|
||||||
results = await recipe_scanner.repair_all_recipes()
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
assert results["repaired"] == 1
|
|
||||||
mock_metadata_provider.get_model_version_info.assert_called_with("5678")
|
|
||||||
|
|
||||||
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
|
||||||
checkpoint = saved_recipe["checkpoint"]
|
|
||||||
assert checkpoint["modelName"] == "Full Model Name"
|
|
||||||
assert checkpoint["modelVersionName"] == "v1.0"
|
|
||||||
assert checkpoint["modelId"] == 1234
|
|
||||||
assert checkpoint["modelVersionId"] == 5678
|
|
||||||
assert checkpoint["type"] == "checkpoint"
|
|
||||||
assert "name" not in checkpoint
|
|
||||||
assert "version" not in checkpoint
|
|
||||||
assert "hash" not in checkpoint
|
|
||||||
assert "file_name" not in checkpoint
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_supports_civitai_red_source_url(setup_scanner):
|
|
||||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
|
||||||
|
|
||||||
recipe = {
|
|
||||||
"id": "r1",
|
|
||||||
"title": "Red Recipe",
|
|
||||||
"source_path": "https://civitai.red/images/12345",
|
|
||||||
"checkpoint": None,
|
|
||||||
"gen_params": {"prompt": ""},
|
|
||||||
}
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
|
||||||
|
|
||||||
mock_civitai_client.get_image_info.return_value = {
|
|
||||||
"modelVersionId": 5678,
|
|
||||||
"meta": {"prompt": "from red"},
|
|
||||||
}
|
|
||||||
mock_metadata_provider.get_model_version_info.return_value = (
|
|
||||||
{
|
|
||||||
"id": 5678,
|
|
||||||
"modelId": 1234,
|
|
||||||
"name": "v1.0",
|
|
||||||
"model": {"name": "Full Model Name", "type": "Checkpoint"},
|
|
||||||
"baseModel": "SDXL 1.0",
|
|
||||||
"images": [{"url": "https://image.url/thumb.jpg"}],
|
|
||||||
"files": [
|
|
||||||
{
|
|
||||||
"type": "Model",
|
|
||||||
"hashes": {"SHA256": "ABCDEF"},
|
|
||||||
"name": "full_filename.safetensors",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
results = await recipe_scanner.repair_all_recipes()
|
|
||||||
|
|
||||||
assert results["repaired"] == 1
|
|
||||||
mock_civitai_client.get_image_info.assert_called_with(
|
|
||||||
"12345", source_url="https://civitai.red/images/12345"
|
|
||||||
)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_with_enriched_checkpoint_hash(setup_scanner):
|
|
||||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
|
||||||
|
|
||||||
recipe = {
|
|
||||||
"id": "r1",
|
|
||||||
"title": "Embedded Only",
|
|
||||||
"checkpoint": None,
|
|
||||||
"gen_params": {
|
|
||||||
"prompt": "",
|
|
||||||
"Model hash": "hash123"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
|
||||||
|
|
||||||
# Mock metadata provider lookup by hash
|
|
||||||
mock_metadata_provider.get_model_by_hash.return_value = ({
|
|
||||||
"id": 999,
|
|
||||||
"modelId": 888,
|
|
||||||
"name": "v2.0",
|
|
||||||
"model": {"name": "Hashed Model", "type": "Checkpoint"},
|
|
||||||
"baseModel": "SD 1.5",
|
|
||||||
"files": [{"type": "Model", "hashes": {"SHA256": "hash123"}, "name": "hashed.safetensors"}]
|
|
||||||
}, None)
|
|
||||||
|
|
||||||
# Run
|
|
||||||
results = await recipe_scanner.repair_all_recipes()
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
assert results["repaired"] == 1
|
|
||||||
mock_metadata_provider.get_model_by_hash.assert_called_with("hash123")
|
|
||||||
|
|
||||||
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
|
||||||
checkpoint = saved_recipe["checkpoint"]
|
|
||||||
assert checkpoint["modelName"] == "Hashed Model"
|
|
||||||
assert checkpoint["modelVersionName"] == "v2.0"
|
|
||||||
assert checkpoint["modelId"] == 888
|
|
||||||
assert checkpoint["type"] == "checkpoint"
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_fallback_to_basic(setup_scanner):
|
|
||||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
|
||||||
|
|
||||||
recipe = {
|
|
||||||
"id": "r1",
|
|
||||||
"title": "No Meta Lookup",
|
|
||||||
"checkpoint": None,
|
|
||||||
"gen_params": {
|
|
||||||
"prompt": "",
|
|
||||||
"Checkpoint": "just_a_name.safetensors"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
|
||||||
|
|
||||||
# Mock metadata provider returning nothing
|
|
||||||
mock_metadata_provider.get_model_by_hash.return_value = (None, "Model not found")
|
|
||||||
|
|
||||||
# Run
|
|
||||||
results = await recipe_scanner.repair_all_recipes()
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
assert results["repaired"] == 1
|
|
||||||
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
|
||||||
assert saved_recipe["checkpoint"]["modelName"] == "just_a_name.safetensors"
|
|
||||||
assert saved_recipe["checkpoint"]["type"] == "checkpoint"
|
|
||||||
assert "modelId" not in saved_recipe["checkpoint"]
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_progress_callback(setup_scanner):
|
|
||||||
recipe_scanner, _, _ = setup_scanner
|
|
||||||
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[
|
|
||||||
{"id": "r1", "title": "R1", "checkpoint": None},
|
|
||||||
{"id": "r2", "title": "R2", "checkpoint": None}
|
|
||||||
])
|
|
||||||
|
|
||||||
progress_calls = []
|
|
||||||
async def progress_callback(data):
|
|
||||||
progress_calls.append(data)
|
|
||||||
|
|
||||||
# Run
|
|
||||||
await recipe_scanner.repair_all_recipes(
|
|
||||||
progress_callback=progress_callback
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify
|
|
||||||
assert len(progress_calls) >= 2
|
|
||||||
assert progress_calls[-1]["status"] == "completed"
|
|
||||||
assert progress_calls[-1]["total"] == 2
|
|
||||||
assert progress_calls[-1]["repaired"] == 2
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_repair_all_recipes_strips_runtime_fields(setup_scanner):
|
|
||||||
recipe_scanner, mock_civitai_client, mock_metadata_provider = setup_scanner
|
|
||||||
|
|
||||||
# Recipe with runtime fields
|
|
||||||
recipe: Dict[str, Any] = {
|
|
||||||
"id": "r1",
|
|
||||||
"title": "Cleanup Test",
|
|
||||||
"checkpoint": {
|
|
||||||
"name": "CP",
|
|
||||||
"inLibrary": True,
|
|
||||||
"localPath": "/path/to/cp",
|
|
||||||
"thumbnailUrl": "thumb.jpg"
|
|
||||||
},
|
|
||||||
"loras": [
|
|
||||||
{
|
|
||||||
"name": "L1",
|
|
||||||
"weight": 0.8,
|
|
||||||
"inLibrary": True,
|
|
||||||
"localPath": "/path/to/l1",
|
|
||||||
"preview_url": "p.jpg"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"gen_params": {"prompt": ""}
|
|
||||||
}
|
|
||||||
recipe_scanner._cache = SimpleNamespace(raw_data=[recipe])
|
|
||||||
# Set high version to trigger repair if needed (or just ensure it processes)
|
|
||||||
recipe["repair_version"] = 0
|
|
||||||
|
|
||||||
# Run
|
|
||||||
await recipe_scanner.repair_all_recipes()
|
|
||||||
|
|
||||||
# Verify sanitation
|
|
||||||
assert recipe_scanner._save_recipe_persistently.called
|
|
||||||
saved_recipe = recipe_scanner._save_recipe_persistently.call_args[0][0]
|
|
||||||
|
|
||||||
# 1. Check LORA
|
|
||||||
lora = saved_recipe["loras"][0]
|
|
||||||
assert "inLibrary" not in lora
|
|
||||||
assert "localPath" not in lora
|
|
||||||
assert "preview_url" not in lora
|
|
||||||
assert "strength" in lora # weight renamed to strength
|
|
||||||
assert lora["strength"] == 0.8
|
|
||||||
|
|
||||||
# 2. Check Checkpoint
|
|
||||||
cp = saved_recipe["checkpoint"]
|
|
||||||
assert "inLibrary" not in cp
|
|
||||||
assert "localPath" not in cp
|
|
||||||
assert "thumbnailUrl" not in cp
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_sanitize_recipe_for_storage(recipe_scanner):
|
|
||||||
|
|
||||||
recipe = {
|
|
||||||
"loras": [{"name": "L1", "inLibrary": True, "weight": 0.5}],
|
|
||||||
"checkpoint": {"name": "CP", "localPath": "/tmp/cp"}
|
|
||||||
}
|
|
||||||
|
|
||||||
clean = recipe_scanner._sanitize_recipe_for_storage(recipe)
|
|
||||||
|
|
||||||
assert "inLibrary" not in clean["loras"][0]
|
|
||||||
assert "strength" in clean["loras"][0]
|
|
||||||
assert clean["loras"][0]["strength"] == 0.5
|
|
||||||
assert "localPath" not in clean["checkpoint"]
|
|
||||||
# Testing based on what enricher would produce if it ran,
|
|
||||||
# but here we are just testing the sanitizer which handles what is ALREADY there.
|
|
||||||
# However, the sanitizer doesn't rename fields, it just removes runtime ones.
|
|
||||||
# Since we changed the enricher to NOT put 'name' anymore, this test case
|
|
||||||
# should probably reflect the new fields if it's simulating a real recipe.
|
|
||||||
assert clean["checkpoint"]["name"] == "CP"
|
|
||||||
@@ -9,6 +9,7 @@ import pytest
|
|||||||
|
|
||||||
from py.config import config
|
from py.config import config
|
||||||
from py.services import model_scanner as model_scanner_module
|
from py.services import model_scanner as model_scanner_module
|
||||||
|
from py.services import recipe_scanner as recipe_scanner_module
|
||||||
from py.services.model_cache import ModelCache
|
from py.services.model_cache import ModelCache
|
||||||
from py.services.model_hash_index import ModelHashIndex
|
from py.services.model_hash_index import ModelHashIndex
|
||||||
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
from py.services.model_scanner import CacheBuildResult, ModelScanner
|
||||||
@@ -4965,3 +4966,133 @@ async def test_find_all_duplicate_recipes_include_prompt_missing_gen_params(reci
|
|||||||
groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
|
groups = await scanner.find_all_duplicate_recipes(include_prompt=True)
|
||||||
# Recipes without gen_params/prompt normalize to empty prompt and match
|
# Recipes without gen_params/prompt normalize to empty prompt and match
|
||||||
assert groups == {"abc:0.8\x1f": ["r1", "r2"]}
|
assert groups == {"abc:0.8\x1f": ["r1", "r2"]}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingRecipeWebSocketManager:
|
||||||
|
"""Minimal ws_manager stand-in that records broadcasts."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.payloads: list[Dict[str, Any]] = []
|
||||||
|
self.broadcasts: list[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def broadcast_init_progress(self, payload: Dict[str, Any]) -> None:
|
||||||
|
self.payloads.append(payload)
|
||||||
|
|
||||||
|
async def broadcast(self, payload: Dict[str, Any]) -> None:
|
||||||
|
self.broadcasts.append(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_progress_recipe_files(recipes_dir: Path, count: int) -> None:
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for idx in range(count):
|
||||||
|
recipe_path = recipes_dir / f"progress-recipe-{idx}.recipe.json"
|
||||||
|
recipe_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": f"progress-recipe-{idx}",
|
||||||
|
"file_path": str(recipes_dir / f"img-{idx}.png"),
|
||||||
|
"title": f"Recipe {idx}",
|
||||||
|
"modified": 0.0,
|
||||||
|
"created_date": 0.0,
|
||||||
|
"loras": [],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_force_refresh_broadcasts_scan_progress(
|
||||||
|
tmp_path: Path, monkeypatch, recipe_scanner
|
||||||
|
):
|
||||||
|
scanner, _stub = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
_write_progress_recipe_files(recipes_dir, 3)
|
||||||
|
|
||||||
|
ws_stub = RecordingRecipeWebSocketManager()
|
||||||
|
monkeypatch.setattr(recipe_scanner_module, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
await scanner.get_cached_data(force_refresh=True)
|
||||||
|
# Wait for the FTS index build so no background task outlives the loop.
|
||||||
|
if scanner._fts_index_task:
|
||||||
|
await scanner._fts_index_task
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages, "expected scan_progress broadcasts"
|
||||||
|
|
||||||
|
started = messages[0]
|
||||||
|
assert started["type"] == "scan_progress"
|
||||||
|
assert started["status"] == "started"
|
||||||
|
assert started["stage"] == "scan_folders"
|
||||||
|
assert started["progress"] == 0
|
||||||
|
assert started["model_type"] == "recipe"
|
||||||
|
assert started["pageType"] == "recipes"
|
||||||
|
assert started["full_rebuild"] is True
|
||||||
|
|
||||||
|
count_messages = [m for m in messages if m["stage"] == "count_models"]
|
||||||
|
assert count_messages and count_messages[0]["total"] == 3
|
||||||
|
|
||||||
|
process_messages = [
|
||||||
|
m
|
||||||
|
for m in messages
|
||||||
|
if m["stage"] == "process_models" and m["status"] == "processing"
|
||||||
|
]
|
||||||
|
assert process_messages, "expected at least one process_models update"
|
||||||
|
final_process = process_messages[-1]
|
||||||
|
assert final_process["processed"] == 3
|
||||||
|
assert final_process["total"] == 3
|
||||||
|
assert final_process["current_name"].endswith(".recipe.json")
|
||||||
|
for message in process_messages:
|
||||||
|
assert 0 < message["progress"] <= 99
|
||||||
|
|
||||||
|
completed = messages[-1]
|
||||||
|
assert completed["status"] == "completed"
|
||||||
|
assert completed["progress"] == 100
|
||||||
|
assert completed["elapsed_seconds"] >= 0
|
||||||
|
assert completed["total"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_init_without_report_progress_does_not_broadcast(
|
||||||
|
tmp_path: Path, monkeypatch, recipe_scanner
|
||||||
|
):
|
||||||
|
"""Startup path (initialize_in_background) must not emit scan_progress."""
|
||||||
|
scanner, _stub = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
_write_progress_recipe_files(recipes_dir, 2)
|
||||||
|
|
||||||
|
ws_stub = RecordingRecipeWebSocketManager()
|
||||||
|
monkeypatch.setattr(recipe_scanner_module, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
# Invalidate the persistent cache so the sync path performs a full
|
||||||
|
# directory scan, exactly like a force refresh but without progress
|
||||||
|
# reporting (this is how initialize_in_background invokes it).
|
||||||
|
scanner._persistent_cache.save_cache([], {})
|
||||||
|
|
||||||
|
scanner._initialize_recipe_cache_sync()
|
||||||
|
|
||||||
|
assert ws_stub.broadcasts == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_init_reports_error_broadcast(
|
||||||
|
tmp_path: Path, monkeypatch, recipe_scanner
|
||||||
|
):
|
||||||
|
scanner, _stub = recipe_scanner
|
||||||
|
recipes_dir = Path(config.loras_roots[0]) / "recipes"
|
||||||
|
_write_progress_recipe_files(recipes_dir, 1)
|
||||||
|
|
||||||
|
ws_stub = RecordingRecipeWebSocketManager()
|
||||||
|
monkeypatch.setattr(recipe_scanner_module, "ws_manager", ws_stub)
|
||||||
|
|
||||||
|
scanner._persistent_cache.save_cache([], {})
|
||||||
|
|
||||||
|
def raising_scan(self, recipes_dir, progress_loop=None):
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(RecipeScanner, "_full_directory_scan_sync", raising_scan)
|
||||||
|
|
||||||
|
scanner._initialize_recipe_cache_sync(report_progress=True)
|
||||||
|
|
||||||
|
messages = ws_stub.broadcasts
|
||||||
|
assert messages[0]["status"] == "started"
|
||||||
|
assert messages[-1]["status"] == "error"
|
||||||
|
assert messages[-1]["error"] == "boom"
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user