Compare commits

..

11 Commits

Author SHA1 Message Date
Will Miao 303833bbae fix(llm): catch UnicodeDecodeError when fetching model catalog (#1099)
resp.json() raises UnicodeDecodeError (not JSONDecodeError) when the
remote body contains invalid UTF-8 bytes, which the exception handler
did not catch and could crash the app. Apply the same fix to both
_load_model_catalog and fetch_ollama_models so they fall back to an
empty catalog. Add regression tests for both paths.
2026-09-06 12:04:16 +08:00
Will Miao f86b7b55d6 feat(recipes): remove deprecated Repair Metadata feature
The recipe "Repair Metadata" action has been marked Deprecated in the UI
for a while and cannot reliably recover recipes imported from CivitAI URLs
whose REST meta has no resources/hashes and whose image has no embedded
metadata (e.g. CivitAI-only generation data). Drop the feature end to end.

Backend:
- remove repair routes (repair, cancel-repair, recipe/{id}/repair,
  repair-bulk, repair-progress) and their handler mappings/methods
- remove RecipeScanner repair_all_recipes / repair_recipe_by_id /
  _repair_single_recipe and REPAIR_VERSION
- remove WebSocketManager recipe-repair progress channel
- drop repair_version column from the persistent recipe cache
- rematch mutual-exclusion now only checks rematch

Frontend:
- remove repair entries from per-recipe, bulk and global context menus
- remove repairRecipe / repairSelectedRecipes / repairRecipes + cancelRepair
  and the repairBulk API client method/endpoint
- drop recipe-repair i18n keys (synced across locales; doctor keys kept)

Tests/docs: delete test_recipe_repair.py, update scaffolding/routes/ws/
persistent-cache/integration tests and i18n guideline examples.
2026-09-05 16:56:20 +08:00
Will Miao 782bb53784 fix(ui): restore equal-width toasts in toast container
Commit 86aa1d80 added align-items: flex-end to .toast-container and
dropped the .toast min-width to 200px. With flex-end alignment each
toast now shrinks to its own content width, so toasts of different
message lengths render at inconsistent widths. Drop the align-items
override so the container falls back to stretch, giving every toast a
single shared width as before the change.
2026-09-05 07:23:54 +08:00
Will Miao 139231e225 chore(skill): remove lora-manager-e2e skill, keep sandbox helpers in scripts/e2e/ 2026-09-05 07:10:43 +08:00
Will Miao 121d8d5cea fix(ui): lighten toolbar shortcut keycaps and fix contrast on active buttons 2026-09-05 00:21:11 +08:00
Will Miao ec147bd677 feat(ui): add setting to keep the action bar visible while scrolling (#1095)
Adds a 'Keep Action Bar Visible' toggle (default off) under
Settings > Interface > Layout Settings. When enabled, the controls bar
(Refresh, Download, etc.) and the breadcrumb nav are wrapped in a shared
sticky container (.sticky-topbar) so both stay pinned as one unit; when
disabled, the wrapper is display: contents and the original behavior
(only the breadcrumb stays visible) is preserved.
2026-09-04 23:53:27 +08:00
Will Miao 93fc28b499 chore(ui): rebuild vue-widgets bundle
Rebuilt from the preceding three commits' sources:

- active-filters chip and its dead settings-toggled broadcast removed
  (the built bundle also no longer embeds the scripts/app.js test shim
  that the chip's settings.js import used to pull in)
- scrollbar inset re-measured on programmatic value changes
- app/api bindings canonicalized to "../../../scripts/*" externals and
  settings.js no longer inlined (bound at runtime via "../settings.js"
  to the vanilla module instance), per the new build guard
2026-09-04 19:02:36 +08:00
Will Miao 7afed1a14b fix(ui): cross-link right-click menu in active-filters settings tooltip
The loramanager.lora_active_filters_autocomplete tooltip only mentioned
the /activefilters and /noactivefilters commands, while the prompt-node
tag-autocomplete tooltip cross-links every toggle entry point (typing in
the node and the node's right-click menu). 6ba64ebb added the right-click
menu entry without extending the tooltip to match; align the wording with
the established pattern.
2026-09-04 19:02:31 +08:00
Will Miao e6f5142e48 fix(ui): re-measure autocomplete scrollbar inset on programmatic value changes
The --lm-vscrollbar-width inset from 634ea7f2 was only refreshed on input
events, mount and mode changes. Programmatic value updates (widget.setValue
from "send lora to workflow", external value-change events) change the
textarea content without an input event, leaving the corner clear (x)
button overlapping a freshly appeared classic scrollbar until the next
keystroke. Mount-time pending value replay was already covered.

- onExternalValueChange and widget.onSetValue now call
  updateVScrollbarWidth() alongside the hasText update
- tests: cover both paths by overriding textarea metrics to an overflowing
  state and asserting the 15px gutter lands in the CSS var
2026-09-04 19:02:31 +08:00
Will Miao 87f05fb66c build(vue-widgets): keep shared runtime modules external in the widget bundle
Guard against the inlined-shim bug class that broke the removed
active-filters chip: importing web/comfyui/* modules from widget source
inlines them into lora-manager-widgets.js, and their own relative imports
then resolve against the repo filesystem at build time instead of the
vanilla files' runtime URL layout.

A resolveId plugin (enforce: pre) now returns explicit external markers:

- scripts/app.js and scripts/api.js imported at any "../../scripts/*"
  depth are rewritten to the canonical "../../../scripts/*" specifier so
  every app/api binding in the bundle is the real ComfyUI module. The
  repo-root scripts/app.js is a unit-test shim (in-memory settings store)
  and must never be bundled; the canonical depth is the only one that
  resolves from the emitted bundle's served location.
- web/comfyui/settings.js is externalized to "../settings.js" so the
  bundle binds to the SAME vanilla module instance the ComfyUI extension
  loader already runs - real settings store, registerExtension side
  effect executed exactly once, no duplicated module state.

A companion plugin warns on any web/comfyui/* import from widget source,
since an inlined copy still duplicates module-level side effects.

Notes from validating the mechanism: rollup output.paths resolves
returned paths to absolute filesystem locations (rejected), and a depth
regex inside rollupOptions.external matches raw specifiers before
resolveId hooks run and would emit the shim-relative depth verbatim
(rejected) - hence explicit { id, external: true } returns.
2026-09-04 19:02:25 +08:00
Will Miao cf64e5baa8 fix(ui): remove per-node active-filters chip from loras widgets
The indicator chip added for /activefilters discoverability was broken by
design of its import path: AutocompleteTextWidget.vue imported
web/comfyui/settings.js into the vue-widgets bundle, and settings.js's
"../../scripts/app.js" import resolved at build time to the repo-root test
shim (scripts/app.js, an in-memory settings store). The chip therefore read
and wrote an orphaned in-memory Map: clicking it flipped only its own
visual state and never touched the real ComfyUI setting that
autocomplete.js consults (use_active_filters query param).

Beyond the defect, a persistent per-node control for a global persisted
setting misleads users and needs cross-instance sync machinery, which the
footer hint, slash commands, right-click menu entry and settings dialog
already cover.

- AutocompleteTextWidget.vue: remove the chip button, its state/handlers,
  the settings.js import (the shim-inlining pathway) and all chip styles
- AutocompleteTextWidget.test.ts: drop the chip indicator describe block
  and the settings.js module mock; beforeEach import no longer needed
- settings.js: drop the lora-manager:setting-toggled window broadcast and
  its export — the chip was its only consumer, so every
  setLoraManagerSettingValue write no longer dispatches a dead event
- autocomplete.activeFilters.test.js: drop the broadcast assertion test
- loraLoader.activeFiltersMenu.test.js: drop SETTING_TOGGLED_EVENT_NAME
  from the settings.js mock

Discoverability of /activefilters // /noactivefilters is unchanged:
command-list footer, first-run hint, node context menu, settings dialog.
2026-09-04 19:02:16 +08:00
57 changed files with 402 additions and 2998 deletions
-146
View File
@@ -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)
+20
View File
@@ -215,6 +215,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`)
+2 -2
View File
@@ -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
+2 -19
View File
@@ -212,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...",
@@ -484,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",
@@ -819,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",
@@ -875,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",
@@ -1128,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",
@@ -2236,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",
+2 -19
View File
@@ -212,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...",
@@ -484,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",
@@ -819,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",
@@ -875,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",
@@ -1128,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",
@@ -2236,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",
+2 -19
View File
@@ -212,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...",
@@ -484,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",
@@ -819,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",
@@ -875,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",
@@ -1128,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",
@@ -2236,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",
+2 -19
View File
@@ -212,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...",
@@ -484,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",
@@ -819,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",
@@ -875,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",
@@ -1128,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",
@@ -2236,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}",
+2 -19
View File
@@ -212,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": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
@@ -484,6 +477,8 @@
"layoutSettings": { "layoutSettings": {
"groupByModel": "קיבוץ לפי מודל", "groupByModel": "קיבוץ לפי מודל",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.", "groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"stickyControls": "השארת סרגל הפעולות גלוי",
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
"displayDensity": "צפיפות תצוגה", "displayDensity": "צפיפות תצוגה",
"displayDensityOptions": { "displayDensityOptions": {
"default": "ברירת מחדל", "default": "ברירת מחדל",
@@ -819,7 +814,6 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים", "setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים", "copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-נתונים", "refreshAll": "רענן את כל המטא-נתונים",
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים", "rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור", "reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה", "checkUpdates": "בדוק עדכונים לבחירה",
@@ -875,7 +869,6 @@
"replacePreview": "החלף תצוגה מקדימה", "replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן", "setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה", "moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-נתונים",
"rematchMetadata": "התאמה מחדש למודלים מקומיים", "rematchMetadata": "התאמה מחדש למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור", "reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל", "excludeModel": "החרג מודל",
@@ -1128,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": "המתכון יובא מחדש בהצלחה",
@@ -2236,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} מתכונים שנבחרו",
+2 -19
View File
@@ -212,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": "レシピをローカルモデルに再マッチングしています...",
@@ -484,6 +477,8 @@
"layoutSettings": { "layoutSettings": {
"groupByModel": "モデルでグループ化", "groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。", "groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"stickyControls": "アクションバーを常に表示",
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
"displayDensity": "表示密度", "displayDensity": "表示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "デフォルト", "default": "デフォルト",
@@ -819,7 +814,6 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定", "setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー", "copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新", "refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング", "rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート", "reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認", "checkUpdates": "選択項目の更新を確認",
@@ -875,7 +869,6 @@
"replacePreview": "プレビューを置換", "replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定", "setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動", "moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング", "rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート", "reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外", "excludeModel": "モデルを除外",
@@ -1128,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": "レシピの再インポートが完了しました",
@@ -2236,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} 件のレシピの再マッチングに失敗しました",
+2 -19
View File
@@ -212,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": "레시피를 로컬 모델에 다시 매칭하는 중...",
@@ -484,6 +477,8 @@
"layoutSettings": { "layoutSettings": {
"groupByModel": "모델별 그룹화", "groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.", "groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"stickyControls": "작업 표시줄 항상 표시",
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
"displayDensity": "표시 밀도", "displayDensity": "표시 밀도",
"displayDensityOptions": { "displayDensityOptions": {
"default": "기본", "default": "기본",
@@ -819,7 +814,6 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정", "setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사", "copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침", "refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭", "rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인", "checkUpdates": "선택 항목 업데이트 확인",
@@ -875,7 +869,6 @@
"replacePreview": "미리보기 교체", "replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정", "setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동", "moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"rematchMetadata": "로컬 모델에 다시 매칭", "rematchMetadata": "로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기", "reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외", "excludeModel": "모델 제외",
@@ -1128,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": "레시피를 다시 가져왔습니다",
@@ -2236,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}개 재매칭 실패",
+2 -19
View File
@@ -212,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": "Повторное сопоставление рецептов с локальными моделями...",
@@ -484,6 +477,8 @@
"layoutSettings": { "layoutSettings": {
"groupByModel": "Группировать по модели", "groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.", "groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
"stickyControls": "Держать панель действий видимой",
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
"displayDensity": "Плотность отображения", "displayDensity": "Плотность отображения",
"displayDensityOptions": { "displayDensityOptions": {
"default": "По умолчанию", "default": "По умолчанию",
@@ -819,7 +814,6 @@
"setContentRating": "Установить рейтинг контента для всех", "setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис", "copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные", "refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"rematchMetadata": "Сопоставить выбранные с локальными моделями", "rematchMetadata": "Сопоставить выбранные с локальными моделями",
"reimportMetadata": "Переимпортировать из источника", "reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных", "checkUpdates": "Проверить обновления для выбранных",
@@ -875,7 +869,6 @@
"replacePreview": "Заменить превью", "replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента", "setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку", "moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"rematchMetadata": "Сопоставить с локальными моделями", "rematchMetadata": "Сопоставить с локальными моделями",
"reimportMetadata": "Переимпортировать из источника", "reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель", "excludeModel": "Исключить модель",
@@ -1128,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": "Рецепт успешно переимпортирован",
@@ -2236,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} выбранных рецептов",
+2 -19
View File
@@ -212,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": "正在将配方重新匹配到本地模型...",
@@ -484,6 +477,8 @@
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分组", "groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。", "groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"stickyControls": "保持操作栏可见",
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
"displayDensity": "显示密度", "displayDensity": "显示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "默认", "default": "默认",
@@ -819,7 +814,6 @@
"setContentRating": "为所选中设置内容评级", "setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法", "copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据", "refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型", "rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入", "reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新", "checkUpdates": "检查所选更新",
@@ -875,7 +869,6 @@
"replacePreview": "替换预览", "replacePreview": "替换预览",
"setContentRating": "设置内容评级", "setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹", "moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型", "rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入", "reimportMetadata": "从源重新导入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
@@ -1128,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": "配方已从源重新导入成功",
@@ -2236,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} 个所选配方重新匹配失败",
+2 -19
View File
@@ -212,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": "正在將配方重新匹配到本地模型...",
@@ -484,6 +477,8 @@
"layoutSettings": { "layoutSettings": {
"groupByModel": "按模型分組", "groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。", "groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"stickyControls": "保持操作列可見",
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
"displayDensity": "顯示密度", "displayDensity": "顯示密度",
"displayDensityOptions": { "displayDensityOptions": {
"default": "預設", "default": "預設",
@@ -819,7 +814,6 @@
"setContentRating": "為全部設定內容分級", "setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法", "copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata", "refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型", "rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入", "reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新", "checkUpdates": "檢查所選更新",
@@ -875,7 +869,6 @@
"replacePreview": "更換預覽圖", "replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級", "setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾", "moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型", "rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入", "reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型", "excludeModel": "排除模型",
@@ -1128,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": "配方已從來源重新匯入成功",
@@ -2236,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} 個所選配方重新匹配失敗",
+3 -174
View File
@@ -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,
@@ -1231,18 +1072,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()
-5
View File
@@ -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"),
+2 -2
View File
@@ -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 []
-4
View File
@@ -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,
-203
View File
@@ -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.
+1
View File
@@ -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": "",
-26
View File
@@ -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
View File
@@ -174,7 +174,7 @@
z-index: var(--z-toast); z-index: var(--z-toast);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; /* No align-items (defaults to stretch) so every toast shares one equal width */
gap: 10px; gap: 10px;
padding: 8px 20px 0; /* Small breathing room below the header */ padding: 8px 20px 0; /* Small breathing room below the header */
pointer-events: none; /* Allow clicking through the container */ pointer-events: none; /* Allow clicking through the container */
+33 -8
View File
@@ -25,6 +25,23 @@
box-shadow: var(--shadow-xs); box-shadow: var(--shadow-xs);
} }
/* Wrapper around the controls bar and breadcrumb nav. With the sticky-controls
setting off it is transparent to layout (display: contents), preserving the
original behavior (only the breadcrumb stays visible). When enabled, the whole
wrapper sticks as one unit so the two bars can never drift apart. */
.sticky-topbar {
display: contents;
}
body.sticky-controls .sticky-topbar {
display: block;
position: sticky;
top: 0;
z-index: calc(var(--z-header) - 1);
background: var(--bg-color);
box-shadow: var(--shadow-xs);
}
/* Responsive container for larger screens */ /* Responsive container for larger screens */
@media (min-width: 2150px) { @media (min-width: 2150px) {
.container { .container {
@@ -201,20 +218,17 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-left: 6px; min-width: 16px;
min-width: 18px; height: 16px;
height: 18px; padding: 0 4px;
padding: 0 5px; font-size: 10px;
font-size: 11px; font-weight: 500;
font-weight: 600;
line-height: 1; line-height: 1;
text-transform: uppercase; text-transform: uppercase;
border-radius: var(--border-radius-xs); border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg); background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border); border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
color: var(--shortcut-text); color: var(--shortcut-text);
vertical-align: middle;
opacity: 0.8; opacity: 0.8;
transition: var(--transition-base); transition: var(--transition-base);
} }
@@ -225,10 +239,21 @@
border-color: var(--shortcut-border-hover); border-color: var(--shortcut-border-hover);
} }
/* Invert the keycap on active (accent-filled) buttons for contrast.
Must come after the hover rule above so it wins on active+hover. */
.control-group button.active .shortcut-key,
.control-group button.active:hover .shortcut-key {
color: var(--lora-accent);
background: rgba(255, 255, 255, 0.92);
border-color: transparent;
opacity: 1;
}
/* Ensure correct vertical alignment for text+shortcut */ /* Ensure correct vertical alignment for text+shortcut */
.control-group button span { .control-group button span {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px;
} }
/* Select dropdown styling */ /* Select dropdown styling */
-33
View File
@@ -20,7 +20,6 @@ const RECIPE_ENDPOINTS = {
move: '/api/lm/recipe/move', move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk', moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete', bulkDelete: '/api/lm/recipes/bulk-delete',
repairBulk: '/api/lm/recipes/repair-bulk',
rematchBulk: '/api/lm/recipes/rematch-bulk', rematchBulk: '/api/lm/recipes/rematch-bulk',
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch', rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
}; };
@@ -678,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');
@@ -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;
@@ -93,10 +93,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 +293,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');
-71
View File
@@ -103,7 +103,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
} }
@@ -910,76 +909,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');
+10
View File
@@ -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);
} }
} }
+1
View File
@@ -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: '',
+2
View File
@@ -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' %}
-6
View File
@@ -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>
@@ -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'),
+2
View File
@@ -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' %}
+2
View File
@@ -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' %}
+3 -4
View File
@@ -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;">
@@ -253,126 +253,4 @@ describe('AutoComplete active-filters flag', () => {
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull(); expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
}); });
it('broadcasts a setting-toggled window event when /activefilters is accepted', async () => {
const events = [];
const listener = (event) => events.push(event.detail);
window.addEventListener('lora-manager:setting-toggled', listener);
try {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// The command token is cleared after acceptance; simulate the caret
// helper seeing the cleared input so the synthetic input event does
// not re-trigger command parsing (same pattern as behavior tests).
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await Promise.resolve();
await Promise.resolve();
expect(events).toContainEqual({
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
});
} finally {
window.removeEventListener('lora-manager:setting-toggled', listener);
}
});
it('removes a stale first-run hint when the toggle is switched on while the dropdown stays open', async () => {
localStorage.removeItem('lm:activefilters-tip-dismissed');
let enabled = false;
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') return enabled;
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: ['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,
});
const triggerShow = async () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
return autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
};
// OFF → the enable hint is shown in the suggestions dropdown.
expect(await triggerShow()).not.toBeNull();
// The node's filter chip toggles the setting ON while the dropdown is
// still open (ComfyUI can keep focus in the textarea, so no blur/hide
// fires). settings.js broadcasts the setting-toggled event.
enabled = true;
window.dispatchEvent(new CustomEvent('lora-manager:setting-toggled', {
detail: { settingId: 'loramanager.lora_active_filters_autocomplete', value: true },
}));
// The stale OFF hint must be gone even though the dropdown never closed.
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
// Further typing while ON must not resurrect the enable hint.
expect(await triggerShow()).toBeNull();
});
it('updates the command-list footer when the toggle changes while the command list is open', async () => {
let enabled = false;
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') return enabled;
return undefined;
});
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 }));
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
const footer = () => autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
expect(footer()).not.toBeNull();
expect(footer().textContent).toContain('Active Filters Search: OFF');
expect(footer().textContent).toContain('/activefilters to enable');
enabled = true;
window.dispatchEvent(new CustomEvent('lora-manager:setting-toggled', {
detail: { settingId: 'loramanager.lora_active_filters_autocomplete', value: true },
}));
expect(footer()).not.toBeNull();
expect(footer().textContent).toContain('Active Filters Search: ON');
expect(footer().textContent).toContain('/noactivefilters to disable');
});
}); });
@@ -54,7 +54,6 @@ const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({ vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete", "loramanager.lora_active_filters_autocomplete",
SETTING_TOGGLED_EVENT_NAME: "lora-manager:setting-toggled",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock, getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock, setLoraManagerSettingValue: setSettingValueMock,
})); }));
-1
View File
@@ -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},
@@ -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
-15
View File
@@ -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:
+60 -1
View File
@@ -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 == []
-327
View File
@@ -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"
+4 -12
View File
@@ -242,22 +242,14 @@ async def test_is_recipe_rematch_running_by_status(manager, status, expected):
assert manager.is_recipe_rematch_running() is expected assert manager.is_recipe_rematch_running() is expected
async def test_rematch_and_repair_channels_are_independent(manager): async def test_rematch_progress_channel_updates_and_cleans_up(manager):
# Rematch progress must not leak into the repair channel # Rematch progress is stored and reported as running while processing.
await manager.broadcast_recipe_rematch_progress({"status": "processing", "current": 1}) await manager.broadcast_recipe_rematch_progress({"status": "processing", "current": 1})
assert manager.is_recipe_rematch_running() is True assert manager.is_recipe_rematch_running() is True
assert manager.is_recipe_repair_running() is False
assert manager.get_recipe_repair_progress() is None
# Repair progress must not overwrite the rematch state
await manager.broadcast_recipe_repair_progress({"status": "processing", "current": 1})
assert manager.is_recipe_repair_running() is True
assert manager.is_recipe_rematch_running() is True
assert manager.get_recipe_rematch_progress() == {"status": "processing", "current": 1} assert manager.get_recipe_rematch_progress() == {"status": "processing", "current": 1}
# Cleaning the rematch channel must leave the repair channel untouched # Finished states clear on cleanup.
await manager.broadcast_recipe_rematch_progress({"status": "completed"}) await manager.broadcast_recipe_rematch_progress({"status": "completed"})
manager.cleanup_recipe_rematch_progress() manager.cleanup_recipe_rematch_progress()
assert manager.get_recipe_rematch_progress() is None assert manager.get_recipe_rematch_progress() is None
assert manager.get_recipe_repair_progress() == {"status": "processing", "current": 1} assert manager.is_recipe_rematch_running() is False
assert manager.is_recipe_repair_running() is True
-5
View File
@@ -40,7 +40,6 @@ def sample_recipes() -> List[Dict[str, Any]]:
"created_date": 1700000000.0, "created_date": 1700000000.0,
"modified": 1700000100.0, "modified": 1700000100.0,
"favorite": True, "favorite": True,
"repair_version": 3,
"preview_nsfw_level": 1, "preview_nsfw_level": 1,
"loras": [ "loras": [
{"hash": "hash1", "file_name": "lora1", "strength": 0.8}, {"hash": "hash1", "file_name": "lora1", "strength": 0.8},
@@ -60,7 +59,6 @@ def sample_recipes() -> List[Dict[str, Any]]:
"created_date": 1700000200.0, "created_date": 1700000200.0,
"modified": 1700000300.0, "modified": 1700000300.0,
"favorite": False, "favorite": False,
"repair_version": 2,
"preview_nsfw_level": 0, "preview_nsfw_level": 0,
"loras": [{"hash": "hash3", "file_name": "lora3", "strength": 0.5}], "loras": [{"hash": "hash3", "file_name": "lora3", "strength": 0.5}],
"gen_params": {"prompt": "another prompt"}, "gen_params": {"prompt": "another prompt"},
@@ -101,7 +99,6 @@ class TestPersistentRecipeCache:
assert r1["base_model"] == "SD1.5" assert r1["base_model"] == "SD1.5"
assert r1["fingerprint"] == "abc123" assert r1["fingerprint"] == "abc123"
assert r1["favorite"] is True assert r1["favorite"] is True
assert r1["repair_version"] == 3
assert len(r1["loras"]) == 2 assert len(r1["loras"]) == 2
assert r1["loras"][0]["hash"] == "hash1" assert r1["loras"][0]["hash"] == "hash1"
assert r1["checkpoint"]["name"] == "model.safetensors" assert r1["checkpoint"]["name"] == "model.safetensors"
@@ -164,7 +161,6 @@ class TestPersistentRecipeCache:
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,
@@ -710,7 +706,6 @@ class TestHasWorkflowColumn:
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,
@@ -27,18 +27,6 @@
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
</svg> </svg>
</button> </button>
<button
v-if="isLorasMode"
type="button"
class="active-filters-toggle"
:class="{ 'is-active': activeFiltersEnabled }"
:title="activeFiltersToggleTitle"
@click="toggleActiveFiltersSearch"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
</svg>
</button>
</div> </div>
</div> </div>
</template> </template>
@@ -46,8 +34,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useAutocomplete } from '@/composables/useAutocomplete' import { useAutocomplete } from '@/composables/useAutocomplete'
// @ts-ignore - ComfyUI external module
import { LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID, SETTING_TOGGLED_EVENT_NAME, getLoraActiveFiltersAutocompletePreference, setLoraManagerSettingValue } from '../../../web/comfyui/settings.js'
// Access LiteGraph global for initial mode detection // Access LiteGraph global for initial mode detection
declare const LiteGraph: { vueNodesMode?: boolean } | undefined declare const LiteGraph: { vueNodesMode?: boolean } | undefined
@@ -87,10 +73,10 @@ const inputWrapperRef = ref<HTMLElement | null>(null)
// Width of the textarea's own vertical scrollbar gutter. When the content // Width of the textarea's own vertical scrollbar gutter. When the content
// overflows and a classic (non-overlay) scrollbar is shown, the scrollbar // overflows and a classic (non-overlay) scrollbar is shown, the scrollbar
// occupies the textarea's rightmost pixels and the absolutely-positioned // occupies the textarea's rightmost pixels and the absolutely-positioned
// corner buttons (clear x / active-filters filter) would overlap it. We // corner clear (x) button would overlap it. We expose this width as a CSS
// expose this width as a CSS var so those buttons can shift left of the // var so the button can shift left of the scrollbar; it is 0 when there is
// scrollbar; it is 0 when there is no scrollbar (content fits, or platform // no scrollbar (content fits, or platform overlay scrollbars that float
// overlay scrollbars that float over the content). // over the content).
const vScrollbarWidth = ref(0) const vScrollbarWidth = ref(0)
let scrollbarResizeObserver: ResizeObserver | null = null let scrollbarResizeObserver: ResizeObserver | null = null
@@ -125,48 +111,6 @@ const hasText = ref(false)
// Show clear button when there is text // Show clear button when there is text
const showClearButton = computed(() => hasText.value) const showClearButton = computed(() => hasText.value)
// Active-filters search indicator (loras nodes only). Mirrors the
// loramanager.lora_active_filters_autocomplete setting so users can
// discover and toggle the /activefilters mode without opening the
// dropdown or the settings dialog.
const isLorasMode = (props.modelType ?? 'loras') === 'loras'
const activeFiltersEnabled = ref(false)
const refreshActiveFiltersState = () => {
if (isLorasMode) {
activeFiltersEnabled.value = getLoraActiveFiltersAutocompletePreference()
}
}
const onSettingToggled = (event: Event) => {
const detail = (event as CustomEvent<{ settingId?: string; value?: unknown }>).detail
if (detail?.settingId === LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
activeFiltersEnabled.value = detail.value === true
}
}
const activeFiltersToggleTitle = computed(() =>
activeFiltersEnabled.value
? 'Active Filters Search is ON: suggestions respect the LoRA Manager page filters. Click to disable, or type /noactivefilters.'
: 'Active Filters Search is OFF: suggestions search the full library. Click to enable, or type /activefilters.'
)
const toggleActiveFiltersSearch = async () => {
const newValue = !activeFiltersEnabled.value
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
)
if (!success) {
throw new Error('settings API unavailable')
}
activeFiltersEnabled.value = newValue
} catch (error) {
console.error('[Lora Manager] Failed to toggle active filters search:', error)
}
}
// Initialize autocomplete with direct ref access // Initialize autocomplete with direct ref access
useAutocomplete( useAutocomplete(
textareaRef, textareaRef,
@@ -256,6 +200,9 @@ const onWheel = (event: WheelEvent) => {
// Handle external value changes (e.g., from "send lora to workflow") // Handle external value changes (e.g., from "send lora to workflow")
const onExternalValueChange = () => { const onExternalValueChange = () => {
// The DOM value was set synchronously by the caller; the new content may
// have toggled the vertical scrollbar on/off.
updateVScrollbarWidth()
updateHasTextState() updateHasTextState()
} }
@@ -265,6 +212,9 @@ const setupWidgetOnSetValue = () => {
props.widget.onSetValue = (value: string) => { props.widget.onSetValue = (value: string) => {
// The DOM value is already set by setValue, just update our state // The DOM value is already set by setValue, just update our state
hasText.value = value.length > 0 hasText.value = value.length > 0
// Programmatic sets can toggle the scrollbar; re-measure so the
// corner clear button stays clear of it.
updateVScrollbarWidth()
} }
} }
} }
@@ -355,11 +305,6 @@ onMounted(() => {
// Setup widget.onSetValue callback // Setup widget.onSetValue callback
setupWidgetOnSetValue() setupWidgetOnSetValue()
// Active-filters indicator: read initial state and stay in sync with
// slash-command / context-menu toggles dispatched via settings.js
refreshActiveFiltersState()
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled)
// Keep the corner buttons clear of the textarea's vertical scrollbar. // Keep the corner buttons clear of the textarea's vertical scrollbar.
updateVScrollbarWidth() updateVScrollbarWidth()
observeScrollbarWidth() observeScrollbarWidth()
@@ -391,7 +336,6 @@ onUnmounted(() => {
// Remove event listener // Remove event listener
document.removeEventListener('lora-manager:vue-mode-change', onModeChange) document.removeEventListener('lora-manager:vue-mode-change', onModeChange)
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled)
}) })
</script> </script>
@@ -484,58 +428,6 @@ onUnmounted(() => {
height: 12px; height: 12px;
} }
/* Active-filters search indicator (loras nodes only) */
.active-filters-toggle {
position: absolute;
top: 3px;
right: calc(3px + var(--lm-vscrollbar-width, 0px));
width: 16px;
height: 16px;
padding: 2px;
margin: 0;
border: none;
border-radius: 4px;
background: rgba(128, 128, 128, 0.25);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
z-index: 10;
}
.active-filters-toggle:hover {
opacity: 1;
background: rgba(128, 128, 128, 0.45);
color: rgba(255, 255, 255, 0.85);
}
.active-filters-toggle.is-active {
background: rgba(59, 130, 246, 0.35);
color: #7db8ff;
opacity: 1;
}
.active-filters-toggle svg {
width: 11px;
height: 11px;
}
/* Vue DOM mode adjustments for the indicator */
.text-input.vue-dom-mode ~ .active-filters-toggle {
top: 8px;
right: calc(8px + var(--lm-vscrollbar-width, 0px));
width: 20px;
height: 20px;
}
.text-input.vue-dom-mode ~ .active-filters-toggle svg {
width: 13px;
height: 13px;
}
/* Vue DOM mode adjustments for clear button */ /* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button { .text-input.vue-dom-mode ~ .clear-button {
right: calc(8px + var(--lm-vscrollbar-width, 0px)); right: calc(8px + var(--lm-vscrollbar-width, 0px));
@@ -10,13 +10,13 @@
import { nextTick } from 'vue' import { nextTick } from 'vue'
import { shallowMount } from '@vue/test-utils' import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest' import { describe, expect, it, vi, afterEach } from 'vitest'
import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue' import AutocompleteTextWidget from '@/components/AutocompleteTextWidget.vue'
function createMockWidget() { function createMockWidget() {
return { return {
callback: vi.fn(), callback: vi.fn(),
onSetValue: undefined, onSetValue: undefined as ((v: string) => void) | undefined,
inputEl: undefined, inputEl: undefined,
metadataWidget: undefined, metadataWidget: undefined,
name: 'text', name: 'text',
@@ -135,121 +135,15 @@ describe('AutocompleteTextWidget clear button', () => {
}) })
}) })
/**
* Tests for the active-filters search indicator (loras mode only).
*
* The small filter chip in the textarea corner mirrors the
* loramanager.lora_active_filters_autocomplete setting: it reflects the
* current state, can toggle it, and stays in sync with slash-command /
* context-menu toggles via the lora-manager:setting-toggled window event.
*/
const settingsMocks = vi.hoisted(() => ({
getPreference: vi.fn(),
setValue: vi.fn(),
}))
vi.mock('../../../web/comfyui/settings.js', () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
'loramanager.lora_active_filters_autocomplete',
SETTING_TOGGLED_EVENT_NAME: 'lora-manager:setting-toggled',
getLoraActiveFiltersAutocompletePreference: settingsMocks.getPreference,
setLoraManagerSettingValue: settingsMocks.setValue,
}))
const getActiveFiltersPreferenceMock = settingsMocks.getPreference
const setSettingValueMock = settingsMocks.setValue
function mountLorasWidget() {
const widget = createMockWidget()
const node = { id: 1 }
const wrapper = shallowMount(AutocompleteTextWidget, {
props: { widget, node, modelType: 'loras' },
attachTo: document.body,
})
return { wrapper, widget }
}
describe('AutocompleteTextWidget active-filters indicator', () => {
beforeEach(() => {
getActiveFiltersPreferenceMock.mockReset()
getActiveFiltersPreferenceMock.mockReturnValue(false)
setSettingValueMock.mockReset()
setSettingValueMock.mockResolvedValue(true)
})
it('renders only in loras mode', () => {
const loras = mountLorasWidget()
expect(loras.wrapper.find('.active-filters-toggle').exists()).toBe(true)
const widget = createMockWidget()
const prompt = shallowMount(AutocompleteTextWidget, {
props: { widget, node: { id: 2 }, modelType: 'prompt' },
attachTo: document.body,
})
expect(prompt.find('.active-filters-toggle').exists()).toBe(false)
})
it('reflects the current setting state', async () => {
const { wrapper } = mountLorasWidget()
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).not.toContain('is-active')
getActiveFiltersPreferenceMock.mockReturnValue(true)
const wrapper2 = mountLorasWidget().wrapper
await nextTick()
expect(wrapper2.find('.active-filters-toggle').classes()).toContain('is-active')
})
it('toggles the setting when clicked', async () => {
const { wrapper } = mountLorasWidget()
await nextTick()
await wrapper.find('.active-filters-toggle').trigger('click')
expect(setSettingValueMock).toHaveBeenCalledWith(
'loramanager.lora_active_filters_autocomplete',
true
)
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).toContain('is-active')
})
it('stays in sync with setting-toggled window events', async () => {
const { wrapper } = mountLorasWidget()
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).not.toContain('is-active')
window.dispatchEvent(
new CustomEvent('lora-manager:setting-toggled', {
detail: {
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
},
})
)
await nextTick()
expect(wrapper.find('.active-filters-toggle').classes()).toContain('is-active')
window.dispatchEvent(
new CustomEvent('lora-manager:setting-toggled', {
detail: { settingId: 'loramanager.some_other_setting', value: true },
})
)
await nextTick()
// Unrelated settings must not flip the indicator
expect(wrapper.find('.active-filters-toggle').classes()).toContain('is-active')
})
})
/** /**
* Tests for the vertical-scrollbar inset. * Tests for the vertical-scrollbar inset.
* *
* When the textarea content overflows and a classic (non-overlay) scrollbar * When the textarea content overflows and a classic (non-overlay) scrollbar
* is shown, the absolutely-positioned corner buttons (clear x, active-filters * is shown, the absolutely-positioned corner clear (x) button would sit on
* filter chip) would sit on top of the scrollbar. The component measures the * top of the scrollbar. The component measures the scrollbar gutter and
* scrollbar gutter and exposes it as the --lm-vscrollbar-width CSS var on * exposes it as the --lm-vscrollbar-width CSS var on .input-wrapper so the
* .input-wrapper so the buttons shift left of the scrollbar. jsdom does no * button shifts left of the scrollbar. jsdom does no layout, so overflow is
* layout, so overflow is simulated by overriding the scroll/dimension props. * simulated by overriding the scroll/dimension props.
*/ */
describe('AutocompleteTextWidget vertical scrollbar inset', () => { describe('AutocompleteTextWidget vertical scrollbar inset', () => {
function overrideTextareaMetrics( function overrideTextareaMetrics(
@@ -322,4 +216,66 @@ describe('AutocompleteTextWidget vertical scrollbar inset', () => {
await nextTick() await nextTick()
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px') expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('0px')
}) })
it('re-measures the inset when the value is set programmatically via onSetValue', async () => {
const { wrapper, widget } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
// Start with no overflow
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
await nextTick()
// Simulate an external setValue (e.g. "send lora to workflow"): the DOM
// value is set by the caller and widget.onSetValue fires without an
// input event. Content now overflows → inset must be re-measured.
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305, // 15px scrollbar gutter
})
if (!widget.onSetValue) throw new Error('onSetValue not installed by component')
widget.onSetValue('long content')
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
})
it('re-measures the inset on external value-change events', async () => {
const { wrapper } = mountWidget()
const textarea = wrapper.find('textarea').element as HTMLTextAreaElement
// Start with no overflow
overrideTextareaMetrics(textarea, {
scrollHeight: 100,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 320,
})
await nextTick()
// The lora-manager:autocomplete-value-changed event fires when the
// widget value is set externally; content now overflows → re-measure.
overrideTextareaMetrics(textarea, {
scrollHeight: 200,
clientHeight: 100,
offsetWidth: 320,
clientWidth: 305, // 15px scrollbar gutter
})
textarea.dispatchEvent(
new CustomEvent('lora-manager:autocomplete-value-changed', {
detail: { value: 'long content' },
})
)
await nextTick()
const wrapperEl = wrapper.find('.input-wrapper').element as HTMLElement
expect(wrapperEl.style.getPropertyValue('--lm-vscrollbar-width')).toBe('15px')
})
}) })
+78 -9
View File
@@ -1,12 +1,86 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js' import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'
import { resolve } from 'path' import { dirname, resolve } from 'path'
// Specifiers that must stay external. The bundle is emitted to
// web/comfyui/vue-widgets/, and ComfyUI serves that directory's parent
// (web/comfyui) at /extensions/ComfyUI-Lora-Manager/, so one "../" from the
// bundle reaches web/comfyui modules and three "../../.." reach ComfyUI's
// own runtime scripts at runtime.
//
// scripts/app.js and scripts/api.js are intentionally NOT listed here: they
// are externalized by the keep-runtime-modules-external plugin below, which
// also rewrites the shallower "../../scripts/*" specifiers used by modules
// inlined from web/comfyui/ so every binding dedupes into a single import.
const EXTERNAL_SPECIFIERS = [
'../loras_widget.js',
'../autocomplete.js',
'../preview_tooltip.js'
]
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
vue(), vue(),
cssInjectedByJsPlugin() // Inject CSS into JS for ComfyUI compatibility cssInjectedByJsPlugin(), // Inject CSS into JS for ComfyUI compatibility
// Keep shared runtime modules external instead of inlining them into
// the bundle. This guards against the inlined-shim bug class (the
// removed active-filters chip ended up writing settings to a dead
// in-memory store this way):
//
// 1. Modules under web/comfyui/ import the repo-root scripts/app.js
// TEST SHIM as "../../scripts/app.js" — a depth that resolves to
// the shim on the build filesystem but 404s at the bundle's
// runtime location. Rewrite to the canonical bundle-depth
// specifier so every app/api binding in the bundle is the REAL
// ComfyUI module.
// 2. web/comfyui/settings.js must never be duplicated into the
// bundle: it registers settings via a module-level side effect
// and owns module state. Externalize it to "../settings.js" so
// the bundle binds to the SAME vanilla module instance that the
// ComfyUI extension loader already loaded.
{
name: 'lora-manager:keep-runtime-modules-external',
enforce: 'pre',
resolveId(source, importer) {
const scriptsMatch = source.match(/^(\.\.\/)+scripts\/(app|api)\.js$/)
if (scriptsMatch) {
return { id: `../../../scripts/${scriptsMatch[2]}.js`, external: true }
}
if (
importer &&
/[\\/]web[\\/]comfyui[\\/]settings\.js$/.test(
resolve(dirname(importer), source)
)
) {
return { id: '../settings.js', external: true }
}
return null
},
},
// Warning twin of the rewrite above: importing web/comfyui/* from
// widget source inlines that module into the bundle, duplicating any
// module-level side effects/state it owns. The settings.js and
// scripts/app|api.js imports are made safe by the plugin above, but
// review any further such import deliberately.
{
name: 'lora-manager:warn-web-comfyui-imports',
enforce: 'pre',
resolveId(source, importer) {
if (
importer &&
/[\\/]vue-widgets[\\/]src[\\/]/.test(importer) &&
/[\\/]web[\\/]comfyui[\\/]/.test(source)
) {
this.warn(
`[vue-widgets] Inlining web/comfyui module "${source}" into the bundle. ` +
'settings.js and scripts/app|api.js imports are externalized by this config, ' +
'but the inlined copy still duplicates module-level side effects — verify that is intended.'
)
}
return null
},
},
], ],
resolve: { resolve: {
alias: { alias: {
@@ -20,13 +94,7 @@ export default defineConfig({
fileName: 'lora-manager-widgets' fileName: 'lora-manager-widgets'
}, },
rollupOptions: { rollupOptions: {
external: [ external: EXTERNAL_SPECIFIERS,
'../../../scripts/app.js',
'../../../scripts/api.js',
'../loras_widget.js',
'../autocomplete.js',
'../preview_tooltip.js'
],
output: { output: {
dir: '../web/comfyui/vue-widgets', dir: '../web/comfyui/vue-widgets',
entryFileNames: 'lora-manager-widgets.js', entryFileNames: 'lora-manager-widgets.js',
@@ -41,3 +109,4 @@ export default defineConfig({
'process.env.NODE_ENV': JSON.stringify('production') 'process.env.NODE_ENV': JSON.stringify('production')
} }
}) })
+3 -49
View File
@@ -14,11 +14,9 @@ import {
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
getAutocompleteAutoFormatPreference, getAutocompleteAutoFormatPreference,
getAutocompleteAcceptKeyPreference, getAutocompleteAcceptKeyPreference,
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
getLoraActiveFiltersAutocompletePreference, getLoraActiveFiltersAutocompletePreference,
getPromptTagAutocompletePreference, getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference, getTagSpaceReplacementPreference,
SETTING_TOGGLED_EVENT_NAME,
setLoraManagerSettingValue, setLoraManagerSettingValue,
} from "./settings.js"; } from "./settings.js";
import { showToast } from "./utils.js"; import { showToast } from "./utils.js";
@@ -609,7 +607,6 @@ class AutoComplete {
this.onBlur = null; this.onBlur = null;
this.onDocumentClick = null; this.onDocumentClick = null;
this.onScroll = null; this.onScroll = null;
this.onSettingToggled = null;
this.init(); this.init();
} }
@@ -777,22 +774,6 @@ class AutoComplete {
}; };
document.addEventListener('click', this.onDocumentClick); document.addEventListener('click', this.onDocumentClick);
// React to setting changes that happen underneath an open dropdown
// (e.g. toggling the active-filters search from the node's filter
// chip can keep the dropdown open in ComfyUI). Refresh the state
// hints so they never show a message for the previous state.
if (this.onSettingToggled) {
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, this.onSettingToggled);
}
this.onSettingToggled = (e) => {
const detail = e && e.detail;
if (!detail || detail.settingId !== LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
return;
}
this._refreshStateHints();
};
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, this.onSettingToggled);
// Mark this element as having autocomplete events bound // Mark this element as having autocomplete events bound
this.inputElement._autocompleteEventsBound = true; this.inputElement._autocompleteEventsBound = true;
@@ -1830,37 +1811,15 @@ class AutoComplete {
} }
} }
/**
* Refresh state-dependent dropdown decorations (first-run Tip and the
* slash-command-list footer) from the live setting. Called when the
* active-filters toggle changes underneath an open dropdown, so the
* dropdown never keeps showing a message for the previous state.
*/
_refreshStateHints() {
if (!this.isVisible || this.modelType !== 'loras') {
return;
}
if (this.showingCommands) {
// Command list: footer text carries the ON/OFF state.
this._renderCommandListFooter();
} else {
// Plain suggestions: the first-run Tip advertises /activefilters
// only while the feature is disabled.
this._maybeShowFirstRunHint();
}
}
/** /**
* Show a one-time, dismissible hint inside the dropdown surfacing the * Show a one-time, dismissible hint inside the dropdown surfacing the
* toggle commands: prompt nodes advertise /noautocomplete, loras nodes * toggle commands: prompt nodes advertise /noautocomplete, loras nodes
* advertise /activefilters. Dismissal is persisted in localStorage. * advertise /activefilters. Dismissal is persisted in localStorage.
*/ */
_maybeShowFirstRunHint() { _maybeShowFirstRunHint() {
// Dropdown decorations can outlive a state change (the dropdown may if (this.firstRunHint) {
// stay open when the setting is toggled from the node, e.g. clicking return;
// the active-filters filter chip). Always recompute from the live }
// state so a stale Tip is never kept for the previous state.
this._removeFirstRunHint();
let hintText = null; let hintText = null;
let storageKey = null; let storageKey = null;
@@ -3219,11 +3178,6 @@ class AutoComplete {
this.onDocumentClick = null; this.onDocumentClick = null;
} }
if (this.onSettingToggled) {
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, this.onSettingToggled);
this.onSettingToggled = null;
}
if (this.onScroll && this.scrollContainer) { if (this.onScroll && this.scrollContainer) {
this.scrollContainer.removeEventListener('scroll', this.onScroll); this.scrollContainer.removeEventListener('scroll', this.onScroll);
this.onScroll = null; this.onScroll = null;
+1 -21
View File
@@ -175,42 +175,23 @@ const getPromptTagAutocompletePreference = (() => {
/** /**
* Persist a LoRA Manager setting through ComfyUI's setting API. * Persist a LoRA Manager setting through ComfyUI's setting API.
* Returns true when the setting was written successfully. * Returns true when the setting was written successfully.
*
* Every successful write broadcasts a "lora-manager:setting-toggled" window
* event (see SETTING_TOGGLED_EVENT_NAME) so widgets mirroring the setting
* (e.g. the active-filters indicator in the autocomplete text widget) stay in
* sync with slash-command / context-menu toggles.
*/ */
const SETTING_TOGGLED_EVENT_NAME = "lora-manager:setting-toggled";
const setLoraManagerSettingValue = async (settingId, value) => { const setLoraManagerSettingValue = async (settingId, value) => {
const settingManager = app?.extensionManager?.setting; const settingManager = app?.extensionManager?.setting;
if (settingManager && typeof settingManager.set === "function") { if (settingManager && typeof settingManager.set === "function") {
await settingManager.set(settingId, value); await settingManager.set(settingId, value);
_notifySettingToggled(settingId, value);
return true; return true;
} }
const setting = app?.ui?.settings?.settingsById?.[settingId]; const setting = app?.ui?.settings?.settingsById?.[settingId];
if (setting) { if (setting) {
app.ui.settings.setSettingValue(settingId, value); app.ui.settings.setSettingValue(settingId, value);
_notifySettingToggled(settingId, value);
return true; return true;
} }
return false; return false;
}; };
const _notifySettingToggled = (settingId, value) => {
try {
window.dispatchEvent(new CustomEvent(SETTING_TOGGLED_EVENT_NAME, {
detail: { settingId, value },
}));
} catch (error) {
// Best-effort notification; ignore non-browser environments
}
};
const getAutocompleteAppendCommaPreference = (() => { const getAutocompleteAppendCommaPreference = (() => {
let settingsUnavailableLogged = false; let settingsUnavailableLogged = false;
@@ -469,7 +450,7 @@ app.registerExtension({
name: "Search LoRA autocomplete within active filters", name: "Search LoRA autocomplete within active filters",
type: "boolean", type: "boolean",
defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT, defaultValue: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT,
tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. Commands /activefilters and /noactivefilters toggle this mode.", tooltip: "When enabled, LoRA autocomplete suggestions respect the active filters (folder/base model/tags) set in the LoRA Manager page. You can also toggle it by typing /activefilters or /noactivefilters in the LoRA field, or from the node's right-click menu.",
category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"], category: ["LoRA Manager", "Autocomplete", "LoRA Active Filters"],
}, },
{ {
@@ -617,7 +598,6 @@ app.registerExtension({
export { export {
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID, LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
SETTING_TOGGLED_EVENT_NAME,
getWheelSensitivity, getWheelSensitivity,
getAutoPathCorrectionPreference, getAutoPathCorrectionPreference,
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
+71 -234
View File
@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0; padding: 20px 0;
} }
.autocomplete-text-widget[data-v-22743258] { .autocomplete-text-widget[data-v-793d67d2] {
background: transparent; background: transparent;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
.input-wrapper[data-v-22743258] { .input-wrapper[data-v-793d67d2] {
position: relative; position: relative;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
} }
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */ /* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-22743258] { .text-input[data-v-793d67d2] {
flex: 1; flex: 1;
width: 100%; width: 100%;
background-color: var(--comfy-input-bg, #222); background-color: var(--comfy-input-bg, #222);
@@ -2152,7 +2152,7 @@ to { transform: rotate(360deg);
} }
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */ /* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-22743258] { .text-input.vue-dom-mode[data-v-793d67d2] {
background-color: var(--color-charcoal-400, #313235); background-color: var(--color-charcoal-400, #313235);
color: #fff; color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */ padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2161,12 +2161,12 @@ to { transform: rotate(360deg);
font-size: 12px; font-size: 12px;
font-family: inherit; font-family: inherit;
} }
.text-input[data-v-22743258]:focus { .text-input[data-v-793d67d2]:focus {
outline: none; outline: none;
} }
/* Clear button styles */ /* Clear button styles */
.clear-button[data-v-22743258] { .clear-button[data-v-793d67d2] {
position: absolute; position: absolute;
right: calc(6px + var(--lm-vscrollbar-width, 0px)); right: calc(6px + var(--lm-vscrollbar-width, 0px));
bottom: 6px; /* Changed from top to bottom */ bottom: 6px; /* Changed from top to bottom */
@@ -2189,79 +2189,31 @@ to { transform: rotate(360deg);
} }
/* Show clear button when hovering over input wrapper */ /* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-22743258] { .input-wrapper:hover .clear-button[data-v-793d67d2] {
opacity: 0.7; opacity: 0.7;
pointer-events: auto; pointer-events: auto;
} }
.clear-button[data-v-22743258]:hover { .clear-button[data-v-793d67d2]:hover {
opacity: 1; opacity: 1;
background: rgba(255, 100, 100, 0.8); background: rgba(255, 100, 100, 0.8);
} }
.clear-button svg[data-v-22743258] { .clear-button svg[data-v-793d67d2] {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
/* Active-filters search indicator (loras nodes only) */
.active-filters-toggle[data-v-22743258] {
position: absolute;
top: 3px;
right: calc(3px + var(--lm-vscrollbar-width, 0px));
width: 16px;
height: 16px;
padding: 2px;
margin: 0;
border: none;
border-radius: 4px;
background: rgba(128, 128, 128, 0.25);
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.7;
transition: opacity 0.2s ease, background-color 0.2s ease, color 0.2s ease;
z-index: 10;
}
.active-filters-toggle[data-v-22743258]:hover {
opacity: 1;
background: rgba(128, 128, 128, 0.45);
color: rgba(255, 255, 255, 0.85);
}
.active-filters-toggle.is-active[data-v-22743258] {
background: rgba(59, 130, 246, 0.35);
color: #7db8ff;
opacity: 1;
}
.active-filters-toggle svg[data-v-22743258] {
width: 11px;
height: 11px;
}
/* Vue DOM mode adjustments for the indicator */
.text-input.vue-dom-mode ~ .active-filters-toggle[data-v-22743258] {
top: 8px;
right: calc(8px + var(--lm-vscrollbar-width, 0px));
width: 20px;
height: 20px;
}
.text-input.vue-dom-mode ~ .active-filters-toggle svg[data-v-22743258] {
width: 13px;
height: 13px;
}
/* Vue DOM mode adjustments for clear button */ /* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-22743258] { .text-input.vue-dom-mode ~ .clear-button[data-v-793d67d2] {
right: calc(8px + var(--lm-vscrollbar-width, 0px)); right: calc(8px + var(--lm-vscrollbar-width, 0px));
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */ bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px; width: 20px;
height: 20px; height: 20px;
background: rgba(107, 114, 128, 0.6); background: rgba(107, 114, 128, 0.6);
} }
.text-input.vue-dom-mode ~ .clear-button[data-v-22743258]:hover { .text-input.vue-dom-mode ~ .clear-button[data-v-793d67d2]:hover {
background: oklch(62% 0.18 25); background: oklch(62% 0.18 25);
} }
.text-input.vue-dom-mode ~ .clear-button svg[data-v-22743258] { .text-input.vue-dom-mode ~ .clear-button svg[data-v-793d67d2] {
width: 14px; width: 14px;
height: 14px; height: 14px;
} }
@@ -2529,8 +2481,9 @@ to { transform: rotate(360deg);
} }
})(); })();
var _a; var _a;
import { app as app$1 } from "../../../scripts/app.js"; import { app } from "../../../scripts/app.js";
import { api as api$1 } from "../../../scripts/api.js"; import { api } from "../../../scripts/api.js";
import "../settings.js";
/** /**
* @vue/shared v3.5.26 * @vue/shared v3.5.26
* (c) 2018-present Yuxi (Evan) You and Vue contributors * (c) 2018-present Yuxi (Evan) You and Vue contributors
@@ -11100,7 +11053,7 @@ const _sfc_main$o = /* @__PURE__ */ defineComponent({
const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$o, [["__scopeId", "data-v-8da8aa4b"]]); const EditButton = /* @__PURE__ */ _export_sfc(_sfc_main$o, [["__scopeId", "data-v-8da8aa4b"]]);
const _hoisted_1$k = { class: "section" }; const _hoisted_1$k = { class: "section" };
const _hoisted_2$j = { class: "section__header" }; const _hoisted_2$j = { class: "section__header" };
const _hoisted_3$h = { class: "section__content" }; const _hoisted_3$g = { class: "section__content" };
const _hoisted_4$f = { const _hoisted_4$f = {
key: 0, key: 0,
class: "section__placeholder" class: "section__placeholder"
@@ -11130,7 +11083,7 @@ const _sfc_main$n = /* @__PURE__ */ defineComponent({
onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit")) onClick: _cache[0] || (_cache[0] = ($event) => _ctx.$emit("edit"))
}) })
]), ]),
createBaseVNode("div", _hoisted_3$h, [ createBaseVNode("div", _hoisted_3$g, [
__props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$f, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$d, [ __props.selected.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_4$f, " All models ")) : (openBlock(), createElementBlock("div", _hoisted_5$d, [
(openBlock(true), createElementBlock(Fragment, null, renderList(__props.selected, (name) => { (openBlock(true), createElementBlock(Fragment, null, renderList(__props.selected, (name) => {
return openBlock(), createBlock(FilterChip, { return openBlock(), createBlock(FilterChip, {
@@ -11149,7 +11102,7 @@ const _sfc_main$n = /* @__PURE__ */ defineComponent({
const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$n, [["__scopeId", "data-v-12f059e2"]]); const BaseModelSection = /* @__PURE__ */ _export_sfc(_sfc_main$n, [["__scopeId", "data-v-12f059e2"]]);
const _hoisted_1$j = { class: "section" }; const _hoisted_1$j = { class: "section" };
const _hoisted_2$i = { class: "section__columns" }; const _hoisted_2$i = { class: "section__columns" };
const _hoisted_3$g = { class: "section__column" }; const _hoisted_3$f = { class: "section__column" };
const _hoisted_4$e = { class: "section__column-header" }; const _hoisted_4$e = { class: "section__column-header" };
const _hoisted_5$c = { class: "section__column-content" }; const _hoisted_5$c = { class: "section__column-content" };
const _hoisted_6$c = { const _hoisted_6$c = {
@@ -11185,7 +11138,7 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "TAGS") createBaseVNode("span", { class: "section__title" }, "TAGS")
], -1)), ], -1)),
createBaseVNode("div", _hoisted_2$i, [ createBaseVNode("div", _hoisted_2$i, [
createBaseVNode("div", _hoisted_3$g, [ createBaseVNode("div", _hoisted_3$f, [
createBaseVNode("div", _hoisted_4$e, [ createBaseVNode("div", _hoisted_4$e, [
_cache[2] || (_cache[2] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)), _cache[2] || (_cache[2] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createVNode(EditButton, { createVNode(EditButton, {
@@ -11231,7 +11184,7 @@ const _sfc_main$m = /* @__PURE__ */ defineComponent({
const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-b869b780"]]); const TagsSection = /* @__PURE__ */ _export_sfc(_sfc_main$m, [["__scopeId", "data-v-b869b780"]]);
const _hoisted_1$i = { class: "section" }; const _hoisted_1$i = { class: "section" };
const _hoisted_2$h = { class: "section__columns" }; const _hoisted_2$h = { class: "section__columns" };
const _hoisted_3$f = { class: "section__column" }; const _hoisted_3$e = { class: "section__column" };
const _hoisted_4$d = { class: "section__column-header" }; const _hoisted_4$d = { class: "section__column-header" };
const _hoisted_5$b = { class: "section__content" }; const _hoisted_5$b = { class: "section__content" };
const _hoisted_6$b = { const _hoisted_6$b = {
@@ -11279,7 +11232,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "FOLDERS") createBaseVNode("span", { class: "section__title" }, "FOLDERS")
], -1)), ], -1)),
createBaseVNode("div", _hoisted_2$h, [ createBaseVNode("div", _hoisted_2$h, [
createBaseVNode("div", _hoisted_3$f, [ createBaseVNode("div", _hoisted_3$e, [
createBaseVNode("div", _hoisted_4$d, [ createBaseVNode("div", _hoisted_4$d, [
_cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)), _cache[3] || (_cache[3] = createBaseVNode("span", { class: "section__column-title section__column-title--include" }, "INCLUDE", -1)),
createBaseVNode("button", { createBaseVNode("button", {
@@ -11347,7 +11300,7 @@ const _sfc_main$l = /* @__PURE__ */ defineComponent({
const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-af9caf84"]]); const FoldersSection = /* @__PURE__ */ _export_sfc(_sfc_main$l, [["__scopeId", "data-v-af9caf84"]]);
const _hoisted_1$h = { class: "section" }; const _hoisted_1$h = { class: "section" };
const _hoisted_2$g = { class: "section__header" }; const _hoisted_2$g = { class: "section__header" };
const _hoisted_3$e = { class: "section__toggle" }; const _hoisted_3$d = { class: "section__toggle" };
const _hoisted_4$c = ["checked"]; const _hoisted_4$c = ["checked"];
const _hoisted_5$a = { class: "section__columns" }; const _hoisted_5$a = { class: "section__columns" };
const _hoisted_6$a = { class: "section__column" }; const _hoisted_6$a = { class: "section__column" };
@@ -11403,7 +11356,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
return openBlock(), createElementBlock("div", _hoisted_1$h, [ return openBlock(), createElementBlock("div", _hoisted_1$h, [
createBaseVNode("div", _hoisted_2$g, [ createBaseVNode("div", _hoisted_2$g, [
_cache[4] || (_cache[4] = createBaseVNode("span", { class: "section__title" }, "NAME PATTERNS", -1)), _cache[4] || (_cache[4] = createBaseVNode("span", { class: "section__title" }, "NAME PATTERNS", -1)),
createBaseVNode("label", _hoisted_3$e, [ createBaseVNode("label", _hoisted_3$d, [
createBaseVNode("input", { createBaseVNode("input", {
type: "checkbox", type: "checkbox",
checked: __props.useRegex, checked: __props.useRegex,
@@ -11487,7 +11440,7 @@ const _sfc_main$k = /* @__PURE__ */ defineComponent({
const NamePatternsSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-9995b5ed"]]); const NamePatternsSection = /* @__PURE__ */ _export_sfc(_sfc_main$k, [["__scopeId", "data-v-9995b5ed"]]);
const _hoisted_1$g = { class: "section" }; const _hoisted_1$g = { class: "section" };
const _hoisted_2$f = { class: "section__toggles" }; const _hoisted_2$f = { class: "section__toggles" };
const _hoisted_3$d = { class: "toggle-item" }; const _hoisted_3$c = { class: "toggle-item" };
const _hoisted_4$b = ["aria-checked"]; const _hoisted_4$b = ["aria-checked"];
const _hoisted_5$9 = { class: "toggle-item" }; const _hoisted_5$9 = { class: "toggle-item" };
const _hoisted_6$9 = ["aria-checked"]; const _hoisted_6$9 = ["aria-checked"];
@@ -11505,7 +11458,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
createBaseVNode("span", { class: "section__title" }, "LICENSE") createBaseVNode("span", { class: "section__title" }, "LICENSE")
], -1)), ], -1)),
createBaseVNode("div", _hoisted_2$f, [ createBaseVNode("div", _hoisted_2$f, [
createBaseVNode("label", _hoisted_3$d, [ createBaseVNode("label", _hoisted_3$c, [
_cache[3] || (_cache[3] = createBaseVNode("span", { _cache[3] || (_cache[3] = createBaseVNode("span", {
class: "toggle-item__label", class: "toggle-item__label",
title: "Use the model without crediting the creator" title: "Use the model without crediting the creator"
@@ -11545,7 +11498,7 @@ const _sfc_main$j = /* @__PURE__ */ defineComponent({
const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-07ddd3df"]]); const LicenseSection = /* @__PURE__ */ _export_sfc(_sfc_main$j, [["__scopeId", "data-v-07ddd3df"]]);
const _hoisted_1$f = { class: "preview" }; const _hoisted_1$f = { class: "preview" };
const _hoisted_2$e = { class: "preview__title" }; const _hoisted_2$e = { class: "preview__title" };
const _hoisted_3$c = ["disabled"]; const _hoisted_3$b = ["disabled"];
const _hoisted_4$a = { const _hoisted_4$a = {
key: 0, key: 0,
class: "preview__tooltip" class: "preview__tooltip"
@@ -11604,7 +11557,7 @@ const _sfc_main$i = /* @__PURE__ */ defineComponent({
d: "M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z" d: "M8 3c-1.552 0-2.94.707-3.857 1.818a.5.5 0 1 1-.771-.636A6.002 6.002 0 0 1 13.917 7H12.9A5.002 5.002 0 0 0 8 3zM3.1 9a5.002 5.002 0 0 0 8.757 2.182.5.5 0 1 1 .771.636A6.002 6.002 0 0 1 2.083 9H3.1z"
}) })
], -1) ], -1)
])], 10, _hoisted_3$c) ])], 10, _hoisted_3$b)
], 32), ], 32),
createVNode(Transition, { name: "tooltip" }, { createVNode(Transition, { name: "tooltip" }, {
default: withCtx(() => [ default: withCtx(() => [
@@ -11716,7 +11669,7 @@ const _sfc_main$h = /* @__PURE__ */ defineComponent({
const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$h, [["__scopeId", "data-v-83235a00"]]); const LoraPoolSummaryView = /* @__PURE__ */ _export_sfc(_sfc_main$h, [["__scopeId", "data-v-83235a00"]]);
const _hoisted_1$d = { class: "lora-pool-modal__header" }; const _hoisted_1$d = { class: "lora-pool-modal__header" };
const _hoisted_2$c = { class: "lora-pool-modal__title-container" }; const _hoisted_2$c = { class: "lora-pool-modal__title-container" };
const _hoisted_3$b = { class: "lora-pool-modal__title" }; const _hoisted_3$a = { class: "lora-pool-modal__title" };
const _hoisted_4$9 = { const _hoisted_4$9 = {
key: 0, key: 0,
class: "lora-pool-modal__subtitle" class: "lora-pool-modal__subtitle"
@@ -11776,7 +11729,7 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
}, [ }, [
createBaseVNode("div", _hoisted_1$d, [ createBaseVNode("div", _hoisted_1$d, [
createBaseVNode("div", _hoisted_2$c, [ createBaseVNode("div", _hoisted_2$c, [
createBaseVNode("h3", _hoisted_3$b, toDisplayString(__props.title), 1), createBaseVNode("h3", _hoisted_3$a, toDisplayString(__props.title), 1),
__props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_4$9, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true) __props.subtitle ? (openBlock(), createElementBlock("p", _hoisted_4$9, toDisplayString(__props.subtitle), 1)) : createCommentVNode("", true)
]), ]),
createBaseVNode("button", { createBaseVNode("button", {
@@ -11804,7 +11757,7 @@ const _sfc_main$g = /* @__PURE__ */ defineComponent({
const ModalWrapper = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-7b4de03d"]]); const ModalWrapper = /* @__PURE__ */ _export_sfc(_sfc_main$g, [["__scopeId", "data-v-7b4de03d"]]);
const _hoisted_1$c = { class: "search-container" }; const _hoisted_1$c = { class: "search-container" };
const _hoisted_2$b = { class: "model-list" }; const _hoisted_2$b = { class: "model-list" };
const _hoisted_3$a = ["checked", "onChange"]; const _hoisted_3$9 = ["checked", "onChange"];
const _hoisted_4$8 = { class: "model-checkbox-visual" }; const _hoisted_4$8 = { class: "model-checkbox-visual" };
const _hoisted_5$6 = { const _hoisted_5$6 = {
key: 0, key: 0,
@@ -11914,7 +11867,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
checked: isSelected(model.name), checked: isSelected(model.name),
onChange: ($event) => toggleModel(model.name), onChange: ($event) => toggleModel(model.name),
class: "model-checkbox" class: "model-checkbox"
}, null, 40, _hoisted_3$a), }, null, 40, _hoisted_3$9),
createBaseVNode("span", _hoisted_4$8, [ createBaseVNode("span", _hoisted_4$8, [
isSelected(model.name) ? (openBlock(), createElementBlock("svg", _hoisted_5$6, [..._cache[4] || (_cache[4] = [ isSelected(model.name) ? (openBlock(), createElementBlock("svg", _hoisted_5$6, [..._cache[4] || (_cache[4] = [
createBaseVNode("path", { d: "M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" }, null, -1) createBaseVNode("path", { d: "M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" }, null, -1)
@@ -11935,7 +11888,7 @@ const _sfc_main$f = /* @__PURE__ */ defineComponent({
const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$f, [["__scopeId", "data-v-e02ca44a"]]); const BaseModelModal = /* @__PURE__ */ _export_sfc(_sfc_main$f, [["__scopeId", "data-v-e02ca44a"]]);
const _hoisted_1$b = { class: "search-container" }; const _hoisted_1$b = { class: "search-container" };
const _hoisted_2$a = ["onClick"]; const _hoisted_2$a = ["onClick"];
const _hoisted_3$9 = { const _hoisted_3$8 = {
key: 0, key: 0,
class: "no-results" class: "no-results"
}; };
@@ -12079,7 +12032,7 @@ const _sfc_main$e = /* @__PURE__ */ defineComponent({
onClick: ($event) => toggleTag(tag.tag) onClick: ($event) => toggleTag(tag.tag)
}, toDisplayString(tag.tag), 11, _hoisted_2$a); }, toDisplayString(tag.tag), 11, _hoisted_2$a);
}), 128)), }), 128)),
visibleTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_3$9, " No tags found ")) : createCommentVNode("", true), visibleTags.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_3$8, " No tags found ")) : createCommentVNode("", true),
hasMoreTags.value ? (openBlock(), createElementBlock("div", _hoisted_4$7, " Scroll to load more... ")) : createCommentVNode("", true) hasMoreTags.value ? (openBlock(), createElementBlock("div", _hoisted_4$7, " Scroll to load more... ")) : createCommentVNode("", true)
], 544) ], 544)
]), ]),
@@ -12094,7 +12047,7 @@ const _hoisted_2$9 = {
key: 1, key: 1,
class: "tree-node__toggle-spacer" class: "tree-node__toggle-spacer"
}; };
const _hoisted_3$8 = { class: "tree-node__checkbox-label" }; const _hoisted_3$7 = { class: "tree-node__checkbox-label" };
const _hoisted_4$6 = ["checked"]; const _hoisted_4$6 = ["checked"];
const _hoisted_5$5 = { const _hoisted_5$5 = {
key: 0, key: 0,
@@ -12155,7 +12108,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
createBaseVNode("path", { d: "M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" }, null, -1) createBaseVNode("path", { d: "M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z" }, null, -1)
])], 2)) ])], 2))
])) : (openBlock(), createElementBlock("span", _hoisted_2$9)), ])) : (openBlock(), createElementBlock("span", _hoisted_2$9)),
createBaseVNode("label", _hoisted_3$8, [ createBaseVNode("label", _hoisted_3$7, [
createBaseVNode("input", { createBaseVNode("input", {
type: "checkbox", type: "checkbox",
class: "tree-node__checkbox", class: "tree-node__checkbox",
@@ -12201,7 +12154,7 @@ const _sfc_main$d = /* @__PURE__ */ defineComponent({
const FolderTreeNode = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-90187dd4"]]); const FolderTreeNode = /* @__PURE__ */ _export_sfc(_sfc_main$d, [["__scopeId", "data-v-90187dd4"]]);
const _hoisted_1$9 = { class: "search-container" }; const _hoisted_1$9 = { class: "search-container" };
const _hoisted_2$8 = { class: "folder-tree" }; const _hoisted_2$8 = { class: "folder-tree" };
const _hoisted_3$7 = { const _hoisted_3$6 = {
key: 1, key: 1,
class: "no-results" class: "no-results"
}; };
@@ -12300,7 +12253,7 @@ const _sfc_main$c = /* @__PURE__ */ defineComponent({
onToggleExpand: toggleExpand, onToggleExpand: toggleExpand,
onToggleSelect: toggleSelect onToggleSelect: toggleSelect
}, null, 8, ["node", "selected", "expanded", "variant"]); }, null, 8, ["node", "selected", "expanded", "variant"]);
}), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$7, " No folders found ")) }), 128)) : (openBlock(), createElementBlock("div", _hoisted_3$6, " No folders found "))
]) ])
]), ]),
_: 1 _: 1
@@ -12706,7 +12659,7 @@ const _sfc_main$b = /* @__PURE__ */ defineComponent({
const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$b, [["__scopeId", "data-v-ed73eab5"]]); const LoraPoolWidget = /* @__PURE__ */ _export_sfc(_sfc_main$b, [["__scopeId", "data-v-ed73eab5"]]);
const _hoisted_1$8 = { class: "last-used-preview" }; const _hoisted_1$8 = { class: "last-used-preview" };
const _hoisted_2$7 = { class: "last-used-preview__content" }; const _hoisted_2$7 = { class: "last-used-preview__content" };
const _hoisted_3$6 = ["src", "onError"]; const _hoisted_3$5 = ["src", "onError"];
const _hoisted_4$5 = { const _hoisted_4$5 = {
key: 1, key: 1,
class: "last-used-preview__thumb last-used-preview__thumb--placeholder" class: "last-used-preview__thumb last-used-preview__thumb--placeholder"
@@ -12758,7 +12711,7 @@ const _sfc_main$a = /* @__PURE__ */ defineComponent({
src: previewUrls.value[lora.name], src: previewUrls.value[lora.name],
class: "last-used-preview__thumb", class: "last-used-preview__thumb",
onError: ($event) => onImageError(lora.name) onError: ($event) => onImageError(lora.name)
}, null, 40, _hoisted_3$6)) : (openBlock(), createElementBlock("div", _hoisted_4$5, [..._cache[0] || (_cache[0] = [ }, null, 40, _hoisted_3$5)) : (openBlock(), createElementBlock("div", _hoisted_4$5, [..._cache[0] || (_cache[0] = [
createBaseVNode("svg", { createBaseVNode("svg", {
viewBox: "0 0 16 16", viewBox: "0 0 16 16",
fill: "currentColor" fill: "currentColor"
@@ -13199,7 +13152,7 @@ const _sfc_main$8 = /* @__PURE__ */ defineComponent({
const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$8, [["__scopeId", "data-v-e0c8dc9f"]]); const DualRangeSlider = /* @__PURE__ */ _export_sfc(_sfc_main$8, [["__scopeId", "data-v-e0c8dc9f"]]);
const _hoisted_1$5 = { class: "randomizer-settings" }; const _hoisted_1$5 = { class: "randomizer-settings" };
const _hoisted_2$5 = { class: "setting-section" }; const _hoisted_2$5 = { class: "setting-section" };
const _hoisted_3$5 = { class: "count-mode-tabs" }; const _hoisted_3$4 = { class: "count-mode-tabs" };
const _hoisted_4$4 = ["checked"]; const _hoisted_4$4 = ["checked"];
const _hoisted_5$3 = ["checked"]; const _hoisted_5$3 = ["checked"];
const _hoisted_6$3 = { class: "slider-container" }; const _hoisted_6$3 = { class: "slider-container" };
@@ -13264,7 +13217,7 @@ const _sfc_main$7 = /* @__PURE__ */ defineComponent({
], -1)), ], -1)),
createBaseVNode("div", _hoisted_2$5, [ createBaseVNode("div", _hoisted_2$5, [
_cache[20] || (_cache[20] = createBaseVNode("label", { class: "setting-label" }, "LoRA Count", -1)), _cache[20] || (_cache[20] = createBaseVNode("label", { class: "setting-label" }, "LoRA Count", -1)),
createBaseVNode("div", _hoisted_3$5, [ createBaseVNode("div", _hoisted_3$4, [
createBaseVNode("label", { createBaseVNode("label", {
class: normalizeClass(["count-mode-tab", { active: __props.countMode === "fixed" }]) class: normalizeClass(["count-mode-tab", { active: __props.countMode === "fixed" }])
}, [ }, [
@@ -13895,7 +13848,7 @@ const _sfc_main$6 = /* @__PURE__ */ defineComponent({
const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$6, [["__scopeId", "data-v-ca6e8cec"]]); const LoraRandomizerWidget = /* @__PURE__ */ _export_sfc(_sfc_main$6, [["__scopeId", "data-v-ca6e8cec"]]);
const _hoisted_1$4 = { class: "cycler-settings" }; const _hoisted_1$4 = { class: "cycler-settings" };
const _hoisted_2$4 = { class: "setting-section progress-section" }; const _hoisted_2$4 = { class: "setting-section progress-section" };
const _hoisted_3$4 = { class: "progress-label" }; const _hoisted_3$3 = { class: "progress-label" };
const _hoisted_4$3 = ["title"]; const _hoisted_4$3 = ["title"];
const _hoisted_5$2 = { class: "progress-counter" }; const _hoisted_5$2 = { class: "progress-counter" };
const _hoisted_6$2 = { class: "progress-index" }; const _hoisted_6$2 = { class: "progress-index" };
@@ -14020,7 +13973,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
class: normalizeClass(["progress-info", { disabled: __props.isPauseDisabled }]), class: normalizeClass(["progress-info", { disabled: __props.isPauseDisabled }]),
onClick: handleOpenSelector onClick: handleOpenSelector
}, [ }, [
createBaseVNode("span", _hoisted_3$4, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1), createBaseVNode("span", _hoisted_3$3, toDisplayString(__props.isWorkflowExecuting ? "Using LoRA:" : "Next LoRA:"), 1),
createBaseVNode("span", { createBaseVNode("span", {
class: normalizeClass(["progress-name clickable", { disabled: __props.isPauseDisabled, "no-lora": __props.isNoLora }]), class: normalizeClass(["progress-name clickable", { disabled: __props.isPauseDisabled, "no-lora": __props.isNoLora }]),
title: __props.currentLoraFilename title: __props.currentLoraFilename
@@ -14220,7 +14173,7 @@ const _sfc_main$5 = /* @__PURE__ */ defineComponent({
const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-f0663be4"]]); const LoraCyclerSettingsView = /* @__PURE__ */ _export_sfc(_sfc_main$5, [["__scopeId", "data-v-f0663be4"]]);
const _hoisted_1$3 = { class: "search-container" }; const _hoisted_1$3 = { class: "search-container" };
const _hoisted_2$3 = { class: "lora-list" }; const _hoisted_2$3 = { class: "lora-list" };
const _hoisted_3$3 = ["onMouseenter", "onClick"]; const _hoisted_3$2 = ["onMouseenter", "onClick"];
const _hoisted_4$2 = { class: "lora-index" }; const _hoisted_4$2 = { class: "lora-index" };
const _hoisted_5$1 = ["title"]; const _hoisted_5$1 = ["title"];
const _hoisted_6$1 = { const _hoisted_6$1 = {
@@ -14401,7 +14354,7 @@ const _sfc_main$4 = /* @__PURE__ */ defineComponent({
title: item.lora.file_name title: item.lora.file_name
}, toDisplayString(item.lora.file_name), 9, _hoisted_5$1), }, toDisplayString(item.lora.file_name), 9, _hoisted_5$1),
__props.currentIndex === item.index ? (openBlock(), createElementBlock("span", _hoisted_6$1, "Current")) : createCommentVNode("", true) __props.currentIndex === item.index ? (openBlock(), createElementBlock("span", _hoisted_6$1, "Current")) : createCommentVNode("", true)
], 42, _hoisted_3$3); ], 42, _hoisted_3$2);
}), 128)), }), 128)),
filteredList.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_7$1, " No LoRAs found ")) : createCommentVNode("", true) filteredList.value.length === 0 ? (openBlock(), createElementBlock("div", _hoisted_7$1, " No LoRAs found ")) : createCommentVNode("", true)
]) ])
@@ -15017,7 +14970,7 @@ const _hoisted_2$2 = {
class: "json-content", class: "json-content",
ref: "contentRef" ref: "contentRef"
}; };
const _hoisted_3$2 = ["innerHTML"]; const _hoisted_3$1 = ["innerHTML"];
const _hoisted_4$1 = { const _hoisted_4$1 = {
key: 1, key: 1,
class: "placeholder" class: "placeholder"
@@ -15112,7 +15065,7 @@ const _sfc_main$2 = /* @__PURE__ */ defineComponent({
hasMetadata.value ? (openBlock(), createElementBlock("pre", { hasMetadata.value ? (openBlock(), createElementBlock("pre", {
key: 0, key: 0,
innerHTML: highlightedJson.value innerHTML: highlightedJson.value
}, null, 8, _hoisted_3$2)) : (openBlock(), createElementBlock("div", _hoisted_4$1, "No metadata available")) }, null, 8, _hoisted_3$1)) : (openBlock(), createElementBlock("div", _hoisted_4$1, "No metadata available"))
], 512) ], 512)
]); ]);
}; };
@@ -15184,71 +15137,8 @@ function useAutocomplete(textareaRef, modelType = "loras", options = {}) {
refreshCaretHelper refreshCaretHelper
}; };
} }
const settingsStore = /* @__PURE__ */ new Map();
const app = {
extensionManager: {
setting: {
get: (id) => settingsStore.has(id) ? settingsStore.get(id) : void 0,
set: async (id, value) => {
settingsStore.set(id, value);
}
}
}
};
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID = "loramanager.lora_active_filters_autocomplete";
const LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT = false;
const SETTING_TOGGLED_EVENT_NAME = "lora-manager:setting-toggled";
const setLoraManagerSettingValue = async (settingId, value) => {
var _a2, _b, _c, _d;
const settingManager = (_a2 = app == null ? void 0 : app.extensionManager) == null ? void 0 : _a2.setting;
if (settingManager && typeof settingManager.set === "function") {
await settingManager.set(settingId, value);
_notifySettingToggled(settingId, value);
return true;
}
const setting = (_d = (_c = (_b = app == null ? void 0 : app.ui) == null ? void 0 : _b.settings) == null ? void 0 : _c.settingsById) == null ? void 0 : _d[settingId];
if (setting) {
app.ui.settings.setSettingValue(settingId, value);
_notifySettingToggled(settingId, value);
return true;
}
return false;
};
const _notifySettingToggled = (settingId, value) => {
try {
window.dispatchEvent(new CustomEvent(SETTING_TOGGLED_EVENT_NAME, {
detail: { settingId, value }
}));
} catch (error) {
}
};
const getLoraActiveFiltersAutocompletePreference = /* @__PURE__ */ (() => {
let settingsUnavailableLogged = false;
return () => {
var _a2;
const settingManager = (_a2 = app == null ? void 0 : app.extensionManager) == null ? void 0 : _a2.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default lora active filters autocomplete setting.");
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
try {
const value = settingManager.get(LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID);
return value ?? LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read lora active filters autocomplete setting, using default.", error);
settingsUnavailableLogged = true;
}
return LORA_ACTIVE_FILTERS_AUTOCOMPLETE_DEFAULT;
}
};
})();
const _hoisted_1$1 = { class: "autocomplete-text-widget" }; const _hoisted_1$1 = { class: "autocomplete-text-widget" };
const _hoisted_2$1 = ["placeholder", "spellcheck"]; const _hoisted_2$1 = ["placeholder", "spellcheck"];
const _hoisted_3$1 = ["title"];
const _sfc_main$1 = /* @__PURE__ */ defineComponent({ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
__name: "AutocompleteTextWidget", __name: "AutocompleteTextWidget",
props: { props: {
@@ -15297,37 +15187,6 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
}; };
const hasText = ref(false); const hasText = ref(false);
const showClearButton = computed(() => hasText.value); const showClearButton = computed(() => hasText.value);
const isLorasMode = (props.modelType ?? "loras") === "loras";
const activeFiltersEnabled = ref(false);
const refreshActiveFiltersState = () => {
if (isLorasMode) {
activeFiltersEnabled.value = getLoraActiveFiltersAutocompletePreference();
}
};
const onSettingToggled = (event) => {
const detail = event.detail;
if ((detail == null ? void 0 : detail.settingId) === LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID) {
activeFiltersEnabled.value = detail.value === true;
}
};
const activeFiltersToggleTitle = computed(
() => activeFiltersEnabled.value ? "Active Filters Search is ON: suggestions respect the LoRA Manager page filters. Click to disable, or type /noactivefilters." : "Active Filters Search is OFF: suggestions search the full library. Click to enable, or type /activefilters."
);
const toggleActiveFiltersSearch = async () => {
const newValue = !activeFiltersEnabled.value;
try {
const success = await setLoraManagerSettingValue(
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID,
newValue
);
if (!success) {
throw new Error("settings API unavailable");
}
activeFiltersEnabled.value = newValue;
} catch (error) {
console.error("[Lora Manager] Failed to toggle active filters search:", error);
}
};
useAutocomplete( useAutocomplete(
textareaRef, textareaRef,
props.modelType ?? "loras", props.modelType ?? "loras",
@@ -15380,12 +15239,14 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
} }
}; };
const onExternalValueChange = () => { const onExternalValueChange = () => {
updateVScrollbarWidth();
updateHasTextState(); updateHasTextState();
}; };
const setupWidgetOnSetValue = () => { const setupWidgetOnSetValue = () => {
if (props.widget) { if (props.widget) {
props.widget.onSetValue = (value) => { props.widget.onSetValue = (value) => {
hasText.value = value.length > 0; hasText.value = value.length > 0;
updateVScrollbarWidth();
}; };
} }
}; };
@@ -15435,8 +15296,6 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
props.widget.callback(textareaRef.value.value); props.widget.callback(textareaRef.value.value);
} }
setupWidgetOnSetValue(); setupWidgetOnSetValue();
refreshActiveFiltersState();
window.addEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled);
updateVScrollbarWidth(); updateVScrollbarWidth();
observeScrollbarWidth(); observeScrollbarWidth();
document.addEventListener("lora-manager:vue-mode-change", onModeChange); document.addEventListener("lora-manager:vue-mode-change", onModeChange);
@@ -15456,7 +15315,6 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
props.widget.onSetValue = void 0; props.widget.onSetValue = void 0;
} }
document.removeEventListener("lora-manager:vue-mode-change", onModeChange); document.removeEventListener("lora-manager:vue-mode-change", onModeChange);
window.removeEventListener(SETTING_TOGGLED_EVENT_NAME, onSettingToggled);
}); });
return (_ctx, _cache) => { return (_ctx, _cache) => {
return openBlock(), createElementBlock("div", _hoisted_1$1, [ return openBlock(), createElementBlock("div", _hoisted_1$1, [
@@ -15503,29 +15361,13 @@ const _sfc_main$1 = /* @__PURE__ */ defineComponent({
y2: "18" y2: "18"
}) })
], -1) ], -1)
])])) : createCommentVNode("", true), ])])) : createCommentVNode("", true)
isLorasMode ? (openBlock(), createElementBlock("button", {
key: 1,
type: "button",
class: normalizeClass(["active-filters-toggle", { "is-active": activeFiltersEnabled.value }]),
title: activeFiltersToggleTitle.value,
onClick: toggleActiveFiltersSearch
}, [..._cache[1] || (_cache[1] = [
createBaseVNode("svg", {
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
"stroke-width": "2"
}, [
createBaseVNode("polygon", { points: "22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" })
], -1)
])], 10, _hoisted_3$1)) : createCommentVNode("", true)
], 4) ], 4)
]); ]);
}; };
} }
}); });
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-22743258"]]); const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main$1, [["__scopeId", "data-v-793d67d2"]]);
const _hoisted_1 = { class: "lora-info-tabs" }; const _hoisted_1 = { class: "lora-info-tabs" };
const _hoisted_2 = { class: "tab-content notes-tab" }; const _hoisted_2 = { class: "tab-content notes-tab" };
const _hoisted_3 = { class: "info-field" }; const _hoisted_3 = { class: "info-field" };
@@ -15942,11 +15784,6 @@ function createModeChangeCallback(node, updateDownstreamLoaders2, nodeSpecificCa
updateDownstreamLoaders2(node); updateDownstreamLoaders2(node);
}; };
} }
const api = {
fetchApi: (...args) => fetch(...args),
addEventListener: (eventName, handler) => document.addEventListener(eventName, handler),
removeEventListener: (eventName, handler) => document.removeEventListener(eventName, handler)
};
let _loraSyntaxFormatCache = null; let _loraSyntaxFormatCache = null;
let _loraSyntaxFormatRefreshPromise = null; let _loraSyntaxFormatRefreshPromise = null;
async function _fetchLoraSyntaxFormat() { async function _fetchLoraSyntaxFormat() {
@@ -16303,8 +16140,8 @@ const AUTOCOMPLETE_TEXT_MIN_WIDTH_DEFAULT = 400;
const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300; const AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT = 300;
const AUTOCOMPLETE_METADATA_VERSION = 1; const AUTOCOMPLETE_METADATA_VERSION = 1;
const LORA_MANAGER_WIDGET_IDS_PROPERTY = "__lm_widget_ids"; const LORA_MANAGER_WIDGET_IDS_PROPERTY = "__lm_widget_ids";
const originalGraphToPrompt = app$1.graphToPrompt.bind(app$1); const originalGraphToPrompt = app.graphToPrompt.bind(app);
app$1.graphToPrompt = async (...args) => { app.graphToPrompt = async (...args) => {
const result = await originalGraphToPrompt(...args); const result = await originalGraphToPrompt(...args);
stripAutocompleteMetadataFromPromptResult(result); stripAutocompleteMetadataFromPromptResult(result);
return result; return result;
@@ -16313,7 +16150,7 @@ function forwardMiddleMouseToCanvas(container) {
if (!container) return; if (!container) return;
container.addEventListener("pointerdown", (event) => { container.addEventListener("pointerdown", (event) => {
if (event.button === 1) { if (event.button === 1) {
const canvas = app$1.canvas; const canvas = app.canvas;
if (canvas && typeof canvas.processMouseDown === "function") { if (canvas && typeof canvas.processMouseDown === "function") {
canvas.processMouseDown(event); canvas.processMouseDown(event);
} }
@@ -16321,7 +16158,7 @@ function forwardMiddleMouseToCanvas(container) {
}); });
container.addEventListener("pointermove", (event) => { container.addEventListener("pointermove", (event) => {
if ((event.buttons & 4) === 4) { if ((event.buttons & 4) === 4) {
const canvas = app$1.canvas; const canvas = app.canvas;
if (canvas && typeof canvas.processMouseMove === "function") { if (canvas && typeof canvas.processMouseMove === "function") {
canvas.processMouseMove(event); canvas.processMouseMove(event);
} }
@@ -16329,7 +16166,7 @@ function forwardMiddleMouseToCanvas(container) {
}); });
container.addEventListener("pointerup", (event) => { container.addEventListener("pointerup", (event) => {
if (event.button === 1) { if (event.button === 1) {
const canvas = app$1.canvas; const canvas = app.canvas;
if (canvas && typeof canvas.processMouseUp === "function") { if (canvas && typeof canvas.processMouseUp === "function") {
canvas.processMouseUp(event); canvas.processMouseUp(event);
} }
@@ -16447,7 +16284,7 @@ function createLoraRandomizerWidget(node) {
const vueApp = createApp(LoraRandomizerWidget, { const vueApp = createApp(LoraRandomizerWidget, {
widget, widget,
node, node,
api: api$1 api
}); });
vueApp.use(PrimeVue, { vueApp.use(PrimeVue, {
unstyled: true, unstyled: true,
@@ -16522,7 +16359,7 @@ function createLoraCyclerWidget(node) {
const vueApp = createApp(LoraCyclerWidget, { const vueApp = createApp(LoraCyclerWidget, {
widget, widget,
node, node,
api: api$1 api
}); });
vueApp.use(PrimeVue, { vueApp.use(PrimeVue, {
unstyled: true, unstyled: true,
@@ -16710,13 +16547,13 @@ function applyAutocompleteTextLayoutFix(widget, _container, isVueMode) {
} }
const initVueDomModeListener = () => { const initVueDomModeListener = () => {
var _a2, _b; var _a2, _b;
if ((_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) { if ((_b = (_a2 = app.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.addEventListener) {
app$1.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => { app.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", () => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
var _a3, _b2, _c, _d, _e2, _f; var _a3, _b2, _c, _d, _e2, _f;
const isVueDomMode = ((_c = (_b2 = (_a3 = app$1.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false; const isVueDomMode = ((_c = (_b2 = (_a3 = app.ui) == null ? void 0 : _a3.settings) == null ? void 0 : _b2.getSettingValue) == null ? void 0 : _c.call(_b2, "Comfy.VueNodes.Enabled")) ?? false;
if ((_d = app$1.graph) == null ? void 0 : _d.nodes) { if ((_d = app.graph) == null ? void 0 : _d.nodes) {
for (const node of app$1.graph.nodes) { for (const node of app.graph.nodes) {
const textWidget = (_e2 = node.widgets) == null ? void 0 : _e2.find( const textWidget = (_e2 = node.widgets) == null ? void 0 : _e2.find(
(w2) => w2.type === "AUTOCOMPLETE_TEXT_LORAS" (w2) => w2.type === "AUTOCOMPLETE_TEXT_LORAS"
); );
@@ -16731,7 +16568,7 @@ const initVueDomModeListener = () => {
const grid = nodeEl.querySelector('[data-testid="node-widgets"]'); const grid = nodeEl.querySelector('[data-testid="node-widgets"]');
if (!grid) continue; if (!grid) continue;
const nodeId = nodeEl.getAttribute("data-node-id"); const nodeId = nodeEl.getAttribute("data-node-id");
const node = (_a4 = app$1.graph) == null ? void 0 : _a4.getNodeById(nodeId); const node = (_a4 = app.graph) == null ? void 0 : _a4.getNodeById(nodeId);
if (!node) continue; if (!node) continue;
const rows = []; const rows = [];
let needsFix = false; let needsFix = false;
@@ -16752,7 +16589,7 @@ const initVueDomModeListener = () => {
} }
} }
}); });
(_f = app$1.canvas) == null ? void 0 : _f.setDirty(true, true); (_f = app.canvas) == null ? void 0 : _f.setDirty(true, true);
document.dispatchEvent(new CustomEvent("lora-manager:vue-mode-change", { document.dispatchEvent(new CustomEvent("lora-manager:vue-mode-change", {
detail: { isVueDomMode } detail: { isVueDomMode }
})); }));
@@ -16760,12 +16597,12 @@ const initVueDomModeListener = () => {
}); });
} }
}; };
if ((_a = app$1.ui) == null ? void 0 : _a.settings) { if ((_a = app.ui) == null ? void 0 : _a.settings) {
initVueDomModeListener(); initVueDomModeListener();
} else { } else {
const checkAppReady = setInterval(() => { const checkAppReady = setInterval(() => {
var _a2; var _a2;
if ((_a2 = app$1.ui) == null ? void 0 : _a2.settings) { if ((_a2 = app.ui) == null ? void 0 : _a2.settings) {
initVueDomModeListener(); initVueDomModeListener();
clearInterval(checkAppReady); clearInterval(checkAppReady);
} }
@@ -16804,8 +16641,8 @@ function createLoraInfoWidget(node) {
const vueApp = createApp(LoraInfoWidget, { const vueApp = createApp(LoraInfoWidget, {
widget, widget,
node, node,
api: api$1, api,
app: app$1, app,
isVueMode: typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode isVueMode: typeof LiteGraph !== "undefined" && LiteGraph.vueNodesMode
}); });
vueApp.use(PrimeVue, { vueApp.use(PrimeVue, {
@@ -16900,7 +16737,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
} }
); );
widget.metadataWidget = metadataWidget; widget.metadataWidget = metadataWidget;
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false; const spellcheck = ((_c = (_b = (_a2 = app.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const maxHeight = modelType === "loras" ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : void 0; const maxHeight = modelType === "loras" ? AUTOCOMPLETE_TEXT_WIDGET_MAX_HEIGHT : void 0;
const vueApp = createApp(AutocompleteTextWidget, { const vueApp = createApp(AutocompleteTextWidget, {
widget, widget,
@@ -16938,7 +16775,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
const minHeight = modelType === "loras" ? void 0 : AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT; const minHeight = modelType === "loras" ? void 0 : AUTOCOMPLETE_TEXT_MIN_HEIGHT_DEFAULT;
return { widget, minWidth, minHeight }; return { widget, minWidth, minHeight };
} }
app$1.registerExtension({ app.registerExtension({
name: "LoraManager.VueWidgets", name: "LoraManager.VueWidgets",
getCustomWidgets() { getCustomWidgets() {
return { return {
File diff suppressed because one or more lines are too long