Compare commits

...

16 Commits

Author SHA1 Message Date
willmiao 08023f0cd9 docs: auto-update supporters list in README 2026-09-06 14:29:39 +00:00
Will Miao 6e2185c182 chore(release): bump version to v1.2.2 2026-09-06 22:29:26 +08:00
Will Miao 41302e75ba fix(download): save multi-variant files under raw stored filenames (#1100)
The public REST API rewrites files[].name to "{model}_{version}" for
non-LoRA model types, so every precision variant of a multi-file version
shared one name and landed on disk with a random short-hash suffix.

Fetch the raw stored filename from the model-versions/mini endpoint
(always pinned with modelFileId) and use it for the on-disk name and
metadata when available; fall back silently to the REST name otherwise.
CivArchive already serves raw names and is skipped.
2026-09-06 22:23:48 +08:00
Will Miao a17399d667 feat(recipes): delegate CivitAI-image re-import to companion browser extension
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.

- POST /api/lm/recipe/{id}/reimport accepts optional payload params
  (image_url/name/resources/gen_params/base_model/tags); the payload path
  reuses the import-remote engine with reimport semantics (user-edit
  carryover, delete-after-save), and malformed/failed payloads fall back
  to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
  (documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
  delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
  and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
  all 9 locales.
2026-09-06 20:26:14 +08:00
Will Miao e2d85a0a21 fix(recipes): allow download for version-only recipe LoRAs (no modelId/hash)
Page-imported recipes can carry an exact CivitAI modelVersionId but no
modelId and no hash (CivitAI exposes no sha256 for e.g. Krea versions).
canDownloadLora() required (modelId && versionId) or a hash, so such
entries were misclassified as unrepairable and offered Reconnect instead
of Download.

- canDownloadLora: treat a bare version id as downloadable (it uniquely
  pins the file; the model id is resolved on demand at download time).
  A model id without an exact version id stays non-downloadable to avoid
  silently grabbing the latest version.
- resolveLoraDownloadIdentifiers: when a hash is absent but a version id
  exists, resolve the owning model id via /civitai/model/version/{id}
  (same endpoint the bulk download missing flow uses). Hash-only and
  direct (modelId+versionId) paths are unchanged.
2026-09-06 19:05:07 +08:00
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
71 changed files with 2438 additions and 3312 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)
+24
View File
@@ -170,6 +170,10 @@ The system runs in two modes:
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc. - Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
- Request handlers in `py/routes/handlers/` implement route logic - Request handlers in `py/routes/handlers/` implement route logic
- All routes use aiohttp, return `web.json_response` or `web.Response` - All routes use aiohttp, return `web.json_response` or `web.Response`
- Endpoints consumed by the companion browser extension (lm-civitai-extension)
MUST also accept `GET` with query-string params: the extension is GET-only by
convention (see its AGENTS.md), even for state-changing operations such as
`GET /api/lm/recipe/{recipe_id}/reimport`
### Recipe System ### Recipe System
@@ -215,6 +219,26 @@ The system runs in two modes:
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom; setup in `tests/frontend/setup.js` - Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom; setup in `tests/frontend/setup.js`
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + `@vue/test-utils` - Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + `@vue/test-utils`
### UI Verification (manual default)
UI/layout changes are verified by the user by eye — do NOT spin up a sandbox,
standalone server, or browser automation to "prove" a visual fix. Ask the user to
look instead. The full browser E2E ceremony (server + Chrome DevTools MCP +
screenshots) is slow, token-heavy, and fragile; reserve it for genuine
server+browser integration bugs, and only when the user explicitly agrees.
If a cross-layer issue ever needs a live server, the sandboxed helpers live in
`scripts/e2e/` (`start_server.py`, `wait_for_server.py`). Non-negotiable rules:
- Always launch with `--settings-path <sandbox>/settings` and sandboxed
`folder_paths` under `/tmp` — the repo folder is the real plugin folder and a
`settings.json` there is read by the live instance. Never touch real config or
real model libraries.
- Never kill a process you did not start; `start_server.py` tracks its own PIDs
via pidfile and refuses to touch unrelated processes on the port.
- Abort after ~30 minutes or 3 consecutive tool failures; report `BLOCKED` with
observed state instead of retrying blindly. Clean up sandbox and server after.
## Key Integration Points ## Key Integration Points
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`) - **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
+2 -2
View File
File diff suppressed because one or more lines are too long
+427 -395
View File
File diff suppressed because it is too large Load Diff
+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
+58
View File
@@ -0,0 +1,58 @@
# CivitAI image imports can end up with 0 LoRAs
## Symptom
Importing a CivitAI image URL can produce a recipe with **zero LoRA
entries**, even though the image page lists LoRAs in its resource panel.
Reported example: `https://civitai.red/images/140818889` was imported as a
local recipe with 0 LoRAs, while the page shows 3 LoRAs. Some images (e.g.
NSFW / higher browsing level) additionally require a login to view, so their
data is not publicly reachable at all.
## Root cause
URL imports use only two data sources:
1. **CivitAI REST image API**`GET /api/v1/images?imageId=<id>&nsfw=X&withMeta=true``meta`
2. **Embedded image metadata** — EXIF/XMP read from the downloaded bytes
For the same image both sources can be empty, and the one source that does
contain the data is never queried. Verified for image 140818889:
| Source | What it returned |
|---|---|
| REST image API | `meta` holds only a prompt; `modelVersionIds: []`; no `resources`/`hashes`; `baseModel: null` |
| Downloaded image | PNG with **no EXIF/XMP** (the CDN URL ends in `.jpeg`, the body is PNG) |
| Image page HTML | `__NEXT_DATA__` embeds the trpc `image.getGenerationData` result → full `resources` list: 3 LoRAs, each with `modelId`, `modelVersionId`, `modelName`, `modelType`, `versionName`, `baseModel` |
Key points:
- The page's resource panel is fed by an **internal, non-public trpc
endpoint**, not by the public REST image API.
- That internal endpoint is **login-gated** for some content — the
"requires login" symptom.
- Even with the version IDs in hand, `/model-versions/{id}` for these
(Krea) versions returns **no `sha256`**, so an exact local-file hash match
is impossible; only model/version identity is recoverable.
## Conclusion / status
0-LoRA imports are a data-source gap: public REST meta and image EXIF are
both empty, while the only complete source (page generation data) is
internal, sometimes login-gated, and not used by the importer.
Such imports **cannot be reliably auto-repaired/completed** by the backend
alone. The old "Repair Metadata" feature only re-fetched the same incomplete
REST meta and could not fix them; it was deprecated and has been removed.
**Fixed via the companion browser extension.** When the extension is
installed with a valid license, it scrapes the image page's internal trpc
generation data with the user's session and calls the payload-capable
re-import endpoint (`POST /api/lm/recipe/{recipe_id}/reimport` with
`image_url`/`name`/`resources`/`gen_params`/`base_model`/`tags` query
params), which rebuilds the recipe from the caller-supplied metadata. The
web UI delegates re-import of CivitAI-image-sourced recipes to the extension
automatically (probe + `lm:reimport*` DOM events); without the extension,
re-import silently falls back to the native path, which remains limited by
the data-source gap documented above.
+3 -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",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich", "rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}", "rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...", "reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert", "reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen", "reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
+3 -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",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "No rematch needed for any of the {total} selected recipes", "rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}", "rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...", "reimporting": "Re-importing recipe from source...",
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
"reimportSuccess": "Recipe re-imported successfully", "reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes", "reimportBulkFailed": "Failed to re-import some recipes",
+3 -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",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación", "rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}", "rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...", "reimporting": "Reimportando receta desde origen...",
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
"reimportSuccess": "Receta reimportada exitosamente", "reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas", "reimportBulkFailed": "Error al reimportar algunas recetas",
+3 -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}",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation", "rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}", "rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
"reimporting": "Ré-import de la Recipe depuis la source...", "reimporting": "Ré-import de la Recipe depuis la source...",
"reimportingViaExtension": "Ré-import de la Recipe {current}/{total} via lextension du navigateur...",
"reimportSuccess": "Recette ré-importée avec succès", "reimportSuccess": "Recette ré-importée avec succès",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines Recipes", "reimportBulkFailed": "Échec du ré-import de certaines Recipes",
+3 -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} מתכונים שנבחרו",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו", "rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}", "rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...", "reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה", "reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל", "reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
+3 -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} 件のレシピの再マッチングに失敗しました",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした", "rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}", "rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...", "reimporting": "ソースからレシピを再インポート中...",
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
"reimportSuccess": "レシピの再インポートが完了しました", "reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました", "reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
+3 -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}개 재매칭 실패",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다", "rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}", "rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...", "reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
"reimportSuccess": "레시피를 다시 가져왔습니다", "reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다", "reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
+3 -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} выбранных рецептов",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления", "rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}", "rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...", "reimporting": "Переимпорт рецепта из источника...",
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
"reimportSuccess": "Рецепт успешно переимпортирован", "reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты", "reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
+3 -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} 个所选配方重新匹配失败",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "{total} 个所选配方均无需重新匹配", "rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}", "rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...", "reimporting": "正在从源重新导入配方...",
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
"reimportSuccess": "配方已从源重新导入成功", "reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败", "reimportBulkFailed": "重新导入某些配方失败",
+3 -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} 個所選配方重新匹配失敗",
@@ -2246,6 +2229,7 @@
"rematchSkipped": "{total} 個所選配方均無需重新匹配", "rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}", "rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...", "reimporting": "正在從來源重新匯入配方...",
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
"reimportSuccess": "配方已從來源重新匯入成功", "reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗", "reimportBulkFailed": "重新匯入某些配方失敗",
+135 -208
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,
@@ -1179,12 +1020,55 @@ class RecipeManagementHandler:
persisted_source_path=persisted_source_path, persisted_source_path=persisted_source_path,
) )
async with self._import_semaphore: # Optional caller-supplied metadata payload (companion browser
import_response = await self._do_import_from_url( # extension re-import). Only honored for CivitAI image page
source_path, # sources; everything else uses the native URL import below.
recipe_scanner, params = request.rel_url.query
target_dir=old_folder, payload_image_url = params.get("image_url")
) payload_name = params.get("name")
payload_resources = params.get("resources")
has_import_payload = bool(
payload_image_url and payload_name and payload_resources
)
import_response: web.Response | None = None
if has_import_payload and image_id:
try:
async with self._import_semaphore:
import_response = await self._import_remote_recipe_impl(
image_url=payload_image_url,
name=payload_name,
resources_raw=payload_resources,
gen_params_raw=params.get("gen_params"),
tags_raw=params.get("tags"),
base_model=params.get("base_model", "") or "",
source_path=source_path,
target_dir=old_folder,
)
except RecipeValidationError as exc:
# Malformed resources/gen_params JSON: treat as "no
# payload" and use the legacy URL re-import.
self._logger.warning(
"Ignoring malformed re-import payload for recipe %s "
"(%s); falling back to source URL re-import",
recipe_id,
exc,
)
except Exception as exc:
self._logger.warning(
"Payload-based re-import failed for recipe %s: %s; "
"falling back to source URL re-import",
recipe_id,
exc,
)
if import_response is None:
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
await self._persistence_service.delete_recipe( await self._persistence_service.delete_recipe(
recipe_scanner=recipe_scanner, recipe_id=recipe_id recipe_scanner=recipe_scanner, recipe_id=recipe_id
@@ -1211,14 +1095,19 @@ class RecipeManagementHandler:
exc, exc,
) )
return web.json_response( response_body: Dict[str, Any] = {
{ "success": True,
"success": True, "old_recipe_id": recipe_id,
"old_recipe_id": recipe_id, "recipe_id": new_recipe_id,
"recipe_id": new_recipe_id, "source_path": source_path,
"source_path": source_path, }
} loras_count = await self._count_recipe_loras(
recipe_scanner, new_recipe_id
) )
if loras_count is not None:
response_body["loras_count"] = loras_count
return web.json_response(response_body)
except RecipeNotFoundError as exc: except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404) return web.json_response({"success": False, "error": str(exc)}, status=404)
except RecipeValidationError as exc: except RecipeValidationError as exc:
@@ -1231,18 +1120,6 @@ class RecipeManagementHandler:
) )
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_repair_progress(self, request: web.Request) -> web.Response:
try:
progress = self._ws_manager.get_recipe_repair_progress()
if progress:
return web.json_response({"success": True, "progress": progress})
return web.json_response(
{"success": False, "message": "No repair in progress"}, status=404
)
except Exception as exc:
self._logger.error("Error getting repair progress: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def import_remote_recipe(self, request: web.Request) -> web.Response: async def import_remote_recipe(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -1263,31 +1140,14 @@ class RecipeManagementHandler:
if not resources_raw: if not resources_raw:
raise RecipeValidationError("Missing required field: resources") raise RecipeValidationError("Missing required field: resources")
checkpoint_entry, lora_entries = self._parse_resources_payload(
resources_raw
)
gen_params_request = self._parse_gen_params(params.get("gen_params"))
self._logger.info(
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
# Throttle concurrent imports to avoid starving ComfyUI's event loop # Throttle concurrent imports to avoid starving ComfyUI's event loop
async with self._import_semaphore: async with self._import_semaphore:
return await self._do_import_remote_recipe( return await self._import_remote_recipe_impl(
image_url=image_url, image_url=image_url,
name=name, name=name,
lora_entries=lora_entries, resources_raw=resources_raw,
checkpoint_entry=checkpoint_entry, gen_params_raw=params.get("gen_params"),
gen_params_request=gen_params_request, tags_raw=params.get("tags"),
tags=self._parse_tags(params.get("tags")),
base_model=params.get("base_model", "") or "", base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url, source_path=params.get("source_path") or image_url,
) )
@@ -1301,6 +1161,52 @@ class RecipeManagementHandler:
) )
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def _import_remote_recipe_impl(
self,
*,
image_url: str,
name: str,
resources_raw: str,
gen_params_raw: Optional[str],
tags_raw: Optional[str],
base_model: str,
source_path: str,
target_dir: str | None = None,
) -> web.Response:
"""Payload-based remote import engine shared by import-remote and the
extension-driven re-import path.
Parses the caller-supplied payloads and delegates to
:meth:`_do_import_remote_recipe`. Raises ``RecipeValidationError`` on
malformed payloads so callers can decide how to handle them (the
re-import path falls back to the legacy URL import).
"""
checkpoint_entry, lora_entries = self._parse_resources_payload(resources_raw)
gen_params_request = self._parse_gen_params(gen_params_raw)
self._logger.info(
"Remote recipe import received: url=%s, lora_count=%d",
image_url,
len(lora_entries),
)
self._logger.debug(
" gen_params_keys=%s, checkpoint_keys=%s",
sorted(gen_params_request.keys()) if gen_params_request else [],
sorted(checkpoint_entry.keys()) if isinstance(checkpoint_entry, dict) else [],
)
return await self._do_import_remote_recipe(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(tags_raw),
base_model=base_model,
source_path=source_path,
target_dir=target_dir,
)
async def _do_import_remote_recipe( async def _do_import_remote_recipe(
self, self,
*, *,
@@ -1312,6 +1218,7 @@ class RecipeManagementHandler:
tags: list[Any], tags: list[Any],
base_model: str, base_model: str,
source_path: str, source_path: str,
target_dir: str | None = None,
) -> web.Response: ) -> web.Response:
recipe_scanner = self._recipe_scanner_getter() recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None: if recipe_scanner is None:
@@ -1475,6 +1382,7 @@ class RecipeManagementHandler:
tags=tags, tags=tags,
metadata=metadata, metadata=metadata,
extension=extension, extension=extension,
target_dir=target_dir,
) )
return web.json_response(result.payload, status=result.status) return web.json_response(result.payload, status=result.status)
@@ -1939,6 +1847,25 @@ class RecipeManagementHandler:
return [] return []
return [tag.strip() for tag in tag_text.split(",") if tag.strip()] return [tag.strip() for tag in tag_text.split(",") if tag.strip()]
async def _count_recipe_loras(
self, recipe_scanner: Any, recipe_id: Optional[str]
) -> Optional[int]:
"""Best-effort LoRA count for a freshly saved recipe (for the
re-import response). Returns None when the recipe cannot be read."""
if not recipe_id:
return None
try:
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
except Exception as exc:
self._logger.debug(
"Could not read new recipe %s for loras_count: %s",
recipe_id,
exc,
)
return None
loras = (recipe or {}).get("loras")
return len(loras) if isinstance(loras, list) else None
def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]: def _parse_gen_params(self, payload: Optional[str]) -> Optional[Dict[str, Any]]:
if payload is None: if payload is None:
return None return None
+5 -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"),
@@ -115,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" "POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
), ),
# The companion browser extension only ever issues GET requests, so the
# payload-based re-import variant must also be reachable via GET.
RouteDefinition(
"GET", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow" "POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
), ),
+44
View File
@@ -505,6 +505,50 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by id {version_id}") logger.warning(f"Failed to fetch version by id {version_id}")
return None return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
"""Fetch raw stored file info via the model-versions/mini endpoint.
The public REST API rewrites ``files[].name`` to
``"{model}_{version}"`` for non-LoRA model types, so every
precision variant of a multi-file version shares one name (#1100).
The mini endpoint returns the raw ``ModelFile.name`` in
``fileName``. ``file_id`` is mandatory: without it mini picks a
file via its own primary-file logic, which can disagree with the
REST ``primary`` flag.
Returns the mini payload dict on success, None on any failure.
"""
try:
success, data = await self._make_request(
"GET",
f"{self.base_url}/model-versions/mini/{version_id}",
params={"modelFileId": file_id},
use_auth=True,
)
if success and isinstance(data, dict):
return data
if is_expected_offline_error(data):
return None
logger.debug(
"Mini endpoint lookup failed for version %s file %s: %s",
version_id,
file_id,
data,
)
return None
except RateLimitError:
raise
except Exception as exc:
logger.debug(
"Error fetching mini info for version %s file %s: %s",
version_id,
file_id,
exc,
)
return None
async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]: async def _fetch_version_by_hash(self, model_hash: Optional[str]) -> Optional[Dict[str, Any]]:
if not model_hash: if not model_hash:
return None return None
+55
View File
@@ -35,6 +35,7 @@ from .service_registry import ServiceRegistry
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from .metadata_service import get_default_metadata_provider, get_metadata_provider from .metadata_service import get_default_metadata_provider, get_metadata_provider
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService from .download_queue_service import DownloadQueueService
@@ -929,6 +930,42 @@ class DownloadManager:
return download_urls return download_urls
async def _fetch_raw_file_name(
self,
metadata_provider,
version_id: Optional[int],
file_id: Any,
) -> Optional[str]:
"""Best-effort lookup of the raw stored filename via the CivitAI
model-versions/mini endpoint (#1100). Returns None on any failure so
the caller can fall back to the (possibly rewritten) REST name."""
if version_id is None or file_id is None:
return None
fetch = getattr(metadata_provider, "get_version_file_mini", None)
if fetch is None:
return None
try:
mini_info = await fetch(int(version_id), int(file_id))
except (TypeError, ValueError):
return None
except RateLimitError:
raise
except Exception as exc:
logger.debug(
"Mini endpoint lookup failed for version %s file %s: %s",
version_id,
file_id,
exc,
)
return None
if not isinstance(mini_info, dict):
return None
raw_name = mini_info.get("fileName")
if not isinstance(raw_name, str) or not raw_name.strip():
return None
# Defensive: never let a path component slip into the filename.
return os.path.basename(raw_name.strip()) or None
def _build_metadata_for_resume( def _build_metadata_for_resume(
self, self,
*, *,
@@ -1858,6 +1895,24 @@ class DownloadManager:
if not download_urls: if not download_urls:
return {"success": False, "error": "No mirror URL found"} return {"success": False, "error": "No mirror URL found"}
# The public REST API rewrites files[].name to
# "{model}_{version}" for non-LoRA model types, so every
# precision variant of a multi-file version shares one name and
# lands on disk with a random short-hash suffix. The mini
# endpoint returns the raw stored filename (#1100). CivArchive
# already serves raw names.
if source != "civarchive":
raw_file_name = await self._fetch_raw_file_name(
metadata_provider, resolved_version_id, file_info.get("id")
)
if raw_file_name and raw_file_name != file_info.get("name"):
logger.info(
"[download] Using raw stored filename '%s' instead of REST name '%s'",
raw_file_name,
file_info.get("name"),
)
file_info = {**file_info, "name": raw_file_name}
# 3. Prepare download # 3. Prepare download
file_name = file_info.get("name", "") file_name = file_info.get("name", "")
if not file_name: if not file_name:
+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 []
+57
View File
@@ -169,6 +169,17 @@ class ModelMetadataProvider(ABC):
"""Published model count for the user; None when unsupported.""" """Published model count for the user; None when unsupported."""
return None return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
"""Fetch raw stored file info via CivitAI's model-versions/mini endpoint.
Only the CivitAI provider implements this (#1100); other providers
already serve raw file names (CivArchive) or cannot resolve this
lookup (SQLite), so the default is None.
"""
return None
class CivitaiModelMetadataProvider(ModelMetadataProvider): class CivitaiModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses Civitai API for metadata""" """Provider that uses Civitai API for metadata"""
@@ -203,6 +214,11 @@ class CivitaiModelMetadataProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]: async def get_creator_model_count(self, username: str) -> Optional[int]:
return await self.client.get_creator_model_count(username) return await self.client.get_creator_model_count(username)
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
return await self.client.get_version_file_mini(version_id, file_id)
class CivArchiveModelMetadataProvider(ModelMetadataProvider): class CivArchiveModelMetadataProvider(ModelMetadataProvider):
"""Provider that uses CivArchive API for metadata""" """Provider that uses CivArchive API for metadata"""
@@ -700,6 +716,37 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue continue
return None return None
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result = await self._call_with_rate_limit(
label,
provider.get_version_file_mini,
version_id,
file_id,
)
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
continue
except Exception as e:
logger.debug(
"Provider %s failed for get_version_file_mini: %s", label, e
)
continue
return None
def _iter_providers(self): def _iter_providers(self):
return zip(self.providers, self._provider_labels) return zip(self.providers, self._provider_labels)
@@ -791,6 +838,16 @@ class RateLimitRetryingProvider(ModelMetadataProvider):
async def get_creator_model_count(self, username: str) -> Optional[int]: async def get_creator_model_count(self, username: str) -> Optional[int]:
return await self._provider.get_creator_model_count(username) return await self._provider.get_creator_model_count(username)
async def get_version_file_mini(
self, version_id: int, file_id: int
) -> Optional[Dict[str, Any]]:
return await self._rate_limit_helper.run(
self._label,
self._provider.get_version_file_mini,
version_id,
file_id,
)
class ModelMetadataProviderManager: class ModelMetadataProviderManager:
"""Manager for selecting and using model metadata providers""" """Manager for selecting and using model metadata providers"""
-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
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-lora-manager" name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!" description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.1" version = "1.2.2"
license = {file = "LICENSE"} license = {file = "LICENSE"}
dependencies = [ dependencies = [
"aiohttp", "aiohttp",
+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;
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js'; import { moveManager } from '../../managers/MoveManager.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu { export class RecipeContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -93,10 +94,6 @@ export class RecipeContextMenu extends BaseContextMenu {
// Download missing LoRAs // Download missing LoRAs
this.downloadMissingLoRAs(recipeId); this.downloadMissingLoRAs(recipeId);
break; break;
case 'repair':
// Repair recipe metadata
this.repairRecipe(recipeId);
break;
case 'rematch': case 'rematch':
// Rematch recipe resources to local models // Rematch recipe resources to local models
this.rematchRecipe(recipeId); this.rematchRecipe(recipeId);
@@ -297,44 +294,6 @@ export class RecipeContextMenu extends BaseContextMenu {
} }
} }
// Repair recipe metadata
async repairRecipe(recipeId) {
if (!recipeId) {
showToast('recipes.contextMenu.repair.missingId', {}, 'error');
return;
}
try {
showToast('recipes.contextMenu.repair.starting', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/repair`, {
method: 'POST'
});
const result = await response.json();
if (result.success) {
if (result.repaired > 0) {
showToast('recipes.contextMenu.repair.success', {}, 'success');
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
const filePath = this.currentCard?.dataset?.filepath;
if (filePath && state.virtualScroller) {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} else {
showToast('recipes.contextMenu.repair.skipped', {}, 'info');
}
} else {
throw new Error(result.error || 'Repair failed');
}
} catch (error) {
console.error('Error repairing recipe:', error);
showToast('recipes.contextMenu.repair.failed', { message: error.message }, 'error');
}
}
async rematchRecipe(recipeId) { async rematchRecipe(recipeId) {
if (!recipeId) { if (!recipeId) {
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error'); showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
@@ -397,6 +356,24 @@ export class RecipeContextMenu extends BaseContextMenu {
return; return;
} }
// Recipes imported from a CivitAI image page can carry incomplete
// metadata (0 LoRAs); the companion browser extension can re-import
// them with the full page data. Fall back to the native path whenever
// the extension is absent, unlicensed, or the delegation fails.
const recipeItem = state.virtualScroller?.items?.find(item => item?.id === recipeId);
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
if (civitaiImage) {
try {
const probe = await probeExtension();
if (probe?.supported && probe?.licenseValid) {
await this.reimportViaExtension(recipeId, civitaiImage, recipeItem?.title || '');
return;
}
} catch (error) {
console.warn('Extension re-import unavailable, using native path:', error);
}
}
state.loadingManager.showSimpleLoading('Re-importing recipe from source...'); state.loadingManager.showSimpleLoading('Re-importing recipe from source...');
try { try {
@@ -419,6 +396,34 @@ export class RecipeContextMenu extends BaseContextMenu {
showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error'); showToast('recipes.contextMenu.reimport.failed', { message: error.message }, 'error');
} }
} }
// Re-import a single CivitAI-image recipe through the companion browser
// extension. Throws on delegation failure so the caller can fall back to
// the native path.
async reimportViaExtension(recipeId, civitaiImage, title) {
state.loadingManager.showSimpleLoading('Re-importing recipe via browser extension...');
try {
const { failed } = await delegateReimport([{
recipeId,
imageId: civitaiImage.imageId,
imageUrl: civitaiImage.imageUrl,
title,
}]);
state.loadingManager.hide();
if (failed > 0) {
showToast('recipes.contextMenu.reimport.failed', { message: 'Extension re-import failed' }, 'error');
} else {
showToast('toast.recipes.reimportSuccess', {}, 'success');
}
const { resetAndReload } = await import('../../api/recipeApi.js');
resetAndReload(false, { preserveScroll: false });
} catch (error) {
state.loadingManager.hide();
throw error;
}
}
} }
// Mix in shared methods from ModelContextMenuMixin // Mix in shared methods from ModelContextMenuMixin
+41 -16
View File
@@ -2877,12 +2877,14 @@ class RecipeModal {
canDownloadLora(lora) { canDownloadLora(lora) {
if (!lora) return false; if (!lora) return false;
const modelId = lora.modelId || lora.modelID || lora.model_id;
const versionId = lora.id || lora.modelVersionId; const versionId = lora.id || lora.modelVersionId;
// Direct download needs both identifiers; a hash alone is enough // A bare CivitAI version id is enough: it uniquely pins the exact
// because downloadRecipeLora resolves it to a version on demand — // file, and downloadRecipeLora resolves the owning model id from the
// the same fallback the bulk "download missing" flow uses. // version on demand (the same fallback the bulk "download missing"
return !!((modelId && versionId) || lora.hash); // flow uses). A hash alone is likewise sufficient. A model id without
// an exact version id is NOT enough — downloading the model's latest
// version could silently mismatch the recipe's pinned version.
return !!(versionId || lora.hash);
} }
renderCivitaiLink(url) { renderCivitaiLink(url) {
@@ -2991,6 +2993,9 @@ class RecipeModal {
* Resolve the Civitai model/version identifiers needed for download. * Resolve the Civitai model/version identifiers needed for download.
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve * Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
* it through the same endpoint the bulk "download missing" flow uses. * it through the same endpoint the bulk "download missing" flow uses.
* Version-only entries (page-imported recipes whose CivitAI version has
* no sha256) are resolved through the version endpoint, which returns
* the owning model id.
*/ */
async resolveLoraDownloadIdentifiers(lora) { async resolveLoraDownloadIdentifiers(lora) {
let modelId = lora.modelId || lora.modelID || lora.model_id; let modelId = lora.modelId || lora.modelID || lora.model_id;
@@ -3001,21 +3006,41 @@ class RecipeModal {
return { modelId, versionId, versionName }; return { modelId, versionId, versionName };
} }
if (!lora.hash) { // Hash-only entries (PNG/recipe-JSON imports): resolve the owning
return null; // model/version through the same endpoint the bulk "download
// missing" flow uses.
if (lora.hash) {
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`);
const versionInfo = await response.json();
if (versionInfo?.error) {
return null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
} }
const response = await fetch(`/api/lm/loras/civitai/model/hash/${lora.hash}`); // Version-only entries (page-imported recipes whose CivitAI versions
const versionInfo = await response.json(); // expose no sha256): the version id still pins the exact file, so
if (versionInfo?.error) { // resolve the owning model id from the version endpoint on demand.
return null; if (versionId) {
const response = await fetch(`/api/lm/loras/civitai/model/version/${versionId}`);
const versionInfo = await response.json();
if (!versionInfo || versionInfo?.error === 'Model not found') {
return null;
}
modelId = versionInfo.modelId || versionInfo.model?.id;
versionId = versionInfo.id || versionId;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
} }
modelId = versionInfo.modelId || versionInfo.model?.id; return null;
versionId = versionInfo.id;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
} }
/** /**
+62 -75
View File
@@ -10,6 +10,7 @@ import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../componen
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js'; import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
import { eventManager } from '../utils/EventManager.js'; import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js'; import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
export class BulkManager { export class BulkManager {
@@ -103,7 +104,6 @@ export class BulkManager {
skipMetadataRefresh: false, skipMetadataRefresh: false,
setFavorite: true, setFavorite: true,
unfavorite: true, unfavorite: true,
repairMetadata: true,
reimportMetadata: true, reimportMetadata: true,
rematchMetadata: true rematchMetadata: true
} }
@@ -858,17 +858,74 @@ export class BulkManager {
`Re-importing recipe 1/${total}...` `Re-importing recipe 1/${total}...`
); );
// Partition the selection: recipes sourced from a CivitAI image page
// can be delegated to the companion browser extension (which scrapes
// the full page metadata); everything else uses the native endpoint.
const delegatable = [];
const nativeFilePaths = [];
for (const filePath of filePaths) {
const recipeItem = recipeMap.get(filePath);
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
if (civitaiImage && recipeItem?.id) {
delegatable.push({
filePath,
recipeId: recipeItem.id,
imageId: civitaiImage.imageId,
imageUrl: civitaiImage.imageUrl,
title: recipeItem.title || '',
});
} else {
nativeFilePaths.push(filePath);
}
}
// Probe once; on any probe/delegate failure the delegatable recipes
// fall back to the native sequential loop below.
if (delegatable.length > 0) {
try {
const probe = await probeExtension();
if (probe?.supported && probe?.licenseValid) {
const batchResult = await delegateReimport(
delegatable.map(({ recipeId, imageId, imageUrl, title }) => ({
recipeId, imageId, imageUrl, title,
})),
{
onProgress: (progress) => {
progressUI.updateProgress(
Math.floor(((progress.current || 0) / total) * 100),
progress.title || '',
translate('toast.recipes.reimportingViaExtension', {
current: progress.current || 0,
total,
})
);
},
}
);
completed += batchResult.completed;
failed += batchResult.failed;
} else {
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
}
} catch (error) {
console.warn('[reimportSelectedRecipes] extension delegation failed, using native path:', error);
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
}
}
try { try {
for (let i = 0; i < filePaths.length; i++) { const processedBeforeNative = completed + failed;
const filePath = filePaths[i]; for (let i = 0; i < nativeFilePaths.length; i++) {
const filePath = nativeFilePaths[i];
const recipeItem = recipeMap.get(filePath); const recipeItem = recipeMap.get(filePath);
const recipeId = recipeItem?.id; const recipeId = recipeItem?.id;
const recipeName = recipeItem?.title || recipeId || 'Unknown'; const recipeName = recipeItem?.title || recipeId || 'Unknown';
const processed = processedBeforeNative + i;
progressUI.updateProgress( progressUI.updateProgress(
Math.floor((i / total) * 100), Math.floor((processed / total) * 100),
recipeName, recipeName,
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...` `Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
); );
if (!recipeId) { if (!recipeId) {
@@ -910,76 +967,6 @@ export class BulkManager {
} }
} }
async repairSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
if (state.currentPageType !== 'recipes') {
showToast('This operation is only available for recipes', {}, 'warning');
return;
}
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
if (typeof apiClient.repairBulkModels !== 'function') {
showToast('Bulk repair is not supported for this model type', {}, 'error');
return;
}
state.loadingManager.showSimpleLoading('Repairing recipe metadata...');
const result = await apiClient.repairBulkModels(filePaths);
if (result.success) {
const total = result.total || filePaths.length;
const repaired = result.repaired || 0;
const skipped = result.skipped || 0;
const recipes = result.recipes || [];
for (const recipe of recipes) {
if (recipe.file_path) {
state.virtualScroller.updateSingleItem(
recipe.file_path,
recipe
);
}
}
if (repaired > 0) {
showToast(
'toast.recipes.repairBulkComplete',
{ repaired, skipped, total },
'success'
);
} else {
showToast(
'toast.recipes.repairBulkSkipped',
{ total },
'info'
);
}
if (state.bulkMode) this.toggleBulkMode();
} else {
throw new Error(result.error || 'Bulk repair failed');
}
} catch (error) {
console.error('Error during bulk recipe repair:', error);
showToast('toast.recipes.repairBulkFailed', { message: error.message }, 'error');
} finally {
if (state.loadingManager?.hide) {
state.loadingManager.hide();
}
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
}
}
async rematchSelectedRecipes() { async rematchSelectedRecipes() {
if (state.selectedModels.size === 0) { if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning'); showToast('toast.recipes.noRecipesSelected', {}, 'warning');
+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: '',
+220
View File
@@ -0,0 +1,220 @@
/**
* Bridge to the companion LoRA Manager browser extension.
*
* The extension can re-import recipes sourced from CivitAI image pages with
* the complete page metadata (internal trpc data scraped with the user's
* session), fixing recipes that the native import (REST API + EXIF only)
* saved with 0 LoRAs.
*
* Protocol: DOM CustomEvents on `document`; `detail` is ALWAYS a JSON
* string on both sides.
*
* LM page -> extension: `lm:reimportProbe`, detail `{}`.
* extension -> LM page: `lm:reimportProbeResult`,
* detail `{supported, licenseValid, extensionVersion?, reason?}`.
* LM page -> extension: `lm:reimportViaExtension`,
* detail `{requestId, recipes: [{recipeId, imageId, imageUrl, title}]}`.
* extension -> LM page: `lm:reimportProgress`,
* detail `{requestId, current, total, recipeId, title, status, message?}`.
* extension -> LM page: `lm:reimportBatchDone`,
* detail `{requestId, completed, failed}`.
*/
const PROBE_EVENT = 'lm:reimportProbe';
const PROBE_RESULT_EVENT = 'lm:reimportProbeResult';
const REIMPORT_EVENT = 'lm:reimportViaExtension';
const PROGRESS_EVENT = 'lm:reimportProgress';
const BATCH_DONE_EVENT = 'lm:reimportBatchDone';
const DEFAULT_PROBE_TIMEOUT_MS = 500;
// Generous batch timeout; any progress event resets it (heartbeat).
const DEFAULT_REIMPORT_TIMEOUT_MS = 3 * 60 * 1000;
// Mirrors py/utils/civitai_utils.py (_SUPPORTED_CIVITAI_PAGE_HOSTS).
const SUPPORTED_CIVITAI_PAGE_HOSTS = new Set([
'civitai.com',
'civitai.red',
'civitai.green',
]);
/**
* Parse the JSON-string `detail` of a protocol event.
* @param {CustomEvent} event
* @returns {object|null} Parsed detail object, or null when absent/invalid.
*/
function parseDetail(event) {
try {
const detail = JSON.parse(event?.detail ?? 'null');
return detail && typeof detail === 'object' ? detail : null;
} catch {
return null;
}
}
/**
* Dispatch a protocol event with a JSON-stringified detail.
* @param {string} type - Event name.
* @param {object} payload - Detail payload (JSON-stringified).
*/
function dispatchProtocolEvent(type, payload) {
document.dispatchEvent(
new CustomEvent(type, { detail: JSON.stringify(payload ?? {}) })
);
}
/**
* Generate a correlation id for a re-import batch.
* @returns {string}
*/
function generateRequestId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `lm-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
/**
* Probe whether the companion extension is installed and usable.
*
* @param {{timeoutMs?: number}} [options]
* @returns {Promise<{supported: boolean, licenseValid: boolean, extensionVersion?: string, reason?: string}|null>}
* Resolves with the probe result, or null when the extension is absent or
* too old to answer (timeout).
*/
export function probeExtension({ timeoutMs = DEFAULT_PROBE_TIMEOUT_MS } = {}) {
return new Promise((resolve) => {
let settled = false;
const timer = setTimeout(() => finish(null), timeoutMs);
const finish = (value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
document.removeEventListener(PROBE_RESULT_EVENT, onResult);
resolve(value);
};
const onResult = (event) => {
const detail = parseDetail(event);
if (!detail) return;
finish({
supported: Boolean(detail.supported),
licenseValid: Boolean(detail.licenseValid),
extensionVersion: detail.extensionVersion,
reason: detail.reason,
});
};
document.addEventListener(PROBE_RESULT_EVENT, onResult);
dispatchProtocolEvent(PROBE_EVENT, {});
});
}
/**
* Delegate a batch of recipe re-imports to the companion extension.
*
* @param {Array<{recipeId: string, imageId: number, imageUrl: string, title: string}>} recipes
* @param {{onProgress?: (progress: object) => void, timeoutMs?: number}} [options]
* @returns {Promise<{completed: number, failed: number}>} Resolves on
* `lm:reimportBatchDone`; rejects on timeout. Listeners are cleaned up in
* all outcomes.
*/
export function delegateReimport(recipes, { onProgress, timeoutMs = DEFAULT_REIMPORT_TIMEOUT_MS } = {}) {
return new Promise((resolve, reject) => {
if (!Array.isArray(recipes) || recipes.length === 0) {
reject(new Error('delegateReimport requires a non-empty recipe list'));
return;
}
const requestId = generateRequestId();
let settled = false;
let timer = null;
const cleanup = () => {
clearTimeout(timer);
document.removeEventListener(PROGRESS_EVENT, onProgressEvent);
document.removeEventListener(BATCH_DONE_EVENT, onBatchDone);
};
const succeed = (value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
};
const fail = (error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const armTimer = () => {
clearTimeout(timer);
timer = setTimeout(
() => fail(new Error('Extension re-import timed out')),
timeoutMs
);
};
const onProgressEvent = (event) => {
const detail = parseDetail(event);
if (!detail || detail.requestId !== requestId) return;
// Heartbeat: any progress for this batch resets the timeout.
armTimer();
if (typeof onProgress === 'function') {
try {
onProgress(detail);
} catch (error) {
console.error('[extensionReimportBridge] onProgress callback failed:', error);
}
}
};
const onBatchDone = (event) => {
const detail = parseDetail(event);
if (!detail || detail.requestId !== requestId) return;
succeed({
completed: Number.isInteger(detail.completed) ? detail.completed : 0,
failed: Number.isInteger(detail.failed) ? detail.failed : 0,
});
};
document.addEventListener(PROGRESS_EVENT, onProgressEvent);
document.addEventListener(BATCH_DONE_EVENT, onBatchDone);
armTimer();
dispatchProtocolEvent(REIMPORT_EVENT, { requestId, recipes });
});
}
/**
* Extract CivitAI image page info from a recipe source_path.
* Mirrors py/utils/civitai_utils.py `extract_civitai_image_id`.
*
* @param {string|null} sourcePath - Recipe source_path.
* @returns {{imageId: number, imageUrl: string}|null} Null when the path is
* not a `/images/<id>` URL on civitai.com/.red/.green.
*/
export function getCivitaiImageInfo(sourcePath) {
if (!sourcePath || typeof sourcePath !== 'string') {
return null;
}
let parsed;
try {
parsed = new URL(sourcePath);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
if (!SUPPORTED_CIVITAI_PAGE_HOSTS.has(parsed.hostname.toLowerCase())) {
return null;
}
const pathMatch = parsed.pathname.match(/\/images\/(\d+)/);
if (!pathMatch) {
return null;
}
return { imageId: Number(pathMatch[1]), imageUrl: sourcePath };
}
+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,37 +253,4 @@ describe('AutoComplete active-filters flag', () => {
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull(); expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
}); });
it('broadcasts a setting-toggled window event when /activefilters is accepted', async () => {
const events = [];
const listener = (event) => events.push(event.detail);
window.addEventListener('lora-manager:setting-toggled', listener);
try {
const input = document.createElement('textarea');
input.value = '/activefilters';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/activefilters');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
// The command token is cleared after acceptance; simulate the caret
// helper seeing the cleared input so the synthetic input event does
// not re-trigger command parsing (same pattern as behavior tests).
caretHelperInstance.getBeforeCursor.mockReturnValue('');
await Promise.resolve();
await Promise.resolve();
expect(events).toContainEqual({
settingId: 'loramanager.lora_active_filters_autocomplete',
value: true,
});
} finally {
window.removeEventListener('lora-manager:setting-toggled', listener);
}
});
}); });
@@ -54,7 +54,6 @@ const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({ vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID: LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete", "loramanager.lora_active_filters_autocomplete",
SETTING_TOGGLED_EVENT_NAME: "lora-manager:setting-toggled",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock, getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock, setLoraManagerSettingValue: setSettingValueMock,
})); }));
@@ -0,0 +1,181 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const showSimpleLoadingMock = vi.fn();
const hideLoadingMock = vi.fn();
const resetAndReloadMock = vi.fn();
const probeExtensionMock = vi.fn();
const delegateReimportMock = vi.fn();
const stateStub = {
virtualScroller: { items: [] },
loadingManager: {
showSimpleLoading: showSimpleLoadingMock,
hide: hideLoadingMock,
},
};
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
updateRecipeMetadata: vi.fn(),
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
moveManager: { showMoveModal: vi.fn() },
}));
vi.mock('../../../static/js/components/ContextMenu/ModelContextMenuMixin.js', () => ({
ModelContextMenuMixin: {
handleCommonMenuActions: vi.fn(() => false),
initNSFWSelector: vi.fn(),
},
}));
// Keep the real getCivitaiImageInfo (gating logic under test); mock only the
// extension communication.
vi.mock('../../../static/js/utils/extensionReimportBridge.js', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
probeExtension: probeExtensionMock,
delegateReimport: delegateReimportMock,
};
});
describe('RecipeContextMenu.reimportRecipe extension delegation', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<div id="recipeContextMenu" class="context-menu" style="display: none;">
<div class="context-menu-item" data-action="reimport"></div>
</div>
`;
stateStub.virtualScroller.items = [
{
id: 'recipe-1',
file_path: '/recipes/recipe-1.webp',
title: 'Civitai Recipe',
source_path: 'https://civitai.com/images/12345',
},
{
id: 'recipe-2',
file_path: '/recipes/recipe-2.webp',
title: 'Local Recipe',
source_path: '/data/imports/local.png',
},
];
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, recipe_id: 'new-id', loras_count: 2 }),
});
});
afterEach(() => {
delete global.fetch;
});
async function createMenu() {
const { RecipeContextMenu } = await import(
'../../../static/js/components/ContextMenu/RecipeContextMenu.js'
);
return new RecipeContextMenu();
}
it('delegates to the extension for a CivitAI image source when licensed', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockResolvedValue({ completed: 1, failed: 0 });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).toHaveBeenCalledWith([{
recipeId: 'recipe-1',
imageId: 12345,
imageUrl: 'https://civitai.com/images/12345',
title: 'Civitai Recipe',
}]);
expect(global.fetch).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
});
it('shows the failure toast when the extension reports failures', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockResolvedValue({ completed: 0, failed: 1 });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(showToastMock).toHaveBeenCalledWith(
'recipes.contextMenu.reimport.failed',
{ message: 'Extension re-import failed' },
'error'
);
expect(global.fetch).not.toHaveBeenCalled();
expect(resetAndReloadMock).toHaveBeenCalledWith(false, { preserveScroll: false });
});
it('uses the native path for non-CivitAI sources without probing', async () => {
const menu = await createMenu();
await menu.reimportRecipe('recipe-2');
expect(probeExtensionMock).not.toHaveBeenCalled();
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-2/reimport', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
});
it('uses the native path when the extension is absent (probe timeout)', async () => {
probeExtensionMock.mockResolvedValue(null);
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
});
it('uses the native path when the license is invalid', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: false });
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(delegateReimportMock).not.toHaveBeenCalled();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
});
it('falls back to the native path when delegation fails', async () => {
probeExtensionMock.mockResolvedValue({ supported: true, licenseValid: true });
delegateReimportMock.mockRejectedValue(new Error('Extension re-import timed out'));
const menu = await createMenu();
await menu.reimportRecipe('recipe-1');
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1/reimport', {
method: 'POST',
});
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.reimportSuccess', {}, 'success');
});
});
@@ -153,6 +153,16 @@ const hashInvalidLora = {
hashInvalid: true, hashInvalid: true,
}; };
// Mirrors the shape served for page-imported recipes whose CivitAI version
// exposes no sha256: an exact modelVersionId but no modelId and no hash.
const versionOnlyLora = {
name: 'version-lora',
modelName: 'Version Only LoRA',
inLibrary: false,
modelVersionId: 3221586,
modelVersionName: 'V1 KREA-2',
};
const recipeWithResources = { const recipeWithResources = {
id: 'recipe-resources', id: 'recipe-resources',
file_path: '/recipes/resources.json', file_path: '/recipes/resources.json',
@@ -171,6 +181,7 @@ const recipeWithResources = {
hashInvalidLora, hashInvalidLora,
{ name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false }, { name: 'mystery-lora', modelName: 'Mystery LoRA', inLibrary: false },
hashOnlyLora, hashOnlyLora,
versionOnlyLora,
], ],
}; };
@@ -281,6 +292,57 @@ describe('RecipeModal resource item interactions', () => {
); );
}); });
it('renders a download action (not reconnect) for a version-only LoRA', async () => {
const recipeModal = await createRecipeModal();
recipeModal.showRecipeDetails(recipeWithResources);
await flushWiring();
const item = document.querySelector('[data-lora-index="6"]');
expect(item).not.toBeNull();
expect(item.classList.contains('missing-locally')).toBe(true);
// Missing from the local library (badge) but still downloadable by its
// exact CivitAI version id, so the row offers Download, not Reconnect.
expect(item.querySelector('.missing-badge')).not.toBeNull();
expect(item.querySelector('.lora-download')).not.toBeNull();
expect(item.querySelector('.lora-reconnect')).toBeNull();
});
it('downloads a version-only LoRA by resolving the model id from the version endpoint', async () => {
const recipeModal = await createRecipeModal();
const requests = [];
// Isolated copy keeps mutations out of the shared fixture.
const isolatedRecipe = JSON.parse(JSON.stringify(recipeWithResources));
fetchRecipeDetailsMock.mockResolvedValue(isolatedRecipe);
global.fetch = vi.fn(async (url) => {
requests.push(String(url));
if (String(url).includes('/civitai/model/version/3221586')) {
return {
ok: true,
json: async () => ({ id: 3221586, modelId: 56789, name: 'V1 KREA-2' }),
};
}
return { ok: true, json: async () => ({}) };
});
recipeModal.showRecipeDetails(isolatedRecipe);
await flushWiring();
const item = document.querySelector('[data-lora-index="6"]');
item.querySelector('.lora-download').click();
await vi.waitFor(() => {
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledTimes(1);
});
expect(
requests.some(u => u.includes('/civitai/model/version/3221586'))
).toBe(true);
expect(downloadVersionWithDefaultsMock).toHaveBeenCalledWith(
'loras',
56789,
3221586,
expect.objectContaining({ source: 'recipe-modal' })
);
});
it('does not navigate when a missing LoRA row is clicked', async () => { it('does not navigate when a missing LoRA row is clicked', async () => {
const recipeModal = await createRecipeModal(); const recipeModal = await createRecipeModal();
const navigateSpy = vi const navigateSpy = vi
@@ -0,0 +1,216 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
probeExtension,
delegateReimport,
getCivitaiImageInfo,
} from '../../../static/js/utils/extensionReimportBridge.js';
const dispatchedEvents = [];
function dispatchProtocolEvent(type, payload) {
document.dispatchEvent(
new CustomEvent(type, { detail: JSON.stringify(payload) })
);
}
// Installs a fake extension that answers probes with the given result.
function installProbeResponder(result) {
const listener = () => dispatchProtocolEvent('lm:reimportProbeResult', result);
document.addEventListener('lm:reimportProbe', listener);
return () => document.removeEventListener('lm:reimportProbe', listener);
}
afterEach(() => {
dispatchedEvents.length = 0;
});
describe('probeExtension', () => {
it('resolves null when no extension answers within the timeout', async () => {
const result = await probeExtension({ timeoutMs: 20 });
expect(result).toBeNull();
});
it('resolves the probe result when the extension answers', async () => {
const uninstall = installProbeResponder({
supported: true,
licenseValid: true,
extensionVersion: '1.2.3',
});
try {
const result = await probeExtension({ timeoutMs: 1000 });
expect(result).toEqual({
supported: true,
licenseValid: true,
extensionVersion: '1.2.3',
reason: undefined,
});
} finally {
uninstall();
}
});
it('reports unsupported/unlicensed answers verbatim', async () => {
const uninstall = installProbeResponder({
supported: false,
licenseValid: false,
reason: 'license expired',
});
try {
const result = await probeExtension({ timeoutMs: 1000 });
expect(result.supported).toBe(false);
expect(result.licenseValid).toBe(false);
expect(result.reason).toBe('license expired');
} finally {
uninstall();
}
});
it('ignores malformed probe results and times out', async () => {
const listener = () => {
document.dispatchEvent(
new CustomEvent('lm:reimportProbeResult', { detail: '{broken json' })
);
};
document.addEventListener('lm:reimportProbe', listener);
try {
const result = await probeExtension({ timeoutMs: 20 });
expect(result).toBeNull();
} finally {
document.removeEventListener('lm:reimportProbe', listener);
}
});
});
describe('delegateReimport', () => {
const recipes = [
{ recipeId: 'r1', imageId: 123, imageUrl: 'https://civitai.com/images/123', title: 'One' },
{ recipeId: 'r2', imageId: 456, imageUrl: 'https://civitai.com/images/456', title: 'Two' },
];
it('rejects immediately for an empty recipe list', async () => {
await expect(delegateReimport([])).rejects.toThrow('non-empty');
});
it('rejects on timeout when the extension never answers', async () => {
await expect(
delegateReimport(recipes, { timeoutMs: 20 })
).rejects.toThrow('timed out');
});
it('dispatches the batch with a requestId and resolves on batchDone', async () => {
const progressEvents = [];
let seenRequest = null;
const listener = (event) => {
seenRequest = JSON.parse(event.detail);
const { requestId } = seenRequest;
// Progress for a DIFFERENT batch must be ignored.
dispatchProtocolEvent('lm:reimportProgress', {
requestId: 'other-batch',
current: 99,
total: 99,
recipeId: 'nope',
title: 'nope',
status: 'success',
});
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 1,
total: 2,
recipeId: 'r1',
title: 'One',
status: 'success',
});
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 2,
total: 2,
recipeId: 'r2',
title: 'Two',
status: 'failed',
message: 'boom',
});
dispatchProtocolEvent('lm:reimportBatchDone', {
requestId,
completed: 1,
failed: 1,
});
};
document.addEventListener('lm:reimportViaExtension', listener);
try {
const result = await delegateReimport(recipes, {
onProgress: (progress) => progressEvents.push(progress),
timeoutMs: 1000,
});
expect(seenRequest.recipes).toEqual(recipes);
expect(typeof seenRequest.requestId).toBe('string');
expect(seenRequest.requestId.length).toBeGreaterThan(0);
expect(result).toEqual({ completed: 1, failed: 1 });
// Only this batch's progress events reach the callback.
expect(progressEvents.map((p) => p.recipeId)).toEqual(['r1', 'r2']);
expect(progressEvents[1].status).toBe('failed');
} finally {
document.removeEventListener('lm:reimportViaExtension', listener);
}
});
it('resets the timeout on every progress heartbeat', async () => {
vi.useFakeTimers();
let requestId = null;
const listener = (event) => {
requestId = JSON.parse(event.detail).requestId;
};
document.addEventListener('lm:reimportViaExtension', listener);
try {
const promise = delegateReimport(recipes, { timeoutMs: 1000 });
// At t=900ms a progress event arrives, pushing the deadline to t=1900ms.
await vi.advanceTimersByTimeAsync(900);
dispatchProtocolEvent('lm:reimportProgress', {
requestId,
current: 1,
total: 2,
recipeId: 'r1',
title: 'One',
status: 'started',
});
// t=1800ms: past the original deadline, still alive thanks to heartbeat.
await vi.advanceTimersByTimeAsync(900);
dispatchProtocolEvent('lm:reimportBatchDone', {
requestId,
completed: 2,
failed: 0,
});
await expect(promise).resolves.toEqual({ completed: 2, failed: 0 });
} finally {
document.removeEventListener('lm:reimportViaExtension', listener);
vi.useRealTimers();
}
});
});
describe('getCivitaiImageInfo', () => {
it.each([
'https://civitai.com/images/12345',
'https://civitai.red/images/12345',
'https://civitai.green/images/12345',
'https://civitai.com/images/12345?foo=bar',
])('extracts the image id from %s', (url) => {
expect(getCivitaiImageInfo(url)).toEqual({ imageId: 12345, imageUrl: url });
});
it.each([
null,
'',
'not a url',
'ftp://civitai.com/images/12345',
'https://civitai.com/models/12345',
'https://example.com/images/12345',
'https://image.civitai.com/x/y/original=true/pic.png',
])('returns null for %s', (url) => {
expect(getCivitaiImageInfo(url)).toBeNull();
});
});
-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
+144 -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:
@@ -2356,3 +2341,147 @@ async def test_get_recipe_detail_includes_recipe_json_path(
assert response.status == 200 assert response.status == 200
payload = await response.json() payload = await response.json()
assert "recipe_json_path" not in payload assert "recipe_json_path" not in payload
async def test_reimport_with_extension_payload_uses_payload_path(
monkeypatch, tmp_path: Path
) -> None:
"""A re-import carrying the companion extension's metadata payload must
use the payload-based import engine (caller-supplied LoRAs) instead of
the legacy CivitAI image URL import, and report loras_count."""
provider_calls: list[str | int] = []
class Provider:
async def get_model_version_info(self, model_version_id):
provider_calls.append(model_version_id)
return {}, None
async def fake_get_default_metadata_provider():
return Provider()
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
old_file = harness.tmp_dir / "recipes" / "sub" / "rec-ext.webp"
harness.scanner.recipes["rec-ext"] = {
"id": "rec-ext",
"title": "Old title",
"file_path": str(old_file),
"tags": ["tag1"],
"source_path": "https://civitai.com/images/12345",
}
harness.civitai.image_info["12345"] = {
"id": 12345,
"url": "https://image.civitai.com/x/y/original=true/pic.png",
"type": "image",
}
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec-ext"}, status=200
)
# The freshly saved recipe as the scanner would see it (for loras_count).
harness.scanner.recipes["new-rec-ext"] = {
"id": "new-rec-ext",
"loras": [{"file_name": "Painterly"}],
}
resources = [
{
"type": "lora",
"modelId": 20,
"modelVersionId": 44,
"modelName": "Painterly",
"modelVersionName": "v2",
"weight": 0.5,
},
]
# The extension only issues GET requests (per its API convention).
response = await harness.client.get(
"/api/lm/recipe/rec-ext/reimport",
params={
"image_url": "https://civitai.com/images/12345",
"name": "Extension Recipe",
"resources": json.dumps(resources),
"gen_params": json.dumps({"prompt": "from extension"}),
"base_model": "Flux",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["old_recipe_id"] == "rec-ext"
assert payload["recipe_id"] == "new-rec-ext"
assert payload["loras_count"] == 1
save_call = harness.persistence.save_calls[-1]
# Caller-supplied payload data wins: name, LoRAs, gen params.
assert save_call["name"] == "Extension Recipe"
assert save_call["metadata"]["loras"][0]["file_name"] == "Painterly"
assert save_call["metadata"]["loras"][0]["weight"] == 0.5
assert save_call["metadata"]["gen_params"]["prompt"] == "from extension"
# Reimport semantics: original source_path and folder are preserved.
assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345"
assert save_call["target_dir"] == str(harness.tmp_dir / "recipes" / "sub")
# The old recipe is deleted and user edits carried over.
assert harness.persistence.delete_calls == ["rec-ext"]
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec-ext"
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
async def test_reimport_with_malformed_payload_falls_back_to_legacy(
monkeypatch, tmp_path: Path
) -> None:
"""Malformed resources JSON must be treated as "no payload": the legacy
source-URL import runs and the request still succeeds."""
async def fake_get_default_metadata_provider():
return SimpleNamespace(get_model_version_info=lambda id: ({}, None))
monkeypatch.setattr(
"py.recipes.enrichment.get_default_metadata_provider",
fake_get_default_metadata_provider,
)
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.recipes["rec-bad"] = {
"id": "rec-bad",
"title": "Broken payload",
"file_path": str(harness.tmp_dir / "recipes" / "rec-bad.webp"),
"tags": [],
"source_path": "https://civitai.com/images/12345",
}
harness.civitai.image_info["12345"] = {
"id": 12345,
"url": "https://image.civitai.com/x/y/original=true/pic.png",
"type": "image",
}
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "legacy-new"}, status=200
)
harness.scanner.recipes["legacy-new"] = {"id": "legacy-new", "loras": []}
response = await harness.client.get(
"/api/lm/recipe/rec-bad/reimport",
params={
"image_url": "https://civitai.com/images/12345",
"name": "Ignored Name",
"resources": "{not valid json",
},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["recipe_id"] == "legacy-new"
assert payload["loras_count"] == 0
save_call = harness.persistence.save_calls[-1]
# Legacy URL path: the payload name is ignored and the title is
# derived from the (empty) metadata, and no caller LoRAs are used.
assert save_call["name"] == "Civitai Image 12345"
assert save_call["metadata"]["loras"] == []
assert save_call["metadata"]["source_path"] == "https://civitai.com/images/12345"
assert harness.persistence.delete_calls == ["rec-bad"]
+41
View File
@@ -818,3 +818,44 @@ async def test_get_model_by_hash_rejects_empty_placeholder_without_request(downl
assert result is None assert result is None
assert error == "Model not found" assert error == "Model not found"
assert requested == [] assert requested == []
async def test_get_version_file_mini_returns_payload(downloader):
"""The mini endpoint returns the raw stored filename (#1100)."""
client = await CivitaiClient.get_instance()
async def fake_make_request(method, url, use_auth=True, **kwargs):
assert method == "GET"
assert url.endswith("/model-versions/mini/3284136")
assert kwargs.get("params") == {"modelFileId": 3168412}
assert use_auth is True
return True, {"fileName": "CyberRealistic_zit_v8.0_bf16.safetensors"}
downloader.make_request = fake_make_request
result = await client.get_version_file_mini(3284136, 3168412)
assert result == {"fileName": "CyberRealistic_zit_v8.0_bf16.safetensors"}
async def test_get_version_file_mini_returns_none_on_failure(downloader):
client = await CivitaiClient.get_instance()
async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, "Model file 2 not found in version 1"
downloader.make_request = fake_make_request
assert await client.get_version_file_mini(1, 2) is None
async def test_get_version_file_mini_propagates_rate_limit(downloader):
client = await CivitaiClient.get_instance()
async def fake_make_request(method, url, use_auth=True, **kwargs):
return False, RateLimitError("limited", retry_after=1.0)
downloader.make_request = fake_make_request
with pytest.raises(RateLimitError):
await client.get_version_file_mini(1, 2)
@@ -2098,3 +2098,174 @@ async def test_discard_cleared_downloads_stops_tracking_and_preserves_files(
# Partial files are preserved for a future resume from disk. # Partial files are preserved for a future resume from disk.
assert save_path.exists() assert save_path.exists()
assert control_path.exists() assert control_path.exists()
@pytest.mark.asyncio
async def test_download_uses_raw_file_name_from_mini_endpoint(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""#1100: when the REST name is rewritten ("{model}_{version}"), the raw
stored filename from the mini endpoint wins for the on-disk name."""
manager = DownloadManager()
get_settings_manager().settings["default_unet_root"] = str(tmp_path / "unet")
metadata_provider.payload = {
"id": 3284136,
"model": {"type": "Checkpoint", "tags": ["realistic"]},
"baseModel": "ZImageTurbo",
"creator": {"username": "Author"},
"files": [
{
"id": 3168412,
"type": "Model",
"primary": True,
"name": "cyberrealisticZImage_v80.safetensors",
"downloadUrl": "https://civitai.com/api/download/models/3284136?fileId=3168412",
}
],
}
metadata_provider.get_version_file_mini = AsyncMock(
return_value={"fileName": "CyberRealistic_zit_v8.0_bf16.safetensors"}
)
captured = {}
async def fake_execute_download(self, **kwargs):
captured["download_urls"] = kwargs["download_urls"]
captured["file_path"] = kwargs["metadata"].file_path
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=3284136,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True, result
metadata_provider.get_version_file_mini.assert_awaited_once_with(3284136, 3168412)
assert captured["file_path"].endswith("CyberRealistic_zit_v8.0_bf16.safetensors")
# The file's own pinned downloadUrl is untouched.
assert captured["download_urls"] == [
"https://civitai.com/api/download/models/3284136?fileId=3168412"
]
@pytest.mark.asyncio
async def test_download_falls_back_to_rest_name_when_mini_fails(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A failed/absent mini lookup must keep the previous behavior."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 42,
"model": {"type": "Checkpoint", "tags": ["fantasy"]},
"baseModel": "BaseModel",
"creator": {"username": "Author"},
"files": [
{
"id": 1001,
"type": "Model",
"primary": True,
"name": "rewritten_v10.safetensors",
"downloadUrl": "https://example.invalid/file.safetensors",
}
],
}
metadata_provider.get_version_file_mini = AsyncMock(return_value=None)
captured = {}
async def fake_execute_download(self, **kwargs):
captured["file_path"] = kwargs["metadata"].file_path
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=42,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["file_path"].endswith("rewritten_v10.safetensors")
@pytest.mark.asyncio
async def test_download_skips_mini_lookup_for_civarchive_source(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""CivArchive already serves raw stored names — no mini call."""
manager = DownloadManager()
mini_mock = AsyncMock(return_value={"fileName": "should_not_be_used.safetensors"})
metadata_provider.get_version_file_mini = mini_mock
monkeypatch.setattr(
download_manager,
"get_metadata_provider",
AsyncMock(return_value=metadata_provider),
)
captured = {}
async def fake_execute_download(self, **kwargs):
captured["file_path"] = kwargs["metadata"].file_path
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=99,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source="civarchive",
)
assert result["success"] is True
mini_mock.assert_not_called()
assert captured["file_path"].endswith("file.safetensors")
@pytest.mark.asyncio
async def test_fetch_raw_file_name_edge_cases():
"""_fetch_raw_file_name never raises and strips path components."""
manager = DownloadManager()
provider = SimpleNamespace()
# Missing version id / file id short-circuit before any provider call.
provider.get_version_file_mini = AsyncMock()
assert await manager._fetch_raw_file_name(provider, None, 1) is None
assert await manager._fetch_raw_file_name(provider, 1, None) is None
provider.get_version_file_mini.assert_not_called()
# Provider without the method (older mocks / non-CivitAI providers).
assert await manager._fetch_raw_file_name(object(), 1, 2) is None
# Non-dict payload, empty fileName.
provider.get_version_file_mini = AsyncMock(return_value="oops")
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
provider.get_version_file_mini = AsyncMock(return_value={"fileName": " "})
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
# Path components are stripped defensively.
provider.get_version_file_mini = AsyncMock(
return_value={"fileName": "../evil/model.safetensors"}
)
assert await manager._fetch_raw_file_name(provider, 1, 2) == "model.safetensors"
# Provider exceptions degrade to None.
provider.get_version_file_mini = AsyncMock(side_effect=RuntimeError("boom"))
assert await manager._fetch_raw_file_name(provider, 1, 2) is None
+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 == []
@@ -207,3 +207,62 @@ async def test_retry_helper_retries_normally_for_small_retry_after(monkeypatch):
result, _ = await helper.run("test", succeeding) result, _ = await helper.run("test", succeeding)
assert result == {"ok": True} assert result == {"ok": True}
assert calls == 2 # Retried once (small retry_after) assert calls == 2 # Retried once (small retry_after)
class MiniCapableProvider(ModelMetadataProvider):
"""Provider that serves raw file names via the mini endpoint (#1100)."""
def __init__(self, payload=None) -> None:
self.payload = payload
self.calls = []
async def get_model_by_hash(self, model_hash: str):
return None, None
async def get_model_versions(self, model_id: str):
return None
async def get_model_version(self, model_id=None, version_id=None):
return None
async def get_model_version_info(self, version_id: str):
return None, None
async def get_user_models(self, username: str, cursor=None):
return None
async def get_version_file_mini(self, version_id: int, file_id: int):
self.calls.append((version_id, file_id))
return self.payload
@pytest.mark.asyncio
async def test_base_provider_get_version_file_mini_defaults_to_none():
provider = TrackingProvider()
assert await provider.get_version_file_mini(1, 2) is None
@pytest.mark.asyncio
async def test_fallback_get_version_file_mini_returns_first_hit():
primary = TrackingProvider() # base default: None
secondary = MiniCapableProvider({"fileName": "raw.safetensors"})
fallback = FallbackMetadataProvider(
[("primary", primary), ("secondary", secondary)],
)
result = await fallback.get_version_file_mini(10, 20)
assert result == {"fileName": "raw.safetensors"}
assert secondary.calls == [(10, 20)]
@pytest.mark.asyncio
async def test_rate_limit_retrying_provider_delegates_get_version_file_mini():
inner = MiniCapableProvider({"fileName": "raw.safetensors"})
wrapper = RateLimitRetryingProvider(inner, label="inner")
result = await wrapper.get_version_file_mini(10, 20)
assert result == {"fileName": "raw.safetensors"}
assert inner.calls == [(10, 20)]
-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')
} }
}) })
+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