Compare commits

...

41 Commits

Author SHA1 Message Date
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
Will Miao 634ea7f299 fix(ui): keep autocomplete corner buttons clear of the textarea scrollbar
The absolutely-positioned clear (x) and active-filters filter buttons sit at
the textarea's right edge, so when content overflows and a classic
(non-overlay) vertical scrollbar appears the buttons overlap it. Measure the
scrollbar gutter (offsetWidth - clientWidth) when content overflows and
expose it as --lm-vscrollbar-width on .input-wrapper; the buttons' right is
now calc(base + var) so they shift left of the scrollbar only while one is
present (0 otherwise, incl. overlay-scrollbar platforms).

Refreshed on input, mount, canvas/Vue-DOM mode change and via a ResizeObserver
on the textarea (widget resize). Rebuilt the vue-widgets bundle.
2026-09-04 12:41:31 +08:00
Will Miao 6ba64ebb3c feat(ui): improve /activefilters discoverability on loras nodes
Mirror the /noautocomplete discoverability pattern for the loras\nactive-filters search toggle:\n\n- autocomplete.js: extend the slash-command-list footer and the\n  one-time first-run hint to loras nodes, advertising\n  /activefilters and /noactivefilters\n- lora_loader.js: add an 'Active Filters Search: ON/OFF' entry to the\n  right-click menu of all loras-autocomplete node classes\n- settings.js: broadcast a 'lora-manager:setting-toggled' window event\n  on every setLoraManagerSettingValue write\n- AutocompleteTextWidget.vue: add a persistent filter indicator chip\n  (loras mode only) that reflects and toggles the setting and stays in\n  sync via the setting-toggled event\n- tests: footer/hint/event coverage, context-menu tests for all four\n  node classes, widget indicator tests; rebuild vue-widgets bundle
2026-09-03 22:42:45 +08:00
Will Miao 03569c62df feat(ui): point help new-content indicator at the updated tabs and elements
- Replace timestamp comparison (help_last_viewed vs a hardcoded date) with
  a content-version marker (data-help-content-version) read from the
  rendered modal markup, so badge state always reflects the content
  actually served
- Only mark content as viewed when the modal is opened while it contains
  new content; opening a stale pre-upgrade page no longer suppresses the
  badge after a refresh
- Flag the Replay Tutorial button itself with a 'New' chip (hidden by
  default, one-time glow animation) and scroll it into view when
  revealed; tab-level dots now mark getting-started and shortcuts
  instead of documentation
- Translate help.newContentBadge into all 9 locales, reusing the
  established help.documentation.newBadge renderings
- Add HelpManager content-version unit tests (12 cases)
2026-09-03 21:40:18 +08:00
Will Miao a61840b366 i18n: translate onboarding/shortcuts/trigger-word keys into all 9 locales
- Fill all 41 [TODO: Translate] placeholders per locale (new onboarding
  steps, Shortcuts cheat-sheet tab, trigger-word copy/edit tooltip)
- Retranslate stale onboarding bulk/contextMenu step contents to match
  the updated en.json source
- Follows docs/i18n-translation-guidelines.md term maps, register, and
  punctuation rules; HTML tags and key names preserved verbatim
2026-09-03 19:20:54 +08:00
Will Miao 726fc178f1 feat(ui): add R/F/D action shortcuts and unify keycap hint style
- Bind R=refresh, F=fetch metadata, D=download in PageControls via
  eventManager (plain letters only, skipped while typing or when a
  modal is open); triggers reuse the buttons' existing click handlers
- Show key-hint chips on the refresh/fetch/download/bulk toolbar
  buttons; convert the bulk chip to a semantic <kbd>
- Redesign shortcut hints as a neutral theme-adaptive keycap:
  --shortcut-* variables in base.css now derive from --text-muted
  with a bottom-edge shadow, shared by the toolbar chips, the header
  search cue, the help-modal cheat sheet, and onboarding key hints
- Add shared isTypingContext() helper to uiHelpers
- Add an Actions group (R/F/D) to the Shortcuts cheat-sheet tab

Verified with vitest (926 passing, incl. 6 new shortcut cases) and a
sandboxed E2E run in real Chrome (light/dark rendering, hover state,
'?' opening the Shortcuts tab, clean console)
2026-09-03 19:10:16 +08:00
Will Miao 8260bd022d feat(ui): improve discoverability of hidden interactions
- Expand onboarding tour from 8 to 11 steps: marquee drag-select,
  drag card to sidebar folder, and the three context menus
  (card / bulk / global); enrich bulk-mode step with range-select
  and exit tips
- Add Replay Tutorial button to help modal Getting Started tab
- Add Shortcuts cheat-sheet tab to help modal, opened directly via
  the '?' key when not typing
- Fix trigger-word tooltip to mention double-click to edit
- Keep checkpoint/embedding send tooltips truthful (no replace mode)

Sync new i18n keys to all locales (placeholders pending translation)
2026-09-03 18:16:00 +08:00
Will Miao b309becdf9 fix(recipes): keep source_path empty on local-fallback re-import
Re-importing a file-imported recipe fell back to its own saved preview
image, then recorded that internal path as the new recipe's source_path.
Since the old preview is deleted with the old recipe, this left a
dangling source_path that showed up as a bogus source URL and blocked
any further re-import with 'no re-importable source'.

Only persist source_path when the re-import source is an accessible
external file; otherwise keep it empty. Also let a dangling non-URL
source_path fall back to the recipe's own image so existing affected
recipes can re-import again.
2026-09-03 17:13:24 +08:00
Will Miao 1e375bb8d9 i18n: translate common.scanProgress into all 9 locales 2026-09-03 11:47:42 +08:00
Will Miao 14da8a6f17 feat(ui): show live scan progress and ETA for cache refresh
Broadcast typed scan_progress messages over /ws/fetch-progress from the
manual refresh/rebuild paths of ModelScanner and RecipeScanner, and
render percent, processed/total, current file name and an EMA-smoothed
ETA in the loading overlay. Hardcoded refresh strings move to i18n
(common.scanProgress); WS connection failure falls back to the previous
static loading behavior.
2026-09-03 11:38:27 +08:00
Will Miao da71985c3e fix(autocomplete): strip lastAccepted boundary from exported workflows (#1093)
The hidden __lm_autocomplete_meta_* widget persisted lastAccepted
(insertedText/textSnapshot) into exported workflow JSON, leaking old
prompt text even after the user deleted it.

Patch app.graphToPrompt (shared by workflow export, Export API and
queueing) to strip lastAccepted from the serialized result's
widgets_values / widgets_values_named / output inputs. Only the
exported artifact is touched; live node state, undo snapshots,
copy/paste and local saves keep the boundary intact.
2026-09-03 08:29:55 +08:00
Will Miao 7c4c8b8f30 fix(ui): add disabled state and feedback to usage tips Add button
The Add button silently returned when no parameter or value was
provided, looking clickable but doing nothing. Keep it disabled until
both inputs are filled, validate the numeric value, surface save
failures via toast without clearing user input, and confirm additions
vs overwrites with success toasts. Includes translations for all
locales.
2026-09-02 23:15:23 +08:00
Will Miao 77109b3cf8 feat(autocomplete): group relative-path results by folder (#1091)
Autocomplete suggestions were ranked purely by relevance across the whole
library, so same-named loras from different subfolders interleaved and were
hard to tell apart. Results are now bucketed by folder (root first, then
alphabetically, with nested paths sorting naturally) while keeping the
existing relevance ordering within each folder group.
2026-09-02 22:01:44 +08:00
Will Miao 00095a5398 fix(autocomplete): sync active filters via server-side store (#1091)
The LoRA Manager page kept its active filters in localStorage, which the
ComfyUI-side autocomplete read directly. When the two run in different
browsers, origins, or the ComfyUI Desktop Electron shell, localStorage is
not shared and the active-filters search silently did nothing.

The manager page now mirrors its filter state to a server-side in-memory
store (PUT /api/lm/{prefix}/active-filters), pushed on every change via a
storage-listener hook and once on page load. The autocomplete widget sends
only use_active_filters=true, and the relative-paths endpoint injects the
stored filters into the search, with explicit query params taking
precedence.
2026-09-02 14:33:44 +08:00
Will Miao 6b41c3bbb4 fix(tests): deflake recipe modal resource item tests by disposing modal instances
RecipeModal instances keep fire-and-forget async chains (hydration
re-renders, mark-hash-invalid re-renders, 500ms reconnect/restore
re-renders) and deferred DOM wiring timers alive across tests. On slow
CI runners these land in the next test's window and overwrite or re-wire
the shared document.body with stale content and stale instance handlers,
failing a different test on every run.

Add a tracked-timer helper and a dispose() teardown hook to RecipeModal:
pending deferred work is cancelled, in-flight async chains become no-ops
after disposal, and the global click listener is detached. The test
afterEach now disposes every modal instance, making the file hermetic.
2026-09-02 12:40:37 +08:00
Will Miao b37238d790 fix(ui): disable modal backdrop blur under software rendering (#1092)
With hardware acceleration disabled, Chrome rasterizes in software and a
full-viewport backdrop-filter forces a per-frame CPU blur over everything
behind the modal, freezing the whole browser.

Detect software rendering via the unmasked WebGL renderer string at app
startup and drop the backdrop blur in that case. Also route the download
modal's sticky toolbar through the shared --modal-backdrop-blur variable
instead of a hardcoded blur(8px).
2026-09-02 12:24:32 +08:00
Will Miao bc33e32c6f feat(showcase): add wheel, swipe and keyboard navigation to the example gallery
- Wheel on the main viewer: horizontal deltas always switch examples;
  vertical deltas switch only at the modal scroll boundary, then stay in a
  sticky session (down = next, up = prev) until the pointer leaves the area
- Touch/pen horizontal swipe switches examples; the synthesized click after
  a swipe is swallowed so the media viewer does not open
- '[' / ']' switch examples while the gallery is expanded; ArrowLeft/Right
  stay reserved for model-level navigation
- Direction-aware slide transition on every switch for visual feedback
  (respects prefers-reduced-motion)
2026-09-02 11:52:59 +08:00
Will Miao 49704d801c fix: show lora info regardless of toggle state 2026-09-02 11:38:30 +08:00
Will Miao 34ca14d7fc fix(showcase): reset gallery position when loading a model's examples
The module-level galleryState kept activeIndex/expanded across models
(the modal is a singleton), so opening model B after navigating model A
started B's gallery at A's last index. Reset activeIndex, expanded and
lastNavDirection in loadExampleImages, the per-model entry point.
2026-09-01 22:54:36 +08:00
Will Miao f7b247f9e8 perf(showcase): cap main viewer image width at 2400 via display mode
- New OptimizationMode.DISPLAY (width=2400 for images, full quality for
  videos) and getDisplayUrl(); the in-modal main viewer renders at most
  ~1200 CSS px wide, so full-size originals wasted 50-70% bandwidth
- Main viewer and adjacent prefetch use display URLs; the full-size
  media viewer keeps using getShowcaseUrl for original quality
2026-09-01 22:45:00 +08:00
Will Miao 3005d2877e perf(showcase): direction-aware prefetch and lazy video thumbnails
- Track last navigation direction and prefetch one extra example ahead
  along it, so repeated prev/next clicks stay cache-hot
- Start strip video thumbnails at preload=none and enable metadata
  loading only when they scroll into view
2026-09-01 22:37:13 +08:00
Will Miao ed2a17970f perf(showcase): prefetch adjacent examples and shrink gallery thumbnails
- Warm the HTTP cache for examples adjacent to the active one after
  expand and on every navigation, so prev/next feels instant (images
  only, deduped, low fetch priority)
- Add GALLERY_THUMBNAIL optimization mode (width=160) for the 72px
  gallery strip instead of reusing the 450px card thumbnails
- Hint priorities: fetchpriority=high on the main media, low on
  strip thumbnails
2026-09-01 22:26:50 +08:00
Will Miao 9584fa85c9 feat(recipes): add location open and recipe ID copy to recipe modal
Add a de-emphasized meta footer to the recipe modal, mirroring the model
modal's hash footnote: a clickable file location on the left (opens the
recipe JSON via the generic open-file-location route, with the Docker
clipboard fallback) and a middle-truncated recipe ID with copy button on
the right.

The recipe detail API now exposes recipe_json_path so the frontend does
not have to guess the on-disk storage layout. Translations for the new
recipes.modal.* keys are filled in for all 9 locales, reusing the model
modal's openFileLocation wording per locale.
2026-09-01 21:58:47 +08:00
Will Miao 1fd7cc0123 fix(recipes): reject the empty-hash placeholder when resolving LoRA hashes
The SHA256 of an empty byte string (written by repackaging tools into
safetensors metadata, or produced by hashing an empty/unreadable file)
was previously resolved against CivitAI's by-hash API, which can contain
polluted entries for it (e.g. a broken SD 1.5 LoRA whose AutoV3 equals
the placeholder) and falsely attributed the wrong model to a recipe.

Guard all lookup paths for the 10/12/64-char AutoV2/AutoV3/full-SHA256
spellings: CivitaiClient.get_model_by_hash/_fetch_version_by_hash return
not-found without a request, and ModelHashIndex ignores the placeholder
in has_hash/get_path/add_autov3.

The Automatic1111 metadata parser keeps the LoRA item itself when its
hash is the placeholder: it matches by filename locally, or retains the
entry with an empty hash flagged hashInvalid (unresolvable-hash state in
the UI, with reconnect as the remedy) instead of dropping it or resolving
it to a polluted CivitAI entry.
2026-09-01 21:14:30 +08:00
Will Miao 39e7c1376c Support re-import for recipes without a source URL
Recipes imported by drag & drop / file-picker record no source_path and
were rejected by re-import. Fall back to the recipe's own saved image,
which still carries the original embedded generation metadata.

Re-import now re-parses that original metadata instead of the appended
recipe JSON block, so parser upgrades produce fresh results. The
already-optimized preview image is kept verbatim: only its WebP EXIF
chunk is rewritten in place to replace the recipe metadata block, and
the recipe JSON is rewritten with the new analysis plus carried-over
user edits.
2026-08-31 10:01:18 +08:00
Will Miao 2a3c632dc5 feat(recipes): add Unknown base-model filter bucket for undetermined recipes
Normalize undetermined recipe base_model to None in RecipeFormatParser
(previously ''). get_base_models now reports an "Unknown" bucket backed
by a dedicated __unknown__ marker, and the listing filter matches it
against recipes whose base model is falsy. Frontend renders the bucket
label as "Unknown" while filtering via the marker.

Tests: handler, scanner, parser, and frontend filtering.
2026-08-31 09:09:53 +08:00
Will Miao 8d46d26abe fix(tests): deflake recipe open stats tests by shrinking debounce in tests
The four tests that wait on the background debounced write race against
SAVE_DELAY (1.0s): _wait_for_save polls 100 x 0.01s = 1.0s, exactly equal to
the debounce, leaving zero slack. On a loaded CI runner the write lands after
the poll gives up, failing intermittently with 'Recipe open stats file was
never written' (5 of 62 backend runs since the tests landed).

Shrink SAVE_DELAY to 0.05s in _prepare so the write lands ~20x inside the
poll window. The debounce duration is not what these tests verify; production
default stays 1.0s.
2026-08-30 22:14:04 +08:00
143 changed files with 10360 additions and 3319 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.
- Request handlers in `py/routes/handlers/` implement route logic
- 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
@@ -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`
- 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
- **Settings:** Stored in the user config directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
+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 |
|---|---|---|
| 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 |
| 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" |
@@ -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`) |
| Embedding | Embedding | 4 keys lowercase "embedding" mid-sentence |
| bulk | 一括 | `modals.checkUpdates.tip` "バルクモード" → 一括モード |
| recipe counter | 件 or 個 | `repairRecipes.success` uses 件, `.cancelled` uses 個 — unify |
| recipe counter | 件 or 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
### 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.
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "{type} werden aktualisiert...",
"fullRebuilding": "{type} werden vollständig neu aufgebaut...",
"actionRefresh": "Aktualisierung",
"actionFullRebuild": "Vollständiger Neuaufbau",
"actionRefreshLower": "Aktualisieren",
"actionRebuildLower": "Neuaufbau",
"stages": {
"scan_folders": "Ordner werden gescannt...",
"count_models": "{total} Dateien gefunden",
"process_models": "Modelle werden verarbeitet",
"reconcile_scan": "Änderungen werden geprüft...",
"process_new": "Neue Modelle werden verarbeitet",
"finalizing": "Abschließen..."
},
"eta": {
"lessThanMinute": "Weniger als eine Minute verbleibend",
"minutes": "~{minutes} Min. verbleibend",
"hours": "~{hours} Std. {minutes} Min. verbleibend"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "Massenoperationen",
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken. Wählen Sie mehrere Modelle aus und führen Sie Stapeloperationen durch. Mit <span class=\"onboarding-shortcut\">Strg+A</span> können Sie alle sichtbaren Modelle auswählen."
"content": "Wechseln Sie in den Massenmodus, indem Sie auf diese Schaltfläche klicken oder <span class=\"onboarding-shortcut\">B</span> drücken, um mehrere Modelle auszuwählen und Stapeloperationen durchzuführen.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> wählt alle sichtbaren Modelle aus, <span class=\"onboarding-shortcut\">Shift+Click</span> wählt einen Bereich aus.<br>• <span class=\"onboarding-shortcut\">Esc</span> oder ein Klick auf einen leeren Bereich verlässt den Massenmodus."
},
"searchOptions": {
"title": "Suchoptionen",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "Kontextmenü",
"content": "<strong>Rechtsklick</strong> auf eine Modellkarte öffnet ein Kontextmenü mit weiteren Aktionen."
"content": "<strong>Rechtsklick</strong> auf eine beliebige Modellkarte öffnet ein Kontextmenü mit Kartenaktionen wie Verschieben, Löschen oder Bearbeiten von Metadaten."
},
"marqueeSelect": {
"title": "Durch Ziehen auswählen",
"content": "Halten Sie die <strong>linke Maustaste</strong> auf einem leeren Bereich des Rasters gedrückt und ziehen Sie, um einen Auswahlrahmen aufzuziehen, der mehrere Karten gleichzeitig auswählt."
},
"dragToSidebar": {
"title": "Organisieren durch Ziehen",
"content": "Ziehen Sie eine Modellkarte auf einen Ordner in der Seitenleiste, um die Datei dorthin zu verschieben. Dies funktioniert auch mit mehreren ausgewählten Karten im Massenmodus."
},
"contextMenus": {
"title": "Weitere Kontextmenüs",
"content": "<strong>Rechtsklick auf eine ausgewählte Karte</strong> im Massenmodus öffnet die Massenaktionen. <strong>Rechtsklick auf einen leeren Bereich</strong> der Seite öffnet globale Aktionen wie das Prüfen auf Updates und das Verwalten ausgeschlossener Modelle."
}
}
},
@@ -179,13 +212,6 @@
"none": "Alle {typePlural} verfügen bereits über Lizenzmetadaten",
"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": {
"label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"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",
"displayDensityOptions": {
"default": "Standard",
@@ -786,7 +814,6 @@
"setContentRating": "Inhaltsbewertung für alle festlegen",
"copyAll": "Alle Syntax kopieren",
"refreshAll": "Alle Metadaten aktualisieren",
"repairMetadata": "Metadaten der Auswahl reparieren",
"rematchMetadata": "Ausgewählte mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren",
"checkUpdates": "Auswahl auf Updates prüfen",
@@ -842,7 +869,6 @@
"replacePreview": "Vorschau ersetzen",
"setContentRating": "Inhaltsbewertung festlegen",
"moveToFolder": "In Ordner verschieben",
"repairMetadata": "Metadaten reparieren",
"rematchMetadata": "Mit lokalen Modellen abgleichen",
"reimportMetadata": "Aus Quelle neu importieren",
"excludeModel": "Modell ausschließen",
@@ -868,6 +894,21 @@
"previousWithShortcut": "Vorheriges Rezept (←)",
"nextWithShortcut": "Nächstes Rezept (→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "Dateispeicherort öffnen",
"copyId": "Rezept-ID kopieren"
},
"openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet",
"failed": "Fehler beim Öffnen des Dateispeicherorts",
"copied": "Pfad in die Zwischenablage kopiert: {{path}}",
"clipboardFallback": "Pfad: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Workflow an ComfyUI senden",
"sent": "Workflow an ComfyUI gesendet",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"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": {
"starting": "Rezept wird aus Quelle neu importiert...",
"success": "Rezept erfolgreich neu importiert",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Wert",
"add": "Hinzufügen",
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y"
"invalidRange": "Ungültiges Bereichsformat. Verwenden Sie x.x-y.y",
"invalidValue": "Bitte geben Sie eine gültige Zahl ein",
"saveFailed": "Fehler beim Speichern des voreingestellten Parameters",
"added": "Voreingestellter Parameter hinzugefügt",
"updated": "Voreingestellter Parameter aktualisiert"
},
"triggerWords": {
"label": "Trigger Words",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "Tippen zum Hinzufügen oder klicken Sie auf Vorschläge unten",
"editWord": "Trigger Word bearbeiten",
"editPlaceholder": "Trigger Word bearbeiten",
"copyWord": "Trigger Word kopieren",
"copyOrEditWord": "Klicken zum Kopieren, Doppelklick zum Bearbeiten",
"deleteWord": "Trigger Word löschen",
"suggestions": {
"noSuggestions": "Keine Vorschläge verfügbar",
@@ -1649,8 +1687,8 @@
"showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel",
"nextExample": "Nächstes Beispiel",
"previousExample": "Vorheriges Beispiel ([)",
"nextExample": "Nächstes Beispiel (])",
"noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "Erste Schritte",
"updateVlogs": "Update-Vlogs",
"documentation": "Dokumentation"
"documentation": "Dokumentation",
"shortcuts": "Tastenkürzel"
},
"gettingStarted": {
"title": "Erste Schritte mit LoRA Manager"
"title": "Erste Schritte mit LoRA Manager",
"replayTutorial": "Tutorial erneut abspielen"
},
"shortcuts": {
"title": "Tastatur- & Mauskürzel",
"groups": {
"general": "Allgemein",
"actions": "Aktionen",
"selection": "Auswahl & Massenmodus",
"navigation": "Navigation",
"modelModal": "Modell- / Rezept-Dialog",
"mediaViewer": "Medienanzeige / Beispielgalerie"
},
"keys": {
"click": "Klick",
"drag": "Ziehen",
"rightClick": "Rechtsklick",
"letter": "Buchstabe",
"swipe": "Wischen"
},
"entries": {
"focusSearch": "Suche fokussieren",
"closeModal": "Dialog / Panel schließen",
"openShortcuts": "Dieses Tastenkürzel-Panel öffnen",
"refresh": "Modellliste aktualisieren",
"fetchMetadata": "Metadaten von CivitAI abrufen (nur Modellseiten)",
"downloadModel": "Ein Modell herunterladen (nur Modellseiten)",
"toggleBulkMode": "Massenmodus umschalten",
"selectAll": "Alle sichtbaren Modelle auswählen",
"rangeSelect": "Bereich auswählen",
"marqueeSelect": "Karten mit Auswahlrahmen auswählen (auf leerem Rasterbereich)",
"exitBulkMode": "Massenmodus verlassen",
"bulkActions": "Auf ausgewählter Karte: Menü für Massenaktionen",
"globalActions": "Auf leerem Seitenbereich: Menü für globale Aktionen (Updates prüfen, ausgeschlossene Modelle verwalten)",
"scrollPages": "Seiten scrollen",
"jumpAlphabet": "Zur Alphabetleiste springen",
"prevNext": "Vorheriges / nächstes Modell",
"deleteEntry": "Löschen",
"cycleMedia": "Medien durchblättern ([ / ] in der Beispielgalerie)",
"swipeTouch": "Medien auf Touch-Geräten durchblättern",
"closeViewer": "Medienanzeige schließen"
}
},
"updateVlogs": {
"title": "Neueste Updates",
@@ -1934,7 +2014,8 @@
"settings": "Einstellungen & Konfiguration",
"extensions": "Erweiterungen",
"newBadge": "NEU"
}
},
"newContentBadge": "NEU"
},
"update": {
"title": "Nach Updates suchen",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"repairBulkComplete": "Reparatur abgeschlossen: {repaired} repariert, {skipped} übersprungen (von {total})",
"repairBulkSkipped": "Keine Reparatur für die {total} ausgewählten Rezepte erforderlich",
"repairBulkFailed": "Reparatur der ausgewählten Rezepte fehlgeschlagen: {message}",
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...",
"reimportingViaExtension": "Rezept {current}/{total} wird über die Browser-Erweiterung neu importiert...",
"reimportSuccess": "Rezept erfolgreich neu importiert",
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "Refreshing {type}s...",
"fullRebuilding": "Full rebuild {type}s...",
"actionRefresh": "Refresh",
"actionFullRebuild": "Full rebuild",
"actionRefreshLower": "refresh",
"actionRebuildLower": "rebuild",
"stages": {
"scan_folders": "Scanning folders...",
"count_models": "Found {total} files",
"process_models": "Processing models",
"reconcile_scan": "Checking for changes...",
"process_new": "Processing new models",
"finalizing": "Finalizing..."
},
"eta": {
"lessThanMinute": "Less than a minute remaining",
"minutes": "~{minutes} min remaining",
"hours": "~{hours} hr {minutes} min remaining"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "Bulk Operations",
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span>. Select multiple models and perform batch operations. Use <span class=\"onboarding-shortcut\">Ctrl+A</span> to select all visible models."
"content": "Enter bulk mode by clicking this button or pressing <span class=\"onboarding-shortcut\">B</span> to select multiple models and perform batch operations.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> select all visible models, <span class=\"onboarding-shortcut\">Shift+Click</span> select a range.<br>• <span class=\"onboarding-shortcut\">Esc</span> or clicking an empty area exits bulk mode."
},
"searchOptions": {
"title": "Search Options",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "Context Menu",
"content": "<strong>Right-click</strong> any model card for a context menu with additional actions."
"content": "<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata."
},
"marqueeSelect": {
"title": "Drag to Select",
"content": "Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once."
},
"dragToSidebar": {
"title": "Organize by Dragging",
"content": "Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode."
},
"contextMenus": {
"title": "More Context Menus",
"content": "In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models."
}
}
},
@@ -179,13 +212,6 @@
"none": "All {typePlural} already have license metadata",
"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": {
"label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"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.",
"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",
"displayDensityOptions": {
"default": "Default",
@@ -786,7 +814,6 @@
"setContentRating": "Set Content Rating for Selected",
"copyAll": "Copy Selected Syntax",
"refreshAll": "Refresh Selected Metadata",
"repairMetadata": "Repair Metadata for Selected",
"rematchMetadata": "Rematch Selected to Local Models",
"reimportMetadata": "Re-import from Source",
"checkUpdates": "Check Updates for Selected",
@@ -842,7 +869,6 @@
"replacePreview": "Replace Preview",
"setContentRating": "Set Content Rating",
"moveToFolder": "Move to Folder",
"repairMetadata": "Repair metadata",
"rematchMetadata": "Rematch to local models",
"reimportMetadata": "Re-import from Source",
"excludeModel": "Exclude Model",
@@ -868,6 +894,21 @@
"previousWithShortcut": "Previous recipe (←)",
"nextWithShortcut": "Next recipe (→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "Open File Location",
"copyId": "Copy recipe ID"
},
"openFileLocation": {
"success": "File location opened successfully",
"failed": "Failed to open file location",
"copied": "Path copied to clipboard: {{path}}",
"clipboardFallback": "Path: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Send Workflow to ComfyUI",
"sent": "Workflow sent to ComfyUI",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "Failed to get information for missing LoRAs",
"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": {
"starting": "Re-importing recipe from source...",
"success": "Recipe re-imported successfully",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Value",
"add": "Add",
"invalidRange": "Invalid range format. Use x.x-y.y"
"invalidRange": "Invalid range format. Use x.x-y.y",
"invalidValue": "Please enter a valid number",
"saveFailed": "Failed to save preset parameter",
"added": "Preset parameter added",
"updated": "Preset parameter updated"
},
"triggerWords": {
"label": "Trigger Words",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "Type to add or click suggestions below",
"editWord": "Edit trigger word",
"editPlaceholder": "Edit trigger word",
"copyWord": "Copy trigger word",
"copyOrEditWord": "Click to copy, double-click to edit",
"deleteWord": "Delete trigger word",
"suggestions": {
"noSuggestions": "No suggestions available",
@@ -1649,8 +1687,8 @@
"showCount": "Show examples ({count})",
"hideExamples": "Hide examples",
"addExamples": "Add examples",
"previousExample": "Previous example",
"nextExample": "Next example",
"previousExample": "Previous example ([)",
"nextExample": "Next example (])",
"noExamples": "No example images available",
"addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "Getting Started",
"updateVlogs": "Update Vlogs",
"documentation": "Documentation"
"documentation": "Documentation",
"shortcuts": "Shortcuts"
},
"gettingStarted": {
"title": "Getting Started with LoRA Manager"
"title": "Getting Started with LoRA Manager",
"replayTutorial": "Replay Tutorial"
},
"shortcuts": {
"title": "Keyboard & Mouse Shortcuts",
"groups": {
"general": "General",
"actions": "Actions",
"selection": "Selection & Bulk Mode",
"navigation": "Navigation",
"modelModal": "Model / Recipe Modal",
"mediaViewer": "Media Viewer / Showcase"
},
"keys": {
"click": "Click",
"drag": "Drag",
"rightClick": "Right-click",
"letter": "Letter",
"swipe": "Swipe"
},
"entries": {
"focusSearch": "Focus search",
"closeModal": "Close modal / panel",
"openShortcuts": "Open this shortcuts panel",
"refresh": "Refresh model list",
"fetchMetadata": "Fetch metadata from CivitAI (model pages only)",
"downloadModel": "Download a model (model pages only)",
"toggleBulkMode": "Toggle bulk mode",
"selectAll": "Select all visible models",
"rangeSelect": "Range select",
"marqueeSelect": "Marquee-select cards (on empty grid area)",
"exitBulkMode": "Exit bulk mode",
"bulkActions": "On selected card: bulk actions menu",
"globalActions": "On empty page area: global actions menu (update check, manage excluded models)",
"scrollPages": "Scroll pages",
"jumpAlphabet": "Jump alphabet bar",
"prevNext": "Previous / next model",
"deleteEntry": "Delete",
"cycleMedia": "Cycle media ([ / ] in showcase gallery)",
"swipeTouch": "Cycle media on touch devices",
"closeViewer": "Close viewer"
}
},
"updateVlogs": {
"title": "Latest Updates",
@@ -1934,7 +2014,8 @@
"settings": "Settings & Configuration",
"extensions": "Extensions",
"newBadge": "NEW"
}
},
"newContentBadge": "New"
},
"update": {
"title": "Check for Updates",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"repairBulkComplete": "Repair complete: {repaired} repaired, {skipped} skipped (of {total})",
"repairBulkSkipped": "No repair needed for any of the {total} selected recipes",
"repairBulkFailed": "Failed to repair selected recipes: {message}",
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...",
"reimportingViaExtension": "Re-importing recipe {current}/{total} via browser extension...",
"reimportSuccess": "Recipe re-imported successfully",
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "Actualizando {type}...",
"fullRebuilding": "Reconstrucción completa de {type}...",
"actionRefresh": "Actualización",
"actionFullRebuild": "Reconstrucción completa",
"actionRefreshLower": "actualizar",
"actionRebuildLower": "reconstruir",
"stages": {
"scan_folders": "Escaneando carpetas...",
"count_models": "Se encontraron {total} archivos",
"process_models": "Procesando modelos",
"reconcile_scan": "Comprobando cambios...",
"process_new": "Procesando modelos nuevos",
"finalizing": "Finalizando..."
},
"eta": {
"lessThanMinute": "Queda menos de un minuto",
"minutes": "Quedan ~{minutes} min",
"hours": "Quedan ~{hours} h {minutes} min"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "Operaciones por lotes",
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span>. Selecciona varios modelos y realiza operaciones por lotes. Usa <span class=\"onboarding-shortcut\">Ctrl+A</span> para seleccionar todos los modelos visibles."
"content": "Entra en el modo por lotes haciendo clic en este botón o presionando <span class=\"onboarding-shortcut\">B</span> para seleccionar varios modelos y realizar operaciones por lotes.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> selecciona todos los modelos visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> selecciona un rango.<br>• <span class=\"onboarding-shortcut\">Esc</span> o hacer clic en un área vacía sale del modo por lotes."
},
"searchOptions": {
"title": "Opciones de búsqueda",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "Menú contextual",
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones adicionales."
"content": "<strong>Clic derecho</strong> en cualquier tarjeta de modelo para ver un menú contextual con acciones de la tarjeta como mover, eliminar o editar metadatos."
},
"marqueeSelect": {
"title": "Arrastrar para seleccionar",
"content": "Mantén pulsado el <strong>botón izquierdo del ratón</strong> en un área vacía de la cuadrícula y arrastra para dibujar un rectángulo de selección que selecciona varias tarjetas a la vez."
},
"dragToSidebar": {
"title": "Organizar arrastrando",
"content": "Arrastra una tarjeta de modelo hasta una carpeta de la barra lateral para mover el archivo allí. Esto también funciona con varias tarjetas seleccionadas en el modo por lotes."
},
"contextMenus": {
"title": "Más menús contextuales",
"content": "En el modo por lotes, <strong>haz clic derecho en una tarjeta seleccionada</strong> para ver las acciones por lotes. <strong>Haz clic derecho en un área vacía</strong> de la página para ver acciones globales como comprobar actualizaciones y gestionar modelos excluidos."
}
}
},
@@ -179,13 +212,6 @@
"none": "Todos los {typePlural} ya tienen metadatos de licencia",
"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": {
"label": "Reasociar recetas con modelos locales",
"loading": "Reasociando recetas con modelos locales...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"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.",
"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",
"displayDensityOptions": {
"default": "Predeterminado",
@@ -786,7 +814,6 @@
"setContentRating": "Establecer clasificación de contenido para todos",
"copyAll": "Copiar toda la sintaxis",
"refreshAll": "Actualizar todos los metadatos",
"repairMetadata": "Reparar metadatos de la selección",
"rematchMetadata": "Reasociar los seleccionados con modelos locales",
"reimportMetadata": "Reimportar desde origen",
"checkUpdates": "Comprobar actualizaciones para la selección",
@@ -842,7 +869,6 @@
"replacePreview": "Reemplazar vista previa",
"setContentRating": "Establecer clasificación de contenido",
"moveToFolder": "Mover a carpeta",
"repairMetadata": "Reparar metadatos",
"rematchMetadata": "Reasociar con modelos locales",
"reimportMetadata": "Reimportar desde origen",
"excludeModel": "Excluir modelo",
@@ -868,6 +894,21 @@
"previousWithShortcut": "Receta anterior (←)",
"nextWithShortcut": "Siguiente receta (→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "Abrir ubicación del archivo",
"copyId": "Copiar ID de la receta"
},
"openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente",
"failed": "Error al abrir la ubicación del archivo",
"copied": "Ruta copiada al portapapeles: {{path}}",
"clipboardFallback": "Ruta: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Enviar workflow a ComfyUI",
"sent": "Workflow enviado a ComfyUI",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "Error al obtener información de LoRAs faltantes",
"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": {
"starting": "Reimportando receta desde origen...",
"success": "Receta reimportada exitosamente",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Valor",
"add": "Añadir",
"invalidRange": "Formato de rango inválido. Use x.x-y.y"
"invalidRange": "Formato de rango inválido. Use x.x-y.y",
"invalidValue": "Introduce un número válido",
"saveFailed": "Error al guardar el parámetro preajustado",
"added": "Parámetro preajustado añadido",
"updated": "Parámetro preajustado actualizado"
},
"triggerWords": {
"label": "Palabras clave",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "Escribe para añadir o haz clic en sugerencias de abajo",
"editWord": "Editar palabra de activación",
"editPlaceholder": "Editar palabra de activación",
"copyWord": "Copiar palabra de activación",
"copyOrEditWord": "Haz clic para copiar, doble clic para editar",
"deleteWord": "Eliminar palabra de activación",
"suggestions": {
"noSuggestions": "No hay sugerencias disponibles",
@@ -1649,8 +1687,8 @@
"showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior",
"nextExample": "Ejemplo siguiente",
"previousExample": "Ejemplo anterior ([)",
"nextExample": "Ejemplo siguiente (])",
"noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "Comenzando",
"updateVlogs": "Vlogs de actualización",
"documentation": "Documentación"
"documentation": "Documentación",
"shortcuts": "Atajos"
},
"gettingStarted": {
"title": "Comenzando con el gestor de LoRA"
"title": "Comenzando con el gestor de LoRA",
"replayTutorial": "Repetir tutorial"
},
"shortcuts": {
"title": "Atajos de teclado y ratón",
"groups": {
"general": "General",
"actions": "Acciones",
"selection": "Selección y modo por lotes",
"navigation": "Navegación",
"modelModal": "Modal de modelo / receta",
"mediaViewer": "Visor de medios / Ejemplos"
},
"keys": {
"click": "Clic",
"drag": "Arrastrar",
"rightClick": "Clic derecho",
"letter": "Letra",
"swipe": "Deslizar"
},
"entries": {
"focusSearch": "Enfocar la búsqueda",
"closeModal": "Cerrar modal / panel",
"openShortcuts": "Abrir este panel de atajos",
"refresh": "Actualizar la lista de modelos",
"fetchMetadata": "Obtener metadatos de CivitAI (solo páginas de modelos)",
"downloadModel": "Descargar un modelo (solo páginas de modelos)",
"toggleBulkMode": "Activar/desactivar el modo por lotes",
"selectAll": "Seleccionar todos los modelos visibles",
"rangeSelect": "Selección por rango",
"marqueeSelect": "Seleccionar tarjetas con un rectángulo de selección (en un área vacía de la cuadrícula)",
"exitBulkMode": "Salir del modo por lotes",
"bulkActions": "En una tarjeta seleccionada: menú de acciones por lotes",
"globalActions": "En un área vacía de la página: menú de acciones globales (comprobar actualizaciones, gestionar modelos excluidos)",
"scrollPages": "Desplazarse por las páginas",
"jumpAlphabet": "Saltar con la barra alfabética",
"prevNext": "Modelo anterior / siguiente",
"deleteEntry": "Eliminar",
"cycleMedia": "Cambiar de medio ([ / ] en la galería de ejemplos)",
"swipeTouch": "Cambiar de medio en dispositivos táctiles",
"closeViewer": "Cerrar el visor"
}
},
"updateVlogs": {
"title": "Últimas actualizaciones",
@@ -1934,7 +2014,8 @@
"settings": "Configuración",
"extensions": "Extensiones",
"newBadge": "NUEVO"
}
},
"newContentBadge": "NUEVO"
},
"update": {
"title": "Comprobar actualizaciones",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"repairBulkComplete": "Reparación completa: {repaired} reparadas, {skipped} omitidas (de {total})",
"repairBulkSkipped": "No se necesita reparación para ninguna de las {total} recetas seleccionadas",
"repairBulkFailed": "Error al reparar las recetas seleccionadas: {message}",
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...",
"reimportingViaExtension": "Reimportando receta {current}/{total} mediante la extensión del navegador...",
"reimportSuccess": "Receta reimportada exitosamente",
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "Mo",
"gb": "Go",
"tb": "To"
},
"scanProgress": {
"refreshing": "Actualisation des {type}...",
"fullRebuilding": "Reconstruction complète des {type}...",
"actionRefresh": "Actualisation",
"actionFullRebuild": "Reconstruction complète",
"actionRefreshLower": "lactualisation",
"actionRebuildLower": "la reconstruction",
"stages": {
"scan_folders": "Scan des dossiers...",
"count_models": "{total} fichiers trouvés",
"process_models": "Traitement des modèles",
"reconcile_scan": "Vérification des modifications...",
"process_new": "Traitement des nouveaux modèles",
"finalizing": "Finalisation..."
},
"eta": {
"lessThanMinute": "Moins dune minute restante",
"minutes": "~{minutes} min restantes",
"hours": "~{hours} h {minutes} min restantes"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "Opérations groupées",
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span>. Sélectionnez plusieurs modèles et effectuez des opérations groupées. Utilisez <span class=\"onboarding-shortcut\">Ctrl+A</span> pour sélectionner tous les modèles visibles."
"content": "Activez le mode groupé en cliquant sur ce bouton ou en appuyant sur <span class=\"onboarding-shortcut\">B</span> pour sélectionner plusieurs modèles et effectuer des opérations groupées.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> sélectionne tous les modèles visibles, <span class=\"onboarding-shortcut\">Shift+Click</span> sélectionne une plage.<br>• <span class=\"onboarding-shortcut\">Esc</span> ou un clic sur une zone vide quitte le mode groupé."
},
"searchOptions": {
"title": "Options de recherche",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "Menu contextuel",
"content": "<strong>Clic droit</strong> sur une carte de modèle pour accéder à un menu contextuel avec des actions supplémentaires."
"content": "<strong>Clic droit</strong> sur n'importe quelle carte de modèle pour ouvrir un menu contextuel avec des actions sur la carte comme déplacer, supprimer ou modifier les métadonnées."
},
"marqueeSelect": {
"title": "Glisser pour sélectionner",
"content": "Maintenez le <strong>bouton gauche de la souris</strong> enfoncé sur une zone vide de la grille et glissez pour tracer un rectangle de sélection qui sélectionne plusieurs cartes à la fois."
},
"dragToSidebar": {
"title": "Organiser par glisser-déposer",
"content": "Glissez une carte de modèle sur un dossier de la barre latérale pour y déplacer le fichier. Cela fonctionne aussi avec plusieurs cartes sélectionnées en mode groupé."
},
"contextMenus": {
"title": "Plus de menus contextuels",
"content": "En mode groupé, <strong>faites un clic droit sur une carte sélectionnée</strong> pour accéder aux actions groupées. <strong>Faites un clic droit sur une zone vide</strong> de la page pour accéder aux actions globales comme la vérification des mises à jour et la gestion des modèles exclus."
}
}
},
@@ -179,13 +212,6 @@
"none": "Tous les {typePlural} possèdent déjà des métadonnées de licence",
"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": {
"label": "Réassocier les Recipes aux modèles locaux",
"loading": "Réassociation des Recipes aux modèles locaux...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"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.",
"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",
"displayDensityOptions": {
"default": "Par défaut",
@@ -786,7 +814,6 @@
"setContentRating": "Définir la classification du contenu pour tous",
"copyAll": "Copier toute la syntaxe",
"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",
"reimportMetadata": "Ré-importer depuis la source",
"checkUpdates": "Vérifier les mises à jour pour la sélection",
@@ -842,7 +869,6 @@
"replacePreview": "Remplacer l'aperçu",
"setContentRating": "Définir la classification du contenu",
"moveToFolder": "Déplacer vers un dossier",
"repairMetadata": "Réparer les métadonnées",
"rematchMetadata": "Réassocier aux modèles locaux",
"reimportMetadata": "Ré-importer depuis la source",
"excludeModel": "Exclure le modèle",
@@ -868,6 +894,21 @@
"previousWithShortcut": "Recette précédente (←)",
"nextWithShortcut": "Recette suivante (→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "Ouvrir lemplacement du fichier",
"copyId": "Copier lID de la Recipe"
},
"openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès",
"failed": "Échec de louverture de lemplacement du fichier",
"copied": "Chemin copié dans le presse-papiers: {{path}}",
"clipboardFallback": "Chemin: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
"sent": "Workflow envoyé vers ComfyUI",
@@ -1080,13 +1121,6 @@
"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}"
},
"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": {
"starting": "Ré-import de la Recipe depuis la source...",
"success": "Recette ré-importée avec succès",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Valeur",
"add": "Ajouter",
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y"
"invalidRange": "Format de plage invalide. Utilisez x.x-y.y",
"invalidValue": "Veuillez saisir un nombre valide",
"saveFailed": "Échec de l'enregistrement du paramètre préréglé",
"added": "Paramètre préréglé ajouté",
"updated": "Paramètre préréglé mis à jour"
},
"triggerWords": {
"label": "Mots-clés",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "Tapez pour ajouter ou cliquez sur les suggestions ci-dessous",
"editWord": "Modifier le mot-clé",
"editPlaceholder": "Modifier le mot-clé",
"copyWord": "Copier le mot-clé",
"copyOrEditWord": "Cliquez pour copier, double-cliquez pour modifier",
"deleteWord": "Supprimer le mot-clé",
"suggestions": {
"noSuggestions": "Aucune suggestion disponible",
@@ -1649,8 +1687,8 @@
"showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent",
"nextExample": "Exemple suivant",
"previousExample": "Exemple précédent ([)",
"nextExample": "Exemple suivant (])",
"noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "Commencer",
"updateVlogs": "Vlogs de mise à jour",
"documentation": "Documentation"
"documentation": "Documentation",
"shortcuts": "Raccourcis"
},
"gettingStarted": {
"title": "Premiers pas avec le Gestionnaire LoRA"
"title": "Premiers pas avec le Gestionnaire LoRA",
"replayTutorial": "Rejouer le tutoriel"
},
"shortcuts": {
"title": "Raccourcis clavier et souris",
"groups": {
"general": "Général",
"actions": "Actions",
"selection": "Sélection et mode groupé",
"navigation": "Navigation",
"modelModal": "Modale Modèle / Recipe",
"mediaViewer": "Visionneuse de médias / Galerie d'exemples"
},
"keys": {
"click": "Clic",
"drag": "Glisser",
"rightClick": "Clic droit",
"letter": "Lettre",
"swipe": "Balayage"
},
"entries": {
"focusSearch": "Donner le focus au champ de recherche",
"closeModal": "Fermer la fenêtre modale / le panneau",
"openShortcuts": "Ouvrir ce panneau de raccourcis",
"refresh": "Actualiser la liste des modèles",
"fetchMetadata": "Récupérer les métadonnées depuis CivitAI (pages de modèles uniquement)",
"downloadModel": "Télécharger un modèle (pages de modèles uniquement)",
"toggleBulkMode": "Activer/désactiver le mode groupé",
"selectAll": "Sélectionner tous les modèles visibles",
"rangeSelect": "Sélection d'une plage",
"marqueeSelect": "Sélection par glisser-déposer des cartes (sur une zone vide de la grille)",
"exitBulkMode": "Quitter le mode groupé",
"bulkActions": "Sur une carte sélectionnée : menu des actions groupées",
"globalActions": "Sur une zone vide de la page : menu des actions globales (vérification des mises à jour, gestion des modèles exclus)",
"scrollPages": "Faire défiler les pages",
"jumpAlphabet": "Sauter via la barre alphabétique",
"prevNext": "Modèle précédent / suivant",
"deleteEntry": "Supprimer",
"cycleMedia": "Parcourir les médias ([ / ] dans la galerie d'exemples)",
"swipeTouch": "Parcourir les médias sur les appareils tactiles",
"closeViewer": "Fermer la visionneuse"
}
},
"updateVlogs": {
"title": "Dernières mises à jour",
@@ -1934,7 +2014,8 @@
"settings": "Paramètres & Configuration",
"extensions": "Extensions",
"newBadge": "NOUVEAU"
}
},
"newContentBadge": "NOUVEAU"
},
"update": {
"title": "Vérifier les mises à jour",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
"noRecipesSelected": "Aucune Recipe sélectionnée",
"repairBulkComplete": "Réparation terminée : {repaired} réparée(s), {skipped} ignorée(s) (sur {total})",
"repairBulkSkipped": "Aucune réparation nécessaire parmi les {total} Recipes sélectionnées",
"repairBulkFailed": "Échec de la réparation des Recipes sélectionnées : {message}",
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
@@ -2151,6 +2229,7 @@
"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}",
"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",
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines Recipes",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "מרענן {type}...",
"fullRebuilding": "בונה מחדש את כל ה-{type}...",
"actionRefresh": "רענון",
"actionFullRebuild": "רענון מלא",
"actionRefreshLower": "רענון",
"actionRebuildLower": "רענון מלא",
"stages": {
"scan_folders": "סורק תיקיות...",
"count_models": "נמצאו {total} קבצים",
"process_models": "מעבד מודלים",
"reconcile_scan": "בודק שינויים...",
"process_new": "מעבד מודלים חדשים",
"finalizing": "מסיים..."
},
"eta": {
"lessThanMinute": "נותרה פחות מדקה",
"minutes": "נותרו ~{minutes} דקות",
"hours": "נותרו ~{hours} שעות ו-{minutes} דקות"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "פעולות בכמות גדולה",
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span>. בחר מספר מודלים ובצע פעולות בכמות גדולה. השתמש ב-<span class=\"onboarding-shortcut\">Ctrl+A</span> כדי לבחור את כל המודלים הגלויים."
"content": "היכנס למצב פעולות בכמות גדולה על ידי לחיצה על כפתור זה או על <span class=\"onboarding-shortcut\">B</span> כדי לבחור מספר מודלים ולבצע פעולות בכמות גדולה.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> בחר את כל המודלים הגלויים, <span class=\"onboarding-shortcut\">Shift+Click</span> בחר טווח.<br>• <span class=\"onboarding-shortcut\">Esc</span> או לחיצה על אזור ריק מוציאים ממצב בכמות גדולה."
},
"searchOptions": {
"title": "אפשרויות חיפוש",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "תפריט הקשר",
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות נוספות."
"content": "<strong>לחיצה ימנית</strong> על כל כרטיס מודל לתפריט הקשר עם פעולות כרטיס כמו העברה, מחיקה או עריכת מטא-נתונים."
},
"marqueeSelect": {
"title": "גרור כדי לבחור",
"content": "החזק את <strong>לחצן העכבר השמאלי</strong> לחוץ על אזור ריק של הרשת וגרור כדי לצייר מסגרת בחירה שבוחרת מספר כרטיסים בבת אחת."
},
"dragToSidebar": {
"title": "ארגון באמצעות גרירה",
"content": "גרור כרטיס מודל אל תיקייה בסרגל הצד כדי להעביר את הקובץ לשם. פעולה זו עובדת גם עם מספר כרטיסים נבחרים במצב בכמות גדולה."
},
"contextMenus": {
"title": "תפריטי הקשר נוספים",
"content": "במצב בכמות גדולה, <strong>לחץ לחיצה ימנית על כרטיס נבחר</strong> לפעולות בכמות גדולה. <strong>לחץ לחיצה ימנית על אזור ריק</strong> בדף לפעולות גלובליות כמו בדיקת עדכונים וניהול מודלים מוחרגים."
}
}
},
@@ -179,13 +212,6 @@
"none": "לכל ה-{typePlural} כבר יש מטא-נתוני רישיון",
"error": "לא ניתן היה לרענן את מטא-נתוני הרישיון עבור {typePlural}: {message}"
},
"repairRecipes": {
"label": "תיקון נתוני מתכונים",
"loading": "מתקן נתוני מתכונים...",
"success": "תוקנו בהצלחה {count} מתכונים.",
"cancelled": "תיקון בוטל. {count} מתכונים תוקנו.",
"error": "תיקון המתכונים נכשל: {message}"
},
"rematchRecipes": {
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "קיבוץ לפי מודל",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל מודל CivitAI מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
"stickyControls": "השארת סרגל הפעולות גלוי",
"stickyControlsHelp": "כאשר מופעל, סרגל הפעולות (רענון, הורדה וכו') נשאר מוצמד לחלק העליון בעת גלילה, יחד עם ניווט פירורי הלחם.",
"displayDensity": "צפיפות תצוגה",
"displayDensityOptions": {
"default": "ברירת מחדל",
@@ -786,7 +814,6 @@
"setContentRating": "הגדר דירוג תוכן לכל המודלים",
"copyAll": "העתק את כל התחבירים",
"refreshAll": "רענן את כל המטא-נתונים",
"repairMetadata": "תקן מטא-נתונים עבור הנבחרים",
"rematchMetadata": "התאמה מחדש של הנבחרים למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור",
"checkUpdates": "בדוק עדכונים לבחירה",
@@ -842,7 +869,6 @@
"replacePreview": "החלף תצוגה מקדימה",
"setContentRating": "הגדר דירוג תוכן",
"moveToFolder": "העבר לתיקייה",
"repairMetadata": "תיקון מטא-נתונים",
"rematchMetadata": "התאמה מחדש למודלים מקומיים",
"reimportMetadata": "ייבא מחדש ממקור",
"excludeModel": "החרג מודל",
@@ -868,6 +894,21 @@
"previousWithShortcut": "המתכון הקודם (←)",
"nextWithShortcut": "המתכון הבא (→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "פתח מיקום קובץ",
"copyId": "העתק מזהה מתכון"
},
"openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה",
"failed": "פתיחת מיקום הקובץ נכשלה",
"copied": "הנתיב הועתק ללוח העריכה: {{path}}",
"clipboardFallback": "נתיב: {{path}}"
}
},
"workflow": {
"sendWorkflow": "שלח workflow ל-ComfyUI",
"sent": "ה-workflow נשלח ל-ComfyUI",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"prepareError": "שגיאה בהכנת LoRAs להורדה: {message}"
},
"repair": {
"starting": "מתקן מטא-נתונים של מתכון...",
"success": "מטא-נתונים של מתכון תוקן בהצלחה",
"skipped": "המתכון כבר בגרסה העדכנית ביותר, אין צורך בתיקון",
"failed": "תיקון המתכון נכשל: {message}",
"missingId": "לא ניתן לתקן את המתכון: חסר מזהה מתכון"
},
"reimport": {
"starting": "מייבא מתכון מחדש מהמקור...",
"success": "המתכון יובא מחדש בהצלחה",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "ערך",
"add": "הוסף",
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y"
"invalidRange": "פורמט טווח לא תקין. השתמש ב-x.x-y.y",
"invalidValue": "נא להזין מספר תקין",
"saveFailed": "שמירת הפרמטר הקבוע מראש נכשלה",
"added": "הפרמטר הקבוע מראש נוסף",
"updated": "הפרמטר הקבוע מראש עודכן"
},
"triggerWords": {
"label": "מילות טריגר",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "הקלד להוספה או לחץ על הצעות למטה",
"editWord": "עריכת מילת טריגר",
"editPlaceholder": "עריכת מילת טריגר",
"copyWord": "העתק מילת טריגר",
"copyOrEditWord": "לחץ כדי להעתיק, לחץ פעמיים כדי לערוך",
"deleteWord": "מחק מילת טריגר",
"suggestions": {
"noSuggestions": "אין הצעות זמינות",
@@ -1649,8 +1687,8 @@
"showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת",
"nextExample": "דוגמה הבאה",
"previousExample": "דוגמה קודמת ([)",
"nextExample": "דוגמה הבאה (])",
"noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "תחילת עבודה",
"updateVlogs": "בלוגי וידאו של עדכונים",
"documentation": "תיעוד"
"documentation": "תיעוד",
"shortcuts": "קיצורי דרך"
},
"gettingStarted": {
"title": "תחילת עבודה עם מנהל LoRA"
"title": "תחילת עבודה עם מנהל LoRA",
"replayTutorial": "הפעל את המדריך מחדש"
},
"shortcuts": {
"title": "קיצורי מקלדת ועכבר",
"groups": {
"general": "כללי",
"actions": "פעולות",
"selection": "בחירה ומצב בכמות גדולה",
"navigation": "ניווט",
"modelModal": "חלון מודל / מתכון",
"mediaViewer": "מציג מדיה / גלריית דוגמאות"
},
"keys": {
"click": "לחיצה",
"drag": "גרירה",
"rightClick": "לחיצה ימנית",
"letter": "אות",
"swipe": "החלקה"
},
"entries": {
"focusSearch": "העבר מיקוד לחיפוש",
"closeModal": "סגור חלון / פאנל",
"openShortcuts": "פתח את פאנל קיצורי הדרך הזה",
"refresh": "רענן את רשימת המודלים",
"fetchMetadata": "אחזר מטא-נתונים מ-CivitAI (דפי מודלים בלבד)",
"downloadModel": "הורד מודל (דפי מודלים בלבד)",
"toggleBulkMode": "הפעל/כבה מצב בכמות גדולה",
"selectAll": "בחר את כל המודלים הגלויים",
"rangeSelect": "בחר טווח",
"marqueeSelect": "בחר כרטיסים במסגרת בחירה (באזור ריק של הרשת)",
"exitBulkMode": "צא ממצב בכמות גדולה",
"bulkActions": "על כרטיס נבחר: תפריט פעולות בכמות גדולה",
"globalActions": "באזור ריק בדף: תפריט פעולות גלובליות (בדיקת עדכונים, ניהול מודלים מוחרגים)",
"scrollPages": "גלול בין דפים",
"jumpAlphabet": "קפוץ בעזרת סרגל האותיות",
"prevNext": "מודל קודם / הבא",
"deleteEntry": "מחק",
"cycleMedia": "עבור בין פריטי מדיה ([ / ] בגלריית הדוגמאות)",
"swipeTouch": "עבור בין פריטי מדיה במכשירי מגע",
"closeViewer": "סגור את המציג"
}
},
"updateVlogs": {
"title": "עדכונים אחרונים",
@@ -1934,7 +2014,8 @@
"settings": "הגדרות ותצורה",
"extensions": "הרחבות",
"newBadge": "חדש"
}
},
"newContentBadge": "חדש"
},
"update": {
"title": "בדוק עדכונים",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"repairBulkComplete": "התיקון הושלם: {repaired} תוקנו, {skipped} דולגו (מתוך {total})",
"repairBulkSkipped": "אין צורך בתיקון עבור {total} המתכונים הנבחרים",
"repairBulkFailed": "תיקון המתכונים הנבחרים נכשל: {message}",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...",
"reimportingViaExtension": "מייבא מתכון מחדש {current}/{total} דרך תוסף הדפדפן...",
"reimportSuccess": "המתכון יובא מחדש בהצלחה",
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "{type}を更新中...",
"fullRebuilding": "{type}を完全に再構築中...",
"actionRefresh": "更新",
"actionFullRebuild": "完全な再構築",
"actionRefreshLower": "更新",
"actionRebuildLower": "再構築",
"stages": {
"scan_folders": "フォルダをスキャン中...",
"count_models": "{total} 件のファイルが見つかりました",
"process_models": "モデルを処理中",
"reconcile_scan": "変更を確認中...",
"process_new": "新しいモデルを処理中",
"finalizing": "最終処理中..."
},
"eta": {
"lessThanMinute": "残り1分未満",
"minutes": "残り約 {minutes} 分",
"hours": "残り約 {hours} 時間 {minutes} 分"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "一括操作",
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入ります。複数のモデルを選択して一括操作が可能です。<span class=\"onboarding-shortcut\">Ctrl+A</span>で表示中のモデルをすべて選択できます。"
"content": "このボタンをクリックするか、<span class=\"onboarding-shortcut\">B</span>キーを押して一括モードに入り複数のモデルを選択して一括操作を実行できます。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>で表示中のモデルをすべて選択、<span class=\"onboarding-shortcut\">Shift+Click</span>で範囲選択。<br>• <span class=\"onboarding-shortcut\">Esc</span>キーまたは空白部分をクリックすると一括モードを終了します。"
},
"searchOptions": {
"title": "検索オプション",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "コンテキストメニュー",
"content": "<strong>モデルカードを右クリック</strong>すると追加の操作ができるコンテキストメニューが表示されます。"
"content": "<strong>モデルカードを右クリック</strong>すると、移動、削除、メタデータの編集などのカード操作を含むコンテキストメニューが表示されます。"
},
"marqueeSelect": {
"title": "ドラッグで選択",
"content": "グリッドの空白部分で<strong>マウスの左ボタン</strong>を押したままドラッグすると、複数のカードを一度に選択する矩形(マーキー)を描画できます。"
},
"dragToSidebar": {
"title": "ドラッグで整理",
"content": "モデルカードをサイドバーのフォルダにドラッグすると、ファイルをそこに移動できます。一括モードで複数選択したカードでも同様に機能します。"
},
"contextMenus": {
"title": "その他のコンテキストメニュー",
"content": "一括モードでは、<strong>選択したカードを右クリック</strong>すると一括操作メニューが表示されます。<strong>ページの空白部分を右クリック</strong>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
}
}
},
@@ -179,13 +212,6 @@
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
},
"repairRecipes": {
"label": "レシピデータの修復",
"loading": "レシピデータを修復中...",
"success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}件のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}"
},
"rematchRecipes": {
"label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"stickyControls": "アクションバーを常に表示",
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
"displayDensity": "表示密度",
"displayDensityOptions": {
"default": "デフォルト",
@@ -786,7 +814,6 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認",
@@ -842,7 +869,6 @@
"replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外",
@@ -868,6 +894,21 @@
"previousWithShortcut": "前のレシピ(←)",
"nextWithShortcut": "次のレシピ(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "ファイルの場所を開く",
"copyId": "レシピIDをコピー"
},
"openFileLocation": {
"success": "ファイルの場所を正常に開きました",
"failed": "ファイルの場所を開くのに失敗しました",
"copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}"
}
},
"workflow": {
"sendWorkflow": "ワークフローをComfyUIへ送信",
"sent": "ワークフローをComfyUIへ送信しました",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
},
"repair": {
"starting": "レシピのメタデータを修復中...",
"success": "レシピのメタデータが正常に修復されました",
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
},
"reimport": {
"starting": "ソースからレシピを再インポート中...",
"success": "レシピの再インポートが完了しました",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "値",
"add": "追加",
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください",
"invalidValue": "有効な数値を入力してください",
"saveFailed": "プリセットパラメータの保存に失敗しました",
"added": "プリセットパラメータを追加しました",
"updated": "プリセットパラメータを更新しました"
},
"triggerWords": {
"label": "トリガーワード",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
"deleteWord": "トリガーワードを削除",
"suggestions": {
"noSuggestions": "提案はありません",
@@ -1649,8 +1687,8 @@
"showCount": "例を表示({count}",
"hideExamples": "例を非表示",
"addExamples": "例を追加",
"previousExample": "前の例",
"nextExample": "次の例",
"previousExample": "前の例[",
"nextExample": "次の例]",
"noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "はじめに",
"updateVlogs": "更新Vlog",
"documentation": "ドキュメント"
"documentation": "ドキュメント",
"shortcuts": "ショートカット"
},
"gettingStarted": {
"title": "LoRA Managerを始める"
"title": "LoRA Managerを始める",
"replayTutorial": "チュートリアルをもう一度再生"
},
"shortcuts": {
"title": "キーボード & マウスのショートカット",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選択 & 一括モード",
"navigation": "ナビゲーション",
"modelModal": "モデル / レシピモーダル",
"mediaViewer": "メディアビューア / ショーケース"
},
"keys": {
"click": "クリック",
"drag": "ドラッグ",
"rightClick": "右クリック",
"letter": "文字キー",
"swipe": "スワイプ"
},
"entries": {
"focusSearch": "検索にフォーカス",
"closeModal": "モーダル / パネルを閉じる",
"openShortcuts": "このショートカットパネルを開く",
"refresh": "モデルリストを更新",
"fetchMetadata": "CivitAIからメタデータを取得(モデルページのみ)",
"downloadModel": "モデルをダウンロード(モデルページのみ)",
"toggleBulkMode": "一括モードを切り替え",
"selectAll": "表示中のモデルをすべて選択",
"rangeSelect": "範囲選択",
"marqueeSelect": "カードを矩形選択(グリッドの空白部分で)",
"exitBulkMode": "一括モードを終了",
"bulkActions": "選択したカード上:一括操作メニュー",
"globalActions": "ページの空白部分:グローバル操作メニュー(更新の確認、除外モデルの管理)",
"scrollPages": "ページをスクロール",
"jumpAlphabet": "アルファベットバーへジャンプ",
"prevNext": "前 / 次のモデル",
"deleteEntry": "削除",
"cycleMedia": "メディアを切り替え(ショーケースギャラリーでは [ / ])",
"swipeTouch": "タッチデバイスでメディアを切り替え",
"closeViewer": "ビューアを閉じる"
}
},
"updateVlogs": {
"title": "最新の更新",
@@ -1934,7 +2014,8 @@
"settings": "設定&構成",
"extensions": "拡張機能",
"newBadge": "新着"
}
},
"newContentBadge": "新着"
},
"update": {
"title": "更新確認",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
"noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
"reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "{type} 새로고침 중...",
"fullRebuilding": "{type} 전체 재구성 중...",
"actionRefresh": "새로고침",
"actionFullRebuild": "전체 재구성",
"actionRefreshLower": "새로고침",
"actionRebuildLower": "재구성",
"stages": {
"scan_folders": "폴더 스캔 중...",
"count_models": "파일 {total}개 발견",
"process_models": "모델 처리 중",
"reconcile_scan": "변경 사항 확인 중...",
"process_new": "새 모델 처리 중",
"finalizing": "마무리 중..."
},
"eta": {
"lessThanMinute": "남은 시간 1분 미만",
"minutes": "약 {minutes}분 남음",
"hours": "약 {hours}시간 {minutes}분 남음"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "일괄 작업",
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하세요. 여러 모델을 선택하 일괄 작업을 수행할 수 있습니다. <span class=\"onboarding-shortcut\">Ctrl+A</span>로 모든 표시된 모델을 선택하세요."
"content": "이 버튼을 클릭하거나 <span class=\"onboarding-shortcut\">B</span> 키를 눌러 일괄 모드로 진입하 여러 모델을 선택하 일괄 작업을 수행하세요.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span>로 모든 표시된 모델을 선택하고, <span class=\"onboarding-shortcut\">Shift+Click</span>으로 범위를 선택할 수 있습니다.<br>• <span class=\"onboarding-shortcut\">Esc</span> 키를 누르거나 빈 영역을 클릭하면 일괄 모드가 종료됩니다."
},
"searchOptions": {
"title": "검색 옵션",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "컨텍스트 메뉴",
"content": "<strong>오른쪽 클릭</strong>으로 모델 카드의 추가 작업 메뉴를 사용할 수 있습니다."
"content": "모델 카드를 <strong>오른쪽 클릭</strong>하면 이동, 삭제, 메타데이터 편집 같은 카드 작업이 담긴 컨텍스트 메뉴를 사용할 수 있습니다."
},
"marqueeSelect": {
"title": "드래그로 선택",
"content": "그리드의 빈 영역에서 <strong>마우스 왼쪽 버튼</strong>을 누른 채 드래그하여 여러 카드를 한 번에 선택하는 선택 영역을 그리세요."
},
"dragToSidebar": {
"title": "드래그로 정리",
"content": "모델 카드를 사이드바의 폴더로 드래그하면 파일이 해당 폴더로 이동합니다. 일괄 모드에서 선택한 여러 카드에도 적용됩니다."
},
"contextMenus": {
"title": "더 많은 컨텍스트 메뉴",
"content": "일괄 모드에서는 <strong>선택한 카드를 오른쪽 클릭</strong>하여 일괄 작업을 사용할 수 있습니다. 페이지의 <strong>빈 영역을 오른쪽 클릭</strong>하면 업데이트 확인이나 제외된 모델 관리 같은 전역 작업을 사용할 수 있습니다."
}
}
},
@@ -179,13 +212,6 @@
"none": "모든 {typePlural}에 이미 라이선스 메타데이터가 있습니다",
"error": "{typePlural}의 라이선스 메타데이터를 새로고침하지 못했습니다: {message}"
},
"repairRecipes": {
"label": "레시피 데이터 복구",
"loading": "레시피 데이터 복구 중...",
"success": "{count}개의 레시피가 성공적으로 복구되었습니다.",
"cancelled": "수리가 취소되었습니다. {count}개의 레시피가 수리되었습니다.",
"error": "레시피 복구 실패: {message}"
},
"rematchRecipes": {
"label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 CivitAI 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
"stickyControls": "작업 표시줄 항상 표시",
"stickyControlsHelp": "활성화하면 작업 표시줄(새로고침, 다운로드 등)이 스크롤 시 브레드크럼 내비게이션과 함께 상단에 고정됩니다.",
"displayDensity": "표시 밀도",
"displayDensityOptions": {
"default": "기본",
@@ -786,7 +814,6 @@
"setContentRating": "모든 모델에 콘텐츠 등급 설정",
"copyAll": "모든 문법 복사",
"refreshAll": "모든 메타데이터 새로고침",
"repairMetadata": "선택한 레시피 메타데이터 복구",
"rematchMetadata": "선택 항목을 로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기",
"checkUpdates": "선택 항목 업데이트 확인",
@@ -842,7 +869,6 @@
"replacePreview": "미리보기 교체",
"setContentRating": "콘텐츠 등급 설정",
"moveToFolder": "폴더로 이동",
"repairMetadata": "메타데이터 복구",
"rematchMetadata": "로컬 모델에 다시 매칭",
"reimportMetadata": "소스에서 다시 가져오기",
"excludeModel": "모델 제외",
@@ -868,6 +894,21 @@
"previousWithShortcut": "이전 레시피(←)",
"nextWithShortcut": "다음 레시피(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "파일 위치 열기",
"copyId": "레시피 ID 복사"
},
"openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다",
"failed": "파일 위치 열기에 실패했습니다",
"copied": "경로가 클립보드에 복사되었습니다: {{path}}",
"clipboardFallback": "경로: {{path}}"
}
},
"workflow": {
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
"sent": "워크플로를 ComfyUI로 보냈습니다",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"prepareError": "LoRA 다운로드 준비 중 오류: {message}"
},
"repair": {
"starting": "레시피 메타데이터 복구 중...",
"success": "레시피 메타데이터가 성공적으로 복구되었습니다",
"skipped": "레시피가 이미 최신 버전입니다. 복구가 필요하지 않습니다",
"failed": "레시피 복구 실패: {message}",
"missingId": "레시피를 복구할 수 없음: 레시피 ID 누락"
},
"reimport": {
"starting": "소스에서 레시피를 다시 가져오는 중...",
"success": "레시피를 다시 가져왔습니다",
@@ -1578,7 +1612,11 @@
"clipSkip": "클립 스킵",
"valuePlaceholder": "값",
"add": "추가",
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요"
"invalidRange": "잘못된 범위 형식입니다. x.x-y.y를 사용하세요",
"invalidValue": "유효한 숫자를 입력하세요",
"saveFailed": "프리셋 매개변수 저장에 실패했습니다",
"added": "프리셋 매개변수가 추가되었습니다",
"updated": "프리셋 매개변수가 업데이트되었습니다"
},
"triggerWords": {
"label": "트리거 단어",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "입력하거나 아래 제안을 클릭하세요",
"editWord": "트리거 단어 편집",
"editPlaceholder": "트리거 단어 편집",
"copyWord": "트리거 단어 복사",
"copyOrEditWord": "클릭하여 복사, 더블 클릭하여 편집",
"deleteWord": "트리거 단어 삭제",
"suggestions": {
"noSuggestions": "사용 가능한 제안이 없습니다",
@@ -1649,8 +1687,8 @@
"showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기",
"addExamples": "예시 추가",
"previousExample": "이전 예시",
"nextExample": "다음 예시",
"previousExample": "이전 예시([)",
"nextExample": "다음 예시(])",
"noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "시작하기",
"updateVlogs": "업데이트 영상",
"documentation": "문서"
"documentation": "문서",
"shortcuts": "단축키"
},
"gettingStarted": {
"title": "LoRA Manager 시작하기"
"title": "LoRA Manager 시작하기",
"replayTutorial": "튜토리얼 다시 보기"
},
"shortcuts": {
"title": "키보드 & 마우스 단축키",
"groups": {
"general": "일반",
"actions": "작업",
"selection": "선택 & 일괄 모드",
"navigation": "내비게이션",
"modelModal": "모델 / 레시피 모달",
"mediaViewer": "미디어 뷰어 / 쇼케이스"
},
"keys": {
"click": "클릭",
"drag": "드래그",
"rightClick": "오른쪽 클릭",
"letter": "문자 키",
"swipe": "스와이프"
},
"entries": {
"focusSearch": "검색창으로 포커스 이동",
"closeModal": "모달 / 패널 닫기",
"openShortcuts": "이 단축키 패널 열기",
"refresh": "모델 목록 새로고침",
"fetchMetadata": "CivitAI에서 메타데이터 가져오기 (모델 페이지만)",
"downloadModel": "모델 다운로드 (모델 페이지만)",
"toggleBulkMode": "일괄 모드 전환",
"selectAll": "표시된 모든 모델 선택",
"rangeSelect": "범위 선택",
"marqueeSelect": "드래그로 카드 선택 (빈 그리드 영역에서)",
"exitBulkMode": "일괄 모드 종료",
"bulkActions": "선택한 카드에서: 일괄 작업 메뉴",
"globalActions": "페이지 빈 영역에서: 전역 작업 메뉴 (업데이트 확인, 제외된 모델 관리)",
"scrollPages": "페이지 스크롤",
"jumpAlphabet": "알파벳 바로 이동",
"prevNext": "이전 / 다음 모델",
"deleteEntry": "삭제",
"cycleMedia": "미디어 전환 (쇼케이스 갤러리에서 [ / ])",
"swipeTouch": "터치 기기에서 미디어 전환",
"closeViewer": "뷰어 닫기"
}
},
"updateVlogs": {
"title": "최신 업데이트",
@@ -1934,7 +2014,8 @@
"settings": "설정 & 구성",
"extensions": "확장",
"newBadge": "신규"
}
},
"newContentBadge": "신규"
},
"update": {
"title": "업데이트 확인",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
"batchImportDirectorySelected": "선택한 폴더: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"repairBulkComplete": "복구 완료: {repaired}개 복구, {skipped}개 건너뜀 (총 {total}개)",
"repairBulkSkipped": "선택한 {total}개 레시피는 복구가 필요하지 않습니다",
"repairBulkFailed": "선택한 레시피 복구 실패: {message}",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
"reimportingViaExtension": "브라우저 확장 프로그램을 통해 레시피를 다시 가져오는 중 ({current}/{total})...",
"reimportSuccess": "레시피를 다시 가져왔습니다",
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "МБ",
"gb": "ГБ",
"tb": "ТБ"
},
"scanProgress": {
"refreshing": "Обновление {type}...",
"fullRebuilding": "Полная пересборка {type}...",
"actionRefresh": "Обновление",
"actionFullRebuild": "Полная пересборка",
"actionRefreshLower": "обновить",
"actionRebuildLower": "пересобрать",
"stages": {
"scan_folders": "Сканирование папок...",
"count_models": "Найдено файлов: {total}",
"process_models": "Обработка моделей",
"reconcile_scan": "Проверка изменений...",
"process_new": "Обработка новых моделей",
"finalizing": "Завершение..."
},
"eta": {
"lessThanMinute": "Осталось меньше минуты",
"minutes": "Осталось ~{minutes} мин",
"hours": "Осталось ~{hours} ч {minutes} мин"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "Массовые операции",
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>. Выберите несколько моделей и выполните пакетные операции. Используйте <span class=\"onboarding-shortcut\">Ctrl+A</span> для выбора всех видимых моделей."
"content": "Войдите в массовый режим, нажав эту кнопку или клавишу <span class=\"onboarding-shortcut\">B</span>, чтобы выбрать несколько моделей и выполнить пакетные операции.<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> выбрать все видимые модели, <span class=\"onboarding-shortcut\">Shift+Click</span> — выбрать диапазон.<br>• <span class=\"onboarding-shortcut\">Esc</span> или клик по пустой области выходит из массового режима."
},
"searchOptions": {
"title": "Опции поиска",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "Контекстное меню",
"content": "<strong>Правый клик</strong> по карточке модели откроет контекстное меню с дополнительными действиями."
"content": "<strong>Правый клик</strong> по любой карточке модели открывает контекстное меню с действиями над карточкой, такими как перемещение, удаление или редактирование метаданных."
},
"marqueeSelect": {
"title": "Выделение рамкой",
"content": "Удерживайте <strong>левую кнопку мыши</strong> на пустой области сетки и перетащите, чтобы нарисовать рамку, выделяющую сразу несколько карточек."
},
"dragToSidebar": {
"title": "Организация перетаскиванием",
"content": "Перетащите карточку модели на папку в боковой панели, чтобы переместить туда файл. Это также работает с несколькими выделенными карточками в массовом режиме."
},
"contextMenus": {
"title": "Другие контекстные меню",
"content": "В массовом режиме <strong>правый клик по выделенной карточке</strong> открывает меню массовых операций. <strong>Правый клик по пустой области</strong> страницы открывает глобальные действия, такие как проверка обновлений и управление исключёнными моделями."
}
}
},
@@ -179,13 +212,6 @@
"none": "У всех {typePlural} уже есть метаданные лицензии",
"error": "Не удалось обновить метаданные лицензии для {typePlural}: {message}"
},
"repairRecipes": {
"label": "Восстановить данные рецептов",
"loading": "Восстановление данных рецептов...",
"success": "Успешно восстановлено {count} рецептов.",
"cancelled": "Восстановление отменено. {count} рецептов было восстановлено.",
"error": "Ошибка восстановления рецептов: {message}"
},
"rematchRecipes": {
"label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели CivitAI в виде одной карточки. Старые версии скрыты.",
"stickyControls": "Держать панель действий видимой",
"stickyControlsHelp": "При включении панель действий (Обновить, Загрузить и т. д.) остаётся закреплённой вверху при прокрутке вместе с навигацией по папкам.",
"displayDensity": "Плотность отображения",
"displayDensityOptions": {
"default": "По умолчанию",
@@ -786,7 +814,6 @@
"setContentRating": "Установить рейтинг контента для всех",
"copyAll": "Копировать весь синтаксис",
"refreshAll": "Обновить все метаданные",
"repairMetadata": "Восстановить метаданные для выбранных",
"rematchMetadata": "Сопоставить выбранные с локальными моделями",
"reimportMetadata": "Переимпортировать из источника",
"checkUpdates": "Проверить обновления для выбранных",
@@ -842,7 +869,6 @@
"replacePreview": "Заменить превью",
"setContentRating": "Установить рейтинг контента",
"moveToFolder": "Переместить в папку",
"repairMetadata": "Восстановить метаданные",
"rematchMetadata": "Сопоставить с локальными моделями",
"reimportMetadata": "Переимпортировать из источника",
"excludeModel": "Исключить модель",
@@ -868,6 +894,21 @@
"previousWithShortcut": "Предыдущий рецепт (←)",
"nextWithShortcut": "Следующий рецепт (→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "Открыть расположение файла",
"copyId": "Копировать ID рецепта"
},
"openFileLocation": {
"success": "Расположение файла успешно открыто",
"failed": "Не удалось открыть расположение файла",
"copied": "Путь скопирован в буфер обмена: {{path}}",
"clipboardFallback": "Путь: {{path}}"
}
},
"workflow": {
"sendWorkflow": "Отправить workflow в ComfyUI",
"sent": "Workflow отправлен в ComfyUI",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"prepareError": "Ошибка подготовки LoRAs для загрузки: {message}"
},
"repair": {
"starting": "Восстановление метаданных рецепта...",
"success": "Метаданные рецепта успешно восстановлены",
"skipped": "Рецепт уже последней версии, восстановление не требуется",
"failed": "Не удалось восстановить рецепт: {message}",
"missingId": "Не удалось восстановить рецепт: отсутствует ID рецепта"
},
"reimport": {
"starting": "Переимпорт рецепта из источника...",
"success": "Рецепт успешно переимпортирован",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "Значение",
"add": "Добавить",
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y"
"invalidRange": "Неверный формат диапазона. Используйте x.x-y.y",
"invalidValue": "Введите корректное число",
"saveFailed": "Не удалось сохранить предустановленный параметр",
"added": "Предустановленный параметр добавлен",
"updated": "Предустановленный параметр обновлён"
},
"triggerWords": {
"label": "Триггерные слова",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "Введите для добавления или нажмите на предложения ниже",
"editWord": "Редактировать триггерное слово",
"editPlaceholder": "Редактировать триггерное слово",
"copyWord": "Копировать триггерное слово",
"copyOrEditWord": "Клик — скопировать, двойной клик — редактировать",
"deleteWord": "Удалить триггерное слово",
"suggestions": {
"noSuggestions": "Предложения недоступны",
@@ -1649,8 +1687,8 @@
"showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример",
"nextExample": "Следующий пример",
"previousExample": "Предыдущий пример ([)",
"nextExample": "Следующий пример (])",
"noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "Начало работы",
"updateVlogs": "Видео обновлений",
"documentation": "Документация"
"documentation": "Документация",
"shortcuts": "Горячие клавиши"
},
"gettingStarted": {
"title": "Начало работы с LoRA Manager"
"title": "Начало работы с LoRA Manager",
"replayTutorial": "Повторить обучение"
},
"shortcuts": {
"title": "Горячие клавиши и действия мыши",
"groups": {
"general": "Общие",
"actions": "Действия",
"selection": "Выделение и массовый режим",
"navigation": "Навигация",
"modelModal": "Окно модели / рецепта",
"mediaViewer": "Просмотр медиа / Витрина"
},
"keys": {
"click": "Клик",
"drag": "Перетаскивание",
"rightClick": "Правый клик",
"letter": "Буква",
"swipe": "Свайп"
},
"entries": {
"focusSearch": "Переход к поиску",
"closeModal": "Закрыть модальное окно / панель",
"openShortcuts": "Открыть эту панель горячих клавиш",
"refresh": "Обновить список моделей",
"fetchMetadata": "Получить метаданные с CivitAI (только на страницах моделей)",
"downloadModel": "Загрузить модель (только на страницах моделей)",
"toggleBulkMode": "Переключить массовый режим",
"selectAll": "Выбрать все видимые модели",
"rangeSelect": "Выбор диапазона",
"marqueeSelect": "Выделение карточек рамкой (на пустой области сетки)",
"exitBulkMode": "Выйти из массового режима",
"bulkActions": "На выделенной карточке: меню массовых операций",
"globalActions": "На пустой области страницы: меню глобальных действий (проверка обновлений, управление исключёнными моделями)",
"scrollPages": "Прокрутка страниц",
"jumpAlphabet": "Переход по алфавитной панели",
"prevNext": "Предыдущая / следующая модель",
"deleteEntry": "Удалить",
"cycleMedia": "Переключение медиа ([ / ] в галерее витрины)",
"swipeTouch": "Переключение медиа на сенсорных устройствах",
"closeViewer": "Закрыть окно просмотра"
}
},
"updateVlogs": {
"title": "Последние обновления",
@@ -1934,7 +2014,8 @@
"settings": "Настройки и конфигурация",
"extensions": "Расширения",
"newBadge": "НОВОЕ"
}
},
"newContentBadge": "НОВОЕ"
},
"update": {
"title": "Проверить обновления",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
"batchImportDirectorySelected": "Выбрана папка: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"repairBulkComplete": "Восстановление завершено: {repaired} восстановлено, {skipped} пропущено (из {total})",
"repairBulkSkipped": "Ни один из {total} выбранных рецептов не требует восстановления",
"repairBulkFailed": "Не удалось восстановить выбранные рецепты: {message}",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...",
"reimportingViaExtension": "Переимпорт рецепта {current}/{total} через расширение браузера...",
"reimportSuccess": "Рецепт успешно переимпортирован",
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "正在刷新 {type}...",
"fullRebuilding": "正在完全重建 {type}...",
"actionRefresh": "刷新",
"actionFullRebuild": "完全重建",
"actionRefreshLower": "刷新",
"actionRebuildLower": "重建",
"stages": {
"scan_folders": "正在扫描文件夹...",
"count_models": "找到 {total} 个文件",
"process_models": "正在处理模型",
"reconcile_scan": "正在检查变更...",
"process_new": "正在处理新模型",
"finalizing": "正在收尾..."
},
"eta": {
"lessThanMinute": "剩余时间不到一分钟",
"minutes": "剩余约 {minutes} 分钟",
"hours": "剩余约 {hours} 小时 {minutes} 分钟"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "批量操作",
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 全选所有可见模型。"
"content": "点击此按钮或按 <span class=\"onboarding-shortcut\">B</span> 进入批量模式可多选模型并行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 全选所有可见模型<span class=\"onboarding-shortcut\">Shift+Click</span> 选择一个范围。<br>• 按 <span class=\"onboarding-shortcut\">Esc</span> 或点击空白区域退出批量模式。"
},
"searchOptions": {
"title": "搜索选项",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "右键菜单",
"content": "<strong>右键点击</strong>任意模型卡片可打开更多操作菜单。"
"content": "<strong>右键点击</strong>任意模型卡片可打开包含移动、删除或编辑元数据等卡片操作菜单。"
},
"marqueeSelect": {
"title": "拖动框选",
"content": "在网格的空白区域按住<strong>鼠标左键</strong>并拖动,绘制一个可同时选中多张卡片的框选区域。"
},
"dragToSidebar": {
"title": "拖放整理",
"content": "将模型卡片拖到侧边栏的文件夹上,即可把文件移动到该文件夹。批量模式下选中的多张卡片也可如此操作。"
},
"contextMenus": {
"title": "更多右键菜单",
"content": "在批量模式下,<strong>右键点击已选中的卡片</strong>可进行批量操作。<strong>右键点击页面空白区域</strong>可使用检查更新、管理已排除的模型等全局操作。"
}
}
},
@@ -179,13 +212,6 @@
"none": "所有 {typePlural} 都已具备许可证元数据",
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
},
"repairRecipes": {
"label": "修复配方数据",
"loading": "正在修复配方数据...",
"success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}"
},
"rematchRecipes": {
"label": "将配方重新匹配到本地模型",
"loading": "正在将配方重新匹配到本地模型...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"stickyControls": "保持操作栏可见",
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
"displayDensity": "显示密度",
"displayDensityOptions": {
"default": "默认",
@@ -786,7 +814,6 @@
"setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新",
@@ -842,7 +869,6 @@
"replacePreview": "替换预览",
"setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"excludeModel": "排除模型",
@@ -868,6 +894,21 @@
"previousWithShortcut": "上一个配方(←)",
"nextWithShortcut": "下一个配方(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "打开文件位置",
"copyId": "复制配方 ID"
},
"openFileLocation": {
"success": "文件位置已成功打开",
"failed": "打开文件位置失败",
"copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}"
}
},
"workflow": {
"sendWorkflow": "发送工作流到 ComfyUI",
"sent": "工作流已发送到 ComfyUI",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "获取缺失 LoRA 信息失败",
"prepareError": "准备下载 LoRA 时出错:{message}"
},
"repair": {
"starting": "正在修复配方元数据...",
"success": "配方元数据修复成功",
"skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
},
"reimport": {
"starting": "正在从源重新导入配方...",
"success": "配方已从源重新导入成功",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "数值",
"add": "添加",
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
"invalidRange": "无效的范围格式。请使用 x.x-y.y",
"invalidValue": "请输入有效的数值",
"saveFailed": "保存预设参数失败",
"added": "已添加预设参数",
"updated": "已更新预设参数"
},
"triggerWords": {
"label": "触发词",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"copyOrEditWord": "单击复制,双击编辑",
"deleteWord": "删除触发词",
"suggestions": {
"noSuggestions": "暂无建议",
@@ -1649,8 +1687,8 @@
"showCount": "显示示例({count}",
"hideExamples": "隐藏示例",
"addExamples": "添加示例",
"previousExample": "上一个示例",
"nextExample": "下一个示例",
"previousExample": "上一个示例[",
"nextExample": "下一个示例]",
"noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "新手入门",
"updateVlogs": "更新日志",
"documentation": "文档"
"documentation": "文档",
"shortcuts": "快捷键"
},
"gettingStarted": {
"title": "LoRA 管理器新手入门"
"title": "LoRA 管理器新手入门",
"replayTutorial": "重播教程"
},
"shortcuts": {
"title": "键盘与鼠标快捷键",
"groups": {
"general": "通用",
"actions": "操作",
"selection": "选择与批量模式",
"navigation": "导航",
"modelModal": "模型 / 配方弹窗",
"mediaViewer": "媒体查看器 / 示例展示"
},
"keys": {
"click": "单击",
"drag": "拖动",
"rightClick": "右键点击",
"letter": "字母",
"swipe": "滑动"
},
"entries": {
"focusSearch": "聚焦搜索框",
"closeModal": "关闭弹窗 / 面板",
"openShortcuts": "打开本快捷键面板",
"refresh": "刷新模型列表",
"fetchMetadata": "从 CivitAI 获取元数据(仅模型页面)",
"downloadModel": "下载模型(仅模型页面)",
"toggleBulkMode": "切换批量模式",
"selectAll": "全选所有可见模型",
"rangeSelect": "范围选择",
"marqueeSelect": "框选卡片(在网格空白区域)",
"exitBulkMode": "退出批量模式",
"bulkActions": "在已选中的卡片上:批量操作菜单",
"globalActions": "在页面空白区域:全局操作菜单(检查更新、管理已排除的模型)",
"scrollPages": "滚动页面",
"jumpAlphabet": "字母索引栏跳转",
"prevNext": "上一个 / 下一个模型",
"deleteEntry": "删除",
"cycleMedia": "切换媒体(在示例展示中按 [ / ])",
"swipeTouch": "在触屏设备上切换媒体",
"closeViewer": "关闭查看器"
}
},
"updateVlogs": {
"title": "最新更新",
@@ -1934,7 +2014,8 @@
"settings": "设置与配置",
"extensions": "扩展",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "检查更新",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
"reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
+107 -28
View File
@@ -50,6 +50,27 @@
"mb": "MB",
"gb": "GB",
"tb": "TB"
},
"scanProgress": {
"refreshing": "正在重新整理 {type}...",
"fullRebuilding": "正在完整重建 {type}...",
"actionRefresh": "重新整理",
"actionFullRebuild": "完整重建",
"actionRefreshLower": "重新整理",
"actionRebuildLower": "重建",
"stages": {
"scan_folders": "正在掃描資料夾...",
"count_models": "找到 {total} 個檔案",
"process_models": "正在處理模型",
"reconcile_scan": "正在檢查變更...",
"process_new": "正在處理新模型",
"finalizing": "正在收尾..."
},
"eta": {
"lessThanMinute": "剩餘時間不到一分鐘",
"minutes": "剩餘約 {minutes} 分鐘",
"hours": "剩餘約 {hours} 小時 {minutes} 分鐘"
}
}
},
"onboarding": {
@@ -75,7 +96,7 @@
},
"bulk": {
"title": "批次操作",
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式。可選取多個模型並執行批量操作。使用 <span class=\"onboarding-shortcut\">Ctrl+A</span> 選取所有可見模型。"
"content": "點擊此按鈕或按下 <span class=\"onboarding-shortcut\">B</span> 進入批模式選取多個模型並執行批量操作。<br>• <span class=\"onboarding-shortcut\">Ctrl/Cmd+A</span> 選取所有可見模型<span class=\"onboarding-shortcut\">Shift+Click</span> 選取一段範圍。<br>• <span class=\"onboarding-shortcut\">Esc</span> 或點擊空白處離開批量模式。"
},
"searchOptions": {
"title": "搜尋選項",
@@ -95,7 +116,19 @@
},
"contextMenu": {
"title": "右鍵選單",
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟更多操作選單。"
"content": "<strong>右鍵點擊</strong>任一模型卡片可開啟包含移動、刪除或編輯中繼資料等卡片操作的右鍵選單。"
},
"marqueeSelect": {
"title": "拖曳框選",
"content": "在網格空白處按住<strong>滑鼠左鍵</strong>並拖曳,畫出框選範圍,一次選取多張卡片。"
},
"dragToSidebar": {
"title": "拖曳整理",
"content": "將模型卡片拖曳到側邊欄的資料夾上,即可將檔案移動到該處。在批量模式下選取多張卡片也可一起拖曳。"
},
"contextMenus": {
"title": "更多右鍵選單",
"content": "在批量模式下,<strong>右鍵點擊已選取的卡片</strong>可開啟批量操作選單。<strong>右鍵點擊頁面空白處</strong>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
}
}
},
@@ -179,13 +212,6 @@
"none": "所有 {typePlural} 已具備授權中繼資料",
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
},
"repairRecipes": {
"label": "修復配方資料",
"loading": "正在修復配方資料...",
"success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}"
},
"rematchRecipes": {
"label": "將配方重新匹配到本地模型",
"loading": "正在將配方重新匹配到本地模型...",
@@ -451,6 +477,8 @@
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"stickyControls": "保持操作列可見",
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
"displayDensity": "顯示密度",
"displayDensityOptions": {
"default": "預設",
@@ -786,7 +814,6 @@
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新",
@@ -842,7 +869,6 @@
"replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型",
@@ -868,6 +894,21 @@
"previousWithShortcut": "上一個配方(←)",
"nextWithShortcut": "下一個配方(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "開啟檔案位置",
"copyId": "複製配方 ID"
},
"openFileLocation": {
"success": "檔案位置已成功開啟",
"failed": "開啟檔案位置失敗",
"copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}"
}
},
"workflow": {
"sendWorkflow": "傳送工作流到 ComfyUI",
"sent": "工作流已傳送到 ComfyUI",
@@ -1080,13 +1121,6 @@
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
},
"repair": {
"starting": "正在修復配方元數據...",
"success": "配方元數據修復成功",
"skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
},
"reimport": {
"starting": "正在從來源重新匯入配方...",
"success": "配方已從來源重新匯入成功",
@@ -1578,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "數值",
"add": "新增",
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
"invalidRange": "無效的範圍格式。請使用 x.x-y.y",
"invalidValue": "請輸入有效的數值",
"saveFailed": "儲存預設參數失敗",
"added": "已新增預設參數",
"updated": "已更新預設參數"
},
"triggerWords": {
"label": "觸發詞",
@@ -1589,7 +1627,7 @@
"addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"copyOrEditWord": "點擊複製,雙擊編輯",
"deleteWord": "刪除觸發詞",
"suggestions": {
"noSuggestions": "無可用建議",
@@ -1649,8 +1687,8 @@
"showCount": "顯示範例({count}",
"hideExamples": "隱藏範例",
"addExamples": "新增範例",
"previousExample": "上一個範例",
"nextExample": "下一個範例",
"previousExample": "上一個範例[",
"nextExample": "下一個範例]",
"noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處",
@@ -1914,10 +1952,52 @@
"tabs": {
"gettingStarted": "快速開始",
"updateVlogs": "更新影片",
"documentation": "文件"
"documentation": "文件",
"shortcuts": "快捷鍵"
},
"gettingStarted": {
"title": "LoRA 管理器快速開始"
"title": "LoRA 管理器快速開始",
"replayTutorial": "重新播放教學"
},
"shortcuts": {
"title": "鍵盤與滑鼠快捷鍵",
"groups": {
"general": "一般",
"actions": "操作",
"selection": "選取與批量模式",
"navigation": "導覽",
"modelModal": "模型 / 配方彈窗",
"mediaViewer": "媒體檢視器 / 範例展示"
},
"keys": {
"click": "點擊",
"drag": "拖曳",
"rightClick": "右鍵點擊",
"letter": "字母鍵",
"swipe": "滑動"
},
"entries": {
"focusSearch": "聚焦搜尋欄",
"closeModal": "關閉彈窗 / 面板",
"openShortcuts": "開啟此快捷鍵面板",
"refresh": "重新整理模型列表",
"fetchMetadata": "從 CivitAI 擷取中繼資料(僅限模型頁面)",
"downloadModel": "下載模型(僅限模型頁面)",
"toggleBulkMode": "切換批量模式",
"selectAll": "選取所有可見模型",
"rangeSelect": "範圍選取",
"marqueeSelect": "框選卡片(在網格空白處拖曳)",
"exitBulkMode": "離開批量模式",
"bulkActions": "在已選取的卡片上:批量操作選單",
"globalActions": "在頁面空白處:全域操作選單(檢查更新、管理已排除的模型)",
"scrollPages": "捲動頁面",
"jumpAlphabet": "字母列跳轉",
"prevNext": "上一個 / 下一個模型",
"deleteEntry": "刪除",
"cycleMedia": "切換媒體(範例展示中的 [ / ])",
"swipeTouch": "在觸控裝置上滑動切換媒體",
"closeViewer": "關閉檢視器"
}
},
"updateVlogs": {
"title": "最新更新",
@@ -1934,7 +2014,8 @@
"settings": "設定與配置",
"extensions": "擴充功能",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "檢查更新",
@@ -2141,9 +2222,6 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何配方",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
@@ -2151,6 +2229,7 @@
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
"reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
+21
View File
@@ -8,6 +8,7 @@ from typing import Dict, Any
from ..base import RecipeMetadataParser
from ..constants import GEN_PARAM_KEYS
from ...services.metadata_service import get_default_metadata_provider
from ...utils.constants import is_empty_placeholder_hash
logger = logging.getLogger(__name__)
@@ -524,6 +525,26 @@ class AutomaticMetadataParser(RecipeMetadataParser):
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
if is_empty_placeholder_hash(lora_hash):
# The empty-hash placeholder (SHA256 of an empty byte
# string) is not a real hash: never look it up in the
# local hash index or on CivitAI. Match by filename;
# otherwise keep the item as unresolved (no hash, flagged
# hashInvalid so the UI shows the unresolvable-hash state
# and offers reconnect instead of download) rather than
# dropping it.
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if local_lora:
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
merge_or_append_local(local_entry)
continue
lora_entry['hash'] = ''
lora_entry['hashInvalid'] = True
if not resource_lora_count:
loras.append(lora_entry)
continue
if lora_hash and recipe_scanner and lora_type == 'lora':
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
if local_lora:
+22 -1
View File
@@ -196,7 +196,7 @@ class RecipeFormatParser(RecipeMetadataParser):
filtered_gen_params[key] = value
return {
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else recipe_metadata.get('base_model', ''),
'base_model': checkpoint['baseModel'] if checkpoint and checkpoint.get('baseModel') else (recipe_metadata.get('base_model') or None),
'loras': loras,
'gen_params': filtered_gen_params,
'tags': recipe_metadata.get('tags', []),
@@ -208,3 +208,24 @@ class RecipeFormatParser(RecipeMetadataParser):
except Exception as e:
logger.error(f"Error parsing recipe format metadata: {e}", exc_info=True)
return {"error": str(e), "loras": []}
def strip_recipe_metadata(metadata_text: str) -> str:
"""Strip the ``Recipe metadata: {...}`` block appended by LoRA Manager.
The saved recipe image carries the original generation metadata followed
by an appended recipe JSON block (see ``ExifUtils.append_recipe_metadata``).
Re-import wants to re-parse the original embedded metadata, so this returns
only the text before the appended marker. The input is returned unchanged
when no marker is present.
"""
if not metadata_text:
return metadata_text
match = re.search(
RecipeFormatParser.METADATA_MARKER,
metadata_text,
re.IGNORECASE | re.DOTALL,
)
if not match:
return metadata_text
return metadata_text[: match.start()].strip()
+89 -1
View File
@@ -15,6 +15,10 @@ from aiohttp import web
import jinja2
from ...config import config
from ...services.active_filters_store import (
ActiveFiltersStore,
active_filters_to_query_kwargs,
)
from ...services.download_coordinator import DownloadCoordinator
from ...services.connectivity_guard import (
OFFLINE_FRIENDLY_MESSAGE,
@@ -1595,12 +1599,50 @@ class ModelQueryHandler:
allow_selling_generated_content.lower() not in ("false", "0", "")
)
# When requested, merge the manager page's active filters stored
# server-side. Explicit query parameters take precedence over the
# stored values.
use_active_filters = (
request.query.get("use_active_filters", "").lower() in ("1", "true")
)
if use_active_filters:
stored = ActiveFiltersStore.get_instance().get_filters(
self._service.model_type
)
injected = active_filters_to_query_kwargs(stored)
if folder is None and "folder" in injected:
folder = injected["folder"]
if "recursive" not in request.query and "recursive" in injected:
recursive = injected["recursive"]
if not base_models and injected.get("base_models"):
base_models = injected["base_models"]
if not model_types and injected.get("model_types"):
model_types = injected["model_types"]
if not tag_filters and injected.get("tags"):
tag_filters = injected["tags"]
if not auto_tag_filters and injected.get("auto_tags"):
auto_tag_filters = injected["auto_tags"]
if "tag_logic" not in request.query and injected.get("tag_logic"):
injected_logic = str(injected["tag_logic"]).lower()
if injected_logic in ("any", "all"):
tag_logic = injected_logic
if credit_required is None and "credit_required" in injected:
credit_required = injected["credit_required"]
if (
allow_selling_generated_content is None
and "allow_selling_generated_content" in injected
):
allow_selling_generated_content = injected[
"allow_selling_generated_content"
]
# The presence of the recursive param (always sent by the loras
# widget when filter mode is on) signals that the filter pipeline
# must run even when no concrete filter is set, so global settings
# like show_only_sfw stay consistent with the list endpoint.
apply_filters = (
"recursive" in request.query
use_active_filters
or "recursive" in request.query
or folder is not None
or bool(base_models)
or bool(model_types)
@@ -1634,6 +1676,50 @@ class ModelQueryHandler:
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def update_active_filters(self, request: web.Request) -> web.Response:
"""Store the manager page's active filters for this model type."""
try:
payload = await request.json()
except Exception:
return web.json_response(
{"success": False, "error": "Invalid JSON body"}, status=400
)
if not isinstance(payload, dict):
return web.json_response(
{"success": False, "error": "Body must be a JSON object"}, status=400
)
try:
ActiveFiltersStore.get_instance().set_filters(
self._service.model_type, payload
)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error(
"Error updating active filters for %s: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_active_filters(self, request: web.Request) -> web.Response:
"""Return the stored active filters for this model type."""
try:
filters = ActiveFiltersStore.get_instance().get_filters(
self._service.model_type
)
return web.json_response({"success": True, "filters": filters})
except Exception as exc:
self._logger.error(
"Error getting active filters for %s: %s",
self._service.model_type,
exc,
exc_info=True,
)
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelDownloadHandler:
"""Coordinate downloads and progress reporting."""
@@ -3339,6 +3425,8 @@ class ModelHandlerSet:
"get_model_metadata": self.query.get_model_metadata,
"get_model_description": self.query.get_model_description,
"get_relative_paths": self.query.get_relative_paths,
"update_active_filters": self.query.update_active_filters,
"get_active_filters": self.query.get_active_filters,
"refresh_model_updates": self.updates.refresh_model_updates,
"fetch_missing_civitai_license_data": self.updates.fetch_missing_civitai_license_data,
"set_model_update_ignore": self.updates.set_model_update_ignore,
+220 -241
View File
@@ -26,6 +26,7 @@ from ...services.recipes import (
RecipeValidationError,
)
from ...services.metadata_service import get_default_metadata_provider
from ...services.recipe_scanner import UNKNOWN_BASE_MODEL_FILTER
from ...utils.civitai_utils import (
build_civitai_image_page_url,
extract_civitai_image_id,
@@ -128,11 +129,6 @@ class RecipeHandlerSet:
"get_recipes_for_checkpoint": self.query.get_recipes_for_checkpoint,
"scan_recipes": self.query.scan_recipes,
"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,
"cancel_rematch": self.management.cancel_rematch,
"rematch_recipe": self.management.rematch_recipe,
@@ -352,6 +348,17 @@ class RecipeListingHandler:
if not recipe:
return web.json_response({"error": "Recipe not found"}, status=404)
# Expose the on-disk recipe JSON path so the modal can offer
# "open file location" without guessing the storage layout.
recipe = dict(recipe)
try:
json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
except Exception: # pragma: no cover - details must still load
json_path = None
if json_path:
recipe["recipe_json_path"] = json_path
return web.json_response(recipe)
except Exception as exc:
self._logger.error(
@@ -473,17 +480,32 @@ class RecipeQueryHandler:
cache = await recipe_scanner.get_cached_data()
base_model_counts: Dict[str, int] = {}
unknown_count = 0
for recipe in getattr(cache, "raw_data", []):
base_model = recipe.get("base_model")
if base_model:
base_model_counts[base_model] = (
base_model_counts.get(base_model, 0) + 1
)
else:
unknown_count += 1
sorted_models = [
{"name": model, "count": count}
for model, count in base_model_counts.items()
]
if unknown_count:
# Synthetic "Unknown" bucket for recipes whose base model could
# not be determined. `value` carries the filter marker so the
# UI can display "Unknown" without colliding with real base
# model strings.
sorted_models.append(
{
"name": "Unknown",
"value": UNKNOWN_BASE_MODEL_FILTER,
"count": unknown_count,
}
)
sorted_models.sort(key=lambda entry: entry["count"], reverse=True)
if limit > 0:
sorted_models = sorted_models[:limit]
@@ -769,157 +791,6 @@ class RecipeManagementHandler:
self._logger.error("Error saving recipe: %s", exc, exc_info=True)
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:
try:
await self._ensure_dependencies_ready()
@@ -931,12 +802,9 @@ class RecipeManagementHandler:
)
# Mutual exclusion: a global rematch cannot start while a rematch
# OR a repair is already running — both mutate recipes under the
# same mutation lock.
if (
self._ws_manager.is_recipe_rematch_running()
or self._ws_manager.is_recipe_repair_running()
):
# is already running — both mutate recipes under the same
# mutation lock.
if self._ws_manager.is_recipe_rematch_running():
return web.json_response(
{"success": False, "error": "Recipe rematch already in progress"},
status=409,
@@ -1074,12 +942,14 @@ class RecipeManagementHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reimport_recipe(self, request: web.Request) -> web.Response:
"""Delete a recipe and re-import it from its source URL.
"""Delete a recipe and re-import it from its source.
This gives the recipe a fresh start re-downloads the image from
CivitAI, re-parses EXIF metadata with the current parser, and
re-resolves LoRAs / checkpoint. User edits (title, tags, favorite)
are carried over from the old recipe.
Gives the recipe a fresh start: URL-sourced recipes re-download the
image from CivitAI; local ones re-parse the saved recipe image. Both
use the original embedded generation metadata (the appended recipe
metadata block is ignored) with the current parser, and re-resolve
LoRAs / checkpoint. User edits (title, tags, favorite) are carried
over from the old recipe.
"""
try:
await self._ensure_dependencies_ready()
@@ -1092,13 +962,40 @@ class RecipeManagementHandler:
if not old_recipe:
raise RecipeNotFoundError(f"Recipe {recipe_id} not found")
source_path = old_recipe.get("source_path")
if not source_path:
old_file_path = old_recipe.get("file_path", "")
old_folder = os.path.dirname(old_file_path) if old_file_path else None
source_path = old_recipe.get("source_path") or ""
image_id = extract_civitai_image_id(source_path) if source_path else None
# Local re-import sources: an explicit local source_path, or — when
# no usable source_path was recorded (drag & drop / file-picker
# imports, or a dangling path left by an earlier re-import) — the
# recipe's own saved image, which still carries the original
# embedded generation metadata next to the recipe metadata block.
# In the fallback case nothing is persisted as source_path: the
# recipe's own previous preview is not an external source, and it
# is deleted together with the old recipe below.
local_source = None
persisted_source_path = ""
if not image_id and source_path and os.path.isfile(source_path):
local_source = source_path
persisted_source_path = source_path
elif (
not image_id
and not source_path.startswith(("http://", "https://"))
and old_file_path
and os.path.isfile(old_file_path)
):
local_source = old_file_path
if not image_id and not local_source:
return web.json_response(
{
"success": False,
"error": (
"Recipe has no source URL — cannot re-import. "
"Recipe has no re-importable source (no source URL "
"and no accessible local image). "
"Use repair or manual import instead."
),
},
@@ -1112,41 +1009,66 @@ class RecipeManagementHandler:
if "tags" in user_edits and not isinstance(user_edits["tags"], list):
del user_edits["tags"]
old_file_path = old_recipe.get("file_path", "")
old_folder = os.path.dirname(old_file_path) if old_file_path else None
image_id = extract_civitai_image_id(source_path)
is_local_file = not image_id and os.path.isfile(source_path)
if not image_id and not is_local_file:
return web.json_response(
{
"success": False,
"error": (
"Recipe source is neither a valid CivitAI image URL "
"nor an accessible local file. "
"Use repair or manual import instead."
),
},
status=400,
)
if is_local_file:
if local_source:
return await self._do_reimport_from_local(
source_path,
local_source,
recipe_scanner,
recipe_id=recipe_id,
target_dir=old_folder,
user_edits=user_edits,
old_title=old_recipe.get("title", ""),
persisted_source_path=persisted_source_path,
)
async with self._import_semaphore:
import_response = await self._do_import_from_url(
source_path,
recipe_scanner,
target_dir=old_folder,
)
# Optional caller-supplied metadata payload (companion browser
# extension re-import). Only honored for CivitAI image page
# sources; everything else uses the native URL import below.
params = request.rel_url.query
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(
recipe_scanner=recipe_scanner, recipe_id=recipe_id
@@ -1173,14 +1095,19 @@ class RecipeManagementHandler:
exc,
)
return web.json_response(
{
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
response_body: Dict[str, Any] = {
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": source_path,
}
loras_count = await self._count_recipe_loras(
recipe_scanner, new_recipe_id
)
if loras_count is not None:
response_body["loras_count"] = loras_count
return web.json_response(response_body)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except RecipeValidationError as exc:
@@ -1193,18 +1120,6 @@ class RecipeManagementHandler:
)
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:
try:
await self._ensure_dependencies_ready()
@@ -1225,31 +1140,14 @@ class RecipeManagementHandler:
if not resources_raw:
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
async with self._import_semaphore:
return await self._do_import_remote_recipe(
return await self._import_remote_recipe_impl(
image_url=image_url,
name=name,
lora_entries=lora_entries,
checkpoint_entry=checkpoint_entry,
gen_params_request=gen_params_request,
tags=self._parse_tags(params.get("tags")),
resources_raw=resources_raw,
gen_params_raw=params.get("gen_params"),
tags_raw=params.get("tags"),
base_model=params.get("base_model", "") or "",
source_path=params.get("source_path") or image_url,
)
@@ -1263,6 +1161,52 @@ class RecipeManagementHandler:
)
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(
self,
*,
@@ -1274,6 +1218,7 @@ class RecipeManagementHandler:
tags: list[Any],
base_model: str,
source_path: str,
target_dir: str | None = None,
) -> web.Response:
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
@@ -1437,6 +1382,7 @@ class RecipeManagementHandler:
tags=tags,
metadata=metadata,
extension=extension,
target_dir=target_dir,
)
return web.json_response(result.payload, status=result.status)
@@ -1901,6 +1847,25 @@ class RecipeManagementHandler:
return []
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]]:
if payload is None:
return None
@@ -2481,11 +2446,20 @@ class RecipeManagementHandler:
target_dir: str | None,
user_edits: dict[str, Any],
old_title: str,
persisted_source_path: str,
) -> web.Response:
"""Re-import a recipe from a local image file.
Reads the original source file, re-parses its EXIF metadata, saves a
fresh recipe, then deletes the old one.
Reads the original source file, re-parses its original embedded
generation metadata (the appended recipe metadata block is ignored so
the current parser gets a fresh pass), saves a new recipe, then deletes
the old one.
``persisted_source_path`` is the source_path recorded on the new
recipe: the external source file when one exists, or empty when the
re-import fell back to the recipe's own previous preview image (that
file is deleted with the old recipe, so recording it would leave a
dangling path that blocks future re-imports).
"""
normalized = os.path.normpath(file_path)
if not os.path.isfile(normalized):
@@ -2501,6 +2475,7 @@ class RecipeManagementHandler:
analysis_result = await self._analysis_service.analyze_local_image(
file_path=normalized,
recipe_scanner=recipe_scanner,
ignore_recipe_metadata=True,
)
analysis_payload: dict[str, Any] = analysis_result.payload
@@ -2513,7 +2488,7 @@ class RecipeManagementHandler:
"base_model": base_model,
"loras": loras,
"gen_params": gen_params,
"source_path": normalized,
"source_path": persisted_source_path,
}
if checkpoint:
metadata["checkpoint"] = checkpoint
@@ -2545,6 +2520,10 @@ class RecipeManagementHandler:
metadata=metadata,
extension=extension,
target_dir=target_dir,
# The source is the recipe's own already-optimized preview image;
# store its bytes verbatim instead of re-compressing (which would
# only degrade quality) and skip the metadata re-append.
skip_optimize=True,
)
await self._persistence_service.delete_recipe(
@@ -2572,7 +2551,7 @@ class RecipeManagementHandler:
"success": True,
"old_recipe_id": recipe_id,
"recipe_id": new_recipe_id,
"source_path": normalized,
"source_path": persisted_source_path,
}
)
+2
View File
@@ -68,6 +68,8 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/{prefix}/model-description", "get_model_description"
),
RouteDefinition("GET", "/api/lm/{prefix}/relative-paths", "get_relative_paths"),
RouteDefinition("PUT", "/api/lm/{prefix}/active-filters", "update_active_filters"),
RouteDefinition("GET", "/api/lm/{prefix}/active-filters", "get_active_filters"),
RouteDefinition(
"GET", "/api/lm/{prefix}/civitai/versions/{model_id}", "get_civitai_versions"
),
+5 -5
View File
@@ -84,11 +84,6 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
"GET", "/api/lm/recipes/for-checkpoint", "get_recipes_for_checkpoint"
),
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-bulk", "rematch_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/{recipe_id}/rematch", "rematch_recipe"),
@@ -115,6 +110,11 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"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(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
),
+135
View File
@@ -0,0 +1,135 @@
"""In-memory store for the LoRA Manager page's active filters.
The manager page keeps its filter state in localStorage for its own
restoration, but the ComfyUI node autocomplete runs in a potentially
different browser/origin (or Electron shell) where that storage is not
shared. This store mirrors the active filters server-side so the
``/api/lm/{prefix}/relative-paths`` endpoint can inject them into
autocomplete searches regardless of which client set them.
State is process-local and intentionally not persisted; the manager page
re-pushes its restored state on load.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
logger = logging.getLogger(__name__)
# Keys copied from the manager page's persisted filter snapshot.
_FILTER_KEYS = (
"baseModel",
"tags",
"autoTags",
"modelTypes",
"tagLogic",
"license",
)
class ActiveFiltersStore:
"""Process-local store of active filters, keyed by model type."""
_instance: Optional["ActiveFiltersStore"] = None
def __init__(self) -> None:
self._filters: Dict[str, Dict[str, Any]] = {}
@classmethod
def get_instance(cls) -> "ActiveFiltersStore":
if cls._instance is None:
cls._instance = cls()
return cls._instance
@classmethod
def reset_instance(cls) -> None:
"""Drop the singleton (test isolation)."""
cls._instance = None
def set_filters(self, model_type: str, payload: Dict[str, Any]) -> None:
"""Replace the stored active filters for a model type.
Only recognized keys are kept; everything else is discarded.
"""
filters = payload.get("filters")
sanitized: Dict[str, Any] = {
"activeFolder": payload.get("activeFolder"),
"recursiveSearch": bool(payload.get("recursiveSearch", True)),
"filters": (
{key: filters[key] for key in _FILTER_KEYS if key in filters}
if isinstance(filters, dict)
else None
),
}
self._filters[model_type] = sanitized
def get_filters(self, model_type: str) -> Optional[Dict[str, Any]]:
"""Return the stored payload for a model type, or None if unset."""
return self._filters.get(model_type)
def clear(self, model_type: str) -> None:
self._filters.pop(model_type, None)
def active_filters_to_query_kwargs(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Map a stored active-filters payload to ``search_relative_paths`` kwargs.
Mirrors the query-param mapping that the ComfyUI autocomplete used to
build client-side from localStorage (web/comfyui/autocomplete.js).
"""
kwargs: Dict[str, Any] = {}
if not payload:
return kwargs
active_folder = payload.get("activeFolder")
recursive = payload.get("recursiveSearch", True)
if active_folder and active_folder != "null":
kwargs["folder"] = active_folder
elif not recursive:
# Root folder with recursion disabled mirrors the page list,
# which matches only root-level files via folder=''.
kwargs["folder"] = ""
filters = payload.get("filters")
if isinstance(filters, dict):
base_models = filters.get("baseModel")
if isinstance(base_models, list):
kwargs["base_models"] = [m for m in base_models if m]
for source_key, target_key in (("tags", "tags"), ("autoTags", "auto_tags")):
states = filters.get(source_key)
if isinstance(states, dict):
mapped = {
tag: state
for tag, state in states.items()
if state in ("include", "exclude")
}
if mapped:
kwargs[target_key] = mapped
model_types = filters.get("modelTypes")
if isinstance(model_types, list):
kwargs["model_types"] = [t for t in model_types if t]
tag_logic = filters.get("tagLogic")
if tag_logic:
kwargs["tag_logic"] = tag_logic
license_filter = filters.get("license")
if isinstance(license_filter, dict):
no_credit = license_filter.get("noCredit")
if no_credit == "include":
kwargs["credit_required"] = False
elif no_credit == "exclude":
kwargs["credit_required"] = True
allow_selling = license_filter.get("allowSelling")
if allow_selling == "include":
kwargs["allow_selling_generated_content"] = True
elif allow_selling == "exclude":
kwargs["allow_selling_generated_content"] = False
kwargs["recursive"] = recursive
return kwargs
+27 -2
View File
@@ -1295,6 +1295,27 @@ class BaseModelService(ABC):
path_for_sorting,
)
@staticmethod
def _relative_path_folder_group_sort_key(
relative_path: str, include_terms: List[str]
) -> tuple:
"""Group paths by folder, then sort by relevance within each group.
Folders are ordered alphabetically (case-insensitive) by their full
folder path, with root-level files (empty folder) first. Within a
folder, paths keep the relevance ordering of
``_relative_path_sort_key``. This keeps same-folder entries together
in the autocomplete dropdown instead of interleaving them by filename.
"""
path_for_sorting = BaseModelService._remove_model_extension(
relative_path.lower()
)
folder = path_for_sorting.rpartition(os.sep)[0]
return (folder,) + BaseModelService._relative_path_sort_key(
relative_path, include_terms
)
async def search_relative_paths(
self,
search_term: str,
@@ -1404,9 +1425,13 @@ class BaseModelService(ABC):
):
matching_paths.append(relative_path)
# Sort by relevance (prefix and earliest hits first, then by length and alphabetically)
# Group by folder (root first, then alphabetically) and sort by
# relevance (prefix and earliest hits, then length and alphabetically)
# within each folder group.
matching_paths.sort(
key=lambda relative: self._relative_path_sort_key(relative, include_terms)
key=lambda relative: self._relative_path_folder_group_sort_key(
relative, include_terms
)
)
# Apply offset and limit
+52 -1
View File
@@ -21,7 +21,7 @@ from .model_metadata_provider import (
from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, is_empty_placeholder_hash
logger = logging.getLogger(__name__)
@@ -180,6 +180,11 @@ class CivitaiClient:
async def get_model_by_hash(
self, model_hash: str
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
if is_empty_placeholder_hash(model_hash):
# The empty-hash placeholder (SHA256 of an empty byte string)
# matches no real file; CivitAI's by-hash index can contain
# polluted entries for it, so never resolve it.
return None, "Model not found"
try:
success, version = await self._make_request(
"GET",
@@ -500,9 +505,55 @@ class CivitaiClient:
logger.warning(f"Failed to fetch version by id {version_id}")
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]]:
if not model_hash:
return None
if is_empty_placeholder_hash(model_hash):
return None
success, version = await self._make_request(
"GET",
+55
View File
@@ -35,6 +35,7 @@ from .service_registry import ServiceRegistry
from .settings_manager import get_settings_manager
from .metadata_service import get_default_metadata_provider, get_metadata_provider
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService
@@ -929,6 +930,42 @@ class DownloadManager:
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(
self,
*,
@@ -1858,6 +1895,24 @@ class DownloadManager:
if not download_urls:
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
file_name = file_info.get("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)
return _catalog_cache or {}
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)
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)
return []
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)
return []
+9 -1
View File
@@ -1,6 +1,8 @@
from typing import Dict, Optional, Set, List
import os
from ..utils.constants import is_empty_placeholder_hash
class ModelHashIndex:
"""Index for looking up models by hash or filename"""
@@ -81,6 +83,8 @@ class ModelHashIndex:
# mapping. First-time registrations stay O(1).
if autov3:
autov3 = autov3.lower()
if is_empty_placeholder_hash(autov3):
autov3 = None
if is_re_registration and (existing_hash != sha256 or autov3):
stale_autov3_keys = [
key for key, mapped_path in self._autov3_to_path.items()
@@ -93,7 +97,7 @@ class ModelHashIndex:
def add_autov3(self, autov3: str, file_path: str) -> None:
"""Add or update an AutoV3-only index entry (used when only AutoV3 is known)"""
if not autov3:
if not autov3 or is_empty_placeholder_hash(autov3):
return
autov3 = autov3.lower()
self._autov3_to_path[autov3] = file_path
@@ -250,6 +254,8 @@ class ModelHashIndex:
def has_hash(self, hash_value: str) -> bool:
"""Check if hash exists in index (SHA256, AutoV2, or AutoV3)"""
if is_empty_placeholder_hash(hash_value):
return False
normalized = hash_value.lower()
if normalized in self._hash_to_path:
return True
@@ -261,6 +267,8 @@ class ModelHashIndex:
def get_path(self, hash_value: str) -> Optional[str]:
"""Get file path for a hash (SHA256, AutoV2, or AutoV3)"""
if is_empty_placeholder_hash(hash_value):
return None
normalized = hash_value.lower()
path = self._hash_to_path.get(normalized)
if path is not None:
+57
View File
@@ -169,6 +169,17 @@ class ModelMetadataProvider(ABC):
"""Published model count for the user; None when unsupported."""
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):
"""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]:
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):
"""Provider that uses CivArchive API for metadata"""
@@ -700,6 +716,37 @@ class FallbackMetadataProvider(ModelMetadataProvider):
continue
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):
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]:
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:
"""Manager for selecting and using model metadata providers"""
+137 -16
View File
@@ -66,6 +66,14 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
# requests (modal open + autocomplete) do not re-walk the model roots.
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
# Maps a scanner model type to the manager page type used in progress
# broadcasts (e.g. 'lora' -> 'loras').
PAGE_TYPE_MAP = {
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings',
}
def _is_pending_delete_path(path: str) -> bool:
"""Return True when any path component is the pending-delete staging dir."""
@@ -149,6 +157,38 @@ class ModelScanner:
# Register this service
asyncio.create_task(self._register_service())
@property
def page_type(self) -> str:
"""Manager page type used in progress broadcasts (e.g. 'loras')."""
return PAGE_TYPE_MAP.get(self.model_type, self.model_type)
async def _broadcast_scan_progress(
self,
status: str,
stage: str,
progress: int,
full_rebuild: bool,
**extra: Any,
) -> None:
"""Broadcast manual-refresh scan progress on the generic WS channel.
Best-effort only: broadcast failures must never affect the scan itself.
"""
payload: Dict[str, Any] = {
'type': 'scan_progress',
'status': status,
'model_type': self.model_type,
'pageType': self.page_type,
'stage': stage,
'full_rebuild': full_rebuild,
'progress': progress,
}
payload.update(extra)
try:
await ws_manager.broadcast(payload)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error broadcasting scan progress for {self.model_type}: {exc}")
@property
def cache_version(self) -> int:
"""Monotonic version counter for the in-memory cache.
@@ -434,12 +474,7 @@ class ModelScanner:
self._is_initializing = True
# Determine the page type based on model type
page_type_map = {
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings'
}
page_type = page_type_map.get(self.model_type, self.model_type)
page_type = self.page_type
# First, try to load from cache
await ws_manager.broadcast_init_progress({
@@ -804,7 +839,7 @@ class ModelScanner:
last_progress_time = time.time()
last_progress_percent = 0
async def progress_callback(processed_files: int, expected_total: int) -> None:
async def progress_callback(processed_files: int, expected_total: int, current_name: str = '') -> None:
nonlocal last_progress_time, last_progress_percent
if expected_total <= 0:
@@ -871,32 +906,84 @@ class ModelScanner:
async def _initialize_cache(self) -> None:
"""Initialize or refresh the cache"""
self._is_initializing = True # Set flag
last_progress_percent = 0
try:
start_time = time.time()
await self._broadcast_scan_progress('started', 'scan_folders', 0, True)
# Manually trigger a symlink rescan during a full rebuild.
# This ensures that any new symlink mappings are correctly picked up.
config.rebuild_symlink_cache()
# Determine the page type based on model type
# Count files in a thread so the event loop stays responsive
loop = asyncio.get_running_loop()
total_files = await loop.run_in_executor(None, self._count_model_files)
await self._broadcast_scan_progress(
'processing', 'count_models', 1, True,
processed=0, total=total_files,
)
last_progress_time = time.time()
async def progress_callback(processed_files: int, expected_total: int, current_name: str = '') -> None:
nonlocal last_progress_time, last_progress_percent
if expected_total <= 0:
return
current_time = time.time()
progress_percent = min(99, int(1 + (processed_files / expected_total) * 98))
if progress_percent <= last_progress_percent:
return
if current_time - last_progress_time <= 0.5 and processed_files != expected_total:
return
last_progress_percent = progress_percent
last_progress_time = current_time
await self._broadcast_scan_progress(
'processing', 'process_models', progress_percent, True,
processed=processed_files, total=expected_total,
current_name=current_name,
)
# Scan for new data
scan_result = await self._gather_model_data()
scan_result = await self._gather_model_data(
total_files=total_files,
progress_callback=progress_callback,
)
if not self.is_cancelled():
await self._broadcast_scan_progress('finalizing', 'finalizing', 99, True)
await self._apply_scan_result(scan_result)
await self._save_persistent_cache(scan_result)
await self._sync_download_history(scan_result.raw_data, source='scan')
await self._broadcast_scan_progress(
'completed', 'finalizing', 100, True,
elapsed_seconds=time.time() - start_time,
)
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization completed in {time.time() - start_time:.2f} seconds, "
f"found {len(scan_result.raw_data)} models"
)
else:
await self._broadcast_scan_progress(
'cancelled', 'process_models', last_progress_percent, True,
elapsed_seconds=time.time() - start_time,
)
logger.info(
f"{self.model_type.capitalize()} Scanner: Cache initialization cancelled "
f"after {time.time() - start_time:.2f} seconds"
)
except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error initializing cache: {e}")
await self._broadcast_scan_progress(
'error', 'process_models', last_progress_percent, True,
error=str(e),
)
# Ensure cache is at least an empty structure on error
if self._cache is None:
self._cache = ModelCache(
@@ -914,6 +1001,8 @@ class ModelScanner:
try:
start_time = time.time()
logger.info(f"{self.model_type.capitalize()} Scanner: Starting fast cache reconciliation...")
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
# Get current cached file paths
cached_paths = {item['file_path'] for item in self._cache.raw_data}
@@ -987,6 +1076,10 @@ class ModelScanner:
await asyncio.sleep(0)
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
await self._broadcast_scan_progress(
'cancelled', 'reconcile_scan', 0, False,
elapsed_seconds=time.time() - start_time,
)
return
# Process new files in batches
@@ -994,10 +1087,14 @@ class ModelScanner:
if new_files:
logger.info(f"{self.model_type.capitalize()} Scanner: Found {len(new_files)} new files to process")
batch_size = 50
for i in range(0, len(new_files), batch_size):
total_new = len(new_files)
processed_new = 0
last_progress_time = time.time()
for i in range(0, total_new, batch_size):
batch = new_files[i:i+batch_size]
for path in batch:
logger.info(f"{self.model_type.capitalize()} Scanner: Processing {path}")
processed_new += 1
try:
# Find the appropriate root path for this file
root_path = None
@@ -1053,9 +1150,24 @@ class ModelScanner:
logger.error(f"Could not determine root path for {path}")
except Exception as e:
logger.error(f"Error adding {path} to cache: {e}")
current_time = time.time()
if current_time - last_progress_time > 0.5 or processed_new == total_new:
last_progress_time = current_time
await self._broadcast_scan_progress(
'processing', 'process_new',
min(99, int(1 + (processed_new / total_new) * 98)), False,
processed=processed_new, total=total_new,
current_name=os.path.basename(path),
)
if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile processing cancelled")
await self._broadcast_scan_progress(
'cancelled', 'process_new',
min(99, int(1 + (processed_new / total_new) * 98)), False,
elapsed_seconds=time.time() - start_time,
)
return
# Find missing files (in cache but not in filesystem)
@@ -1121,8 +1233,17 @@ class ModelScanner:
await self._persist_current_cache()
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
await self._broadcast_scan_progress(
'completed', 'process_new', 100, False,
added=total_added, removed=total_removed,
elapsed_seconds=time.time() - start_time,
)
except Exception as e:
logger.error(f"{self.model_type.capitalize()} Scanner: Error reconciling cache: {e}", exc_info=True)
await self._broadcast_scan_progress(
'error', 'reconcile_scan', 0, False,
error=str(e),
)
finally:
self._is_initializing = False # Unset flag
self.bump_cache_version()
@@ -1498,7 +1619,7 @@ class ModelScanner:
self,
*,
total_files: int = 0,
progress_callback: Optional[Callable[[int, int], Awaitable[None]]] = None
progress_callback: Optional[Callable[[int, int, str], Awaitable[None]]] = None
) -> CacheBuildResult:
"""Collect metadata for all model files."""
@@ -1510,11 +1631,11 @@ class ModelScanner:
processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set()
async def handle_progress() -> None:
async def handle_progress(current_name: str = '') -> None:
if progress_callback is None:
return
try:
await progress_callback(processed_files, total_files)
await progress_callback(processed_files, total_files, current_name)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error reporting progress for {self.model_type}: {exc}")
@@ -1580,7 +1701,7 @@ class ModelScanner:
for tag in result.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
await handle_progress()
await handle_progress(entry.name)
await asyncio.sleep(0)
if self.is_cancelled():
return
-4
View File
@@ -52,7 +52,6 @@ class PersistentRecipeCache:
"file_mtime",
"file_size",
"favorite",
"repair_version",
"preview_nsfw_level",
"loras_json",
"checkpoint_json",
@@ -442,7 +441,6 @@ class PersistentRecipeCache:
file_mtime REAL,
file_size INTEGER,
favorite INTEGER DEFAULT 0,
repair_version INTEGER DEFAULT 0,
preview_nsfw_level INTEGER DEFAULT 0,
loras_json TEXT,
checkpoint_json TEXT,
@@ -541,7 +539,6 @@ class PersistentRecipeCache:
file_mtime,
file_size,
1 if recipe.get("favorite") else 0,
int(recipe.get("repair_version") or 0),
int(recipe.get("preview_nsfw_level") or 0),
loras_json,
checkpoint_json,
@@ -599,7 +596,6 @@ class PersistentRecipeCache:
"created_date": row["created_date"] or 0.0,
"modified": row["modified"] or 0.0,
"favorite": bool(row["favorite"]),
"repair_version": row["repair_version"] or 0,
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
"has_workflow": bool(row["has_workflow"]),
"loras": loras,
+125 -212
View File
@@ -48,6 +48,12 @@ _CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
# Valid LoRA availability statuses for the recipe listing filter.
_VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
# Filter marker for recipes whose base model could not be determined
# (base_model is None or empty). The UI displays "Unknown" for this bucket;
# the marker keeps the semantics explicit and disjoint from any real base
# model string.
UNKNOWN_BASE_MODEL_FILTER = "__unknown__"
class RecipeScanner:
"""Service for scanning and managing recipe images"""
@@ -88,8 +94,6 @@ class RecipeScanner:
cls._instance._civitai_client = None # Will be lazily initialized
return cls._instance
REPAIR_VERSION = 4
def __init__(
self,
lora_scanner: Optional[LoraScanner] = None,
@@ -805,207 +809,6 @@ class RecipeScanner:
"""Check if cancellation has been 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]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
@@ -1747,7 +1550,36 @@ class RecipeScanner:
# Mark initialization as complete regardless of outcome
self._is_initializing = False
def _initialize_recipe_cache_sync(self):
async def _broadcast_scan_progress(
self,
status: str,
stage: str,
progress: int,
full_rebuild: bool,
**extra: Any,
) -> None:
"""Broadcast manual-refresh scan progress on the generic WS channel.
Mirrors ``ModelScanner._broadcast_scan_progress`` so the recipes page
can reuse the same frontend contract. Best-effort only: broadcast
failures must never affect the scan itself.
"""
payload: Dict[str, Any] = {
'type': 'scan_progress',
'status': status,
'model_type': 'recipe',
'pageType': 'recipes',
'stage': stage,
'full_rebuild': full_rebuild,
'progress': progress,
}
payload.update(extra)
try:
await ws_manager.broadcast(payload)
except Exception as exc: # pragma: no cover - defensive logging
logger.error(f"Error broadcasting scan progress for recipe: {exc}")
def _initialize_recipe_cache_sync(self, report_progress: bool = False):
"""Synchronous version of recipe cache initialization for thread pool execution.
Uses persistent cache for fast startup when available:
@@ -1755,8 +1587,14 @@ class RecipeScanner:
2. Reconcile with filesystem (check mtime/size for changes)
3. Fall back to full directory scan if cache miss or reconciliation fails
4. Persist results for next startup
Args:
report_progress: When True (manual force-refresh only), broadcast
scan_progress messages during the full directory scan. Startup
initialization leaves this False and behaves as before.
"""
loop = None
scan_start_time: Optional[float] = None
try:
# Ensure cache exists to avoid None reference errors
if self._cache is None:
@@ -1838,7 +1676,17 @@ class RecipeScanner:
# Fall back to full directory scan
logger.info("Recipe cache miss: performing full directory scan")
recipes, json_paths = self._full_directory_scan_sync(recipes_dir)
if report_progress:
scan_start_time = time.time()
# Broadcast from the worker thread via its own event loop,
# mirroring ModelScanner._initialize_cache_sync.
loop.run_until_complete(
self._broadcast_scan_progress('started', 'scan_folders', 0, True)
)
recipes, json_paths = self._full_directory_scan_sync(
recipes_dir,
progress_loop=loop if report_progress else None,
)
self._json_path_map = json_paths
# Update cache with the collected data
@@ -1852,12 +1700,30 @@ class RecipeScanner:
recipes, json_paths, self._cache.image_id_map
)
if report_progress:
loop.run_until_complete(
self._broadcast_scan_progress(
'completed', 'finalizing', 100, True,
elapsed_seconds=time.time() - (scan_start_time or time.time()),
total=len(recipes),
)
)
return self._cache
except Exception as e:
logger.error(f"Error in thread-based recipe cache initialization: {e}")
import traceback
traceback.print_exc(file=sys.stderr)
if report_progress and loop is not None:
try:
loop.run_until_complete(
self._broadcast_scan_progress(
'error', 'process_models', 0, True, error=str(e)
)
)
except Exception: # pragma: no cover - defensive logging
logger.error("Error broadcasting recipe scan failure", exc_info=True)
return self._cache if hasattr(self, "_cache") else None
finally:
# Clean up the event loop
@@ -2011,12 +1877,16 @@ class RecipeScanner:
return updated
def _full_directory_scan_sync(
self, recipes_dir: str
self,
recipes_dir: str,
progress_loop: Optional[asyncio.AbstractEventLoop] = None,
) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
"""Perform a full synchronous directory scan for recipes.
Args:
recipes_dir: Path to the recipes directory.
progress_loop: When set (manual force-refresh only), broadcast
scan_progress messages through this thread-local event loop.
Returns:
Tuple of (recipes list, json_paths dict).
@@ -2031,6 +1901,17 @@ class RecipeScanner:
if file.lower().endswith(".recipe.json"):
recipe_files.append(os.path.join(root, file))
total_files = len(recipe_files)
if progress_loop is not None:
progress_loop.run_until_complete(
self._broadcast_scan_progress(
'processing', 'count_models', 1, True,
processed=0, total=total_files,
)
)
last_progress_time = time.time()
# Process each recipe file
for i, recipe_path in enumerate(recipe_files):
recipe_data = self._load_recipe_file_sync(recipe_path)
@@ -2038,6 +1919,23 @@ class RecipeScanner:
recipe_id = str(recipe_data.get("id", ""))
recipes.append(recipe_data)
json_paths[recipe_id] = recipe_path
if progress_loop is not None and total_files > 0:
processed = i + 1
current_time = time.time()
# Throttle to one update per 0.5s; always send the final one.
if (
processed == total_files
or current_time - last_progress_time > 0.5
):
last_progress_time = current_time
progress_percent = min(99, int(1 + (processed / total_files) * 98))
progress_loop.run_until_complete(
self._broadcast_scan_progress(
'processing', 'process_models', progress_percent, True,
processed=processed, total=total_files,
current_name=os.path.basename(recipe_path),
)
)
# Periodically release GIL so the event loop thread can run
if i % 100 == 0:
time.sleep(0)
@@ -2607,11 +2505,14 @@ class RecipeScanner:
start_time = time.time()
# Run the heavy lifting in a thread pool same path
# used by initialize_in_background().
# used by initialize_in_background(). Pass
# report_progress=True so manual refreshes broadcast
# scan_progress updates; startup init keeps it off.
loop = asyncio.get_event_loop()
cache = await loop.run_in_executor(
None,
self._initialize_recipe_cache_sync,
True,
)
if cache is not None:
self._cache = cache
@@ -3530,11 +3431,23 @@ class RecipeScanner:
if filters:
# Filter by base model
if "base_model" in filters and filters["base_model"]:
filtered_data = [
item
for item in filtered_data
if item.get("base_model", "") in filters["base_model"]
]
base_model_filter = filters["base_model"]
if UNKNOWN_BASE_MODEL_FILTER in base_model_filter:
# The unknown bucket matches recipes whose base model
# could not be determined (None/empty); real base
# models in the list still match by exact name.
filtered_data = [
item
for item in filtered_data
if not item.get("base_model")
or item.get("base_model") in base_model_filter
]
else:
filtered_data = [
item
for item in filtered_data
if item.get("base_model", "") in base_model_filter
]
# Filter by favorite
if "favorite" in filters and filters["favorite"]:
+17
View File
@@ -368,6 +368,7 @@ class RecipeAnalysisService:
*,
file_path: str | None,
recipe_scanner,
ignore_recipe_metadata: bool = False,
) -> AnalysisResult:
"""Analyze a file already present on disk."""
@@ -389,6 +390,22 @@ class RecipeAnalysisService:
}
return result
if ignore_recipe_metadata:
# Re-import: re-parse the original embedded generation metadata
# instead of the recipe JSON block LoRA Manager appended on save.
from ...recipes.parsers.recipe_format import strip_recipe_metadata
metadata = strip_recipe_metadata(metadata)
if not metadata:
result = self._metadata_not_found_response(normalized_path)
result.payload["diagnostics"] = {
"channel": "local",
"exif_present": True,
"ignore_recipe_metadata": True,
"reason": "only_recipe_metadata",
}
return result
result = await self._parse_metadata(
metadata,
recipe_scanner=recipe_scanner,
+16 -3
View File
@@ -58,6 +58,7 @@ class RecipePersistenceService:
extension: str | None = None,
recipe_id: str | None = None,
target_dir: str | None = None,
skip_optimize: bool = False,
) -> PersistenceResult:
"""Persist a user uploaded recipe.
@@ -67,6 +68,11 @@ class RecipePersistenceService:
target_dir: If provided, save recipe files to this directory instead
of the default recipes_dir. Used by re-import to preserve the
original folder location.
skip_optimize: If True, store the image bytes verbatim without
resizing/re-encoding (recipe metadata is still embedded via a
byte-level EXIF update that leaves the pixels untouched). Used
by local re-import, where the source is the recipe's own
already-optimized preview image.
"""
missing_fields = []
@@ -87,9 +93,12 @@ class RecipePersistenceService:
recipe_id = recipe_id or str(uuid.uuid4())
# Handle video formats by bypassing optimization and metadata embedding
# Handle video formats by bypassing optimization and metadata embedding.
# Local re-import also bypasses optimization: the source is the
# recipe's own already-optimized preview image, so re-compressing it
# would only degrade quality.
is_video = extension in [".mp4", ".webm"]
if is_video:
if is_video or skip_optimize:
optimized_image = resolved_image_bytes
# extension is already set
else:
@@ -175,7 +184,11 @@ class RecipePersistenceService:
json.dump(recipe_data, file_obj, indent=4, ensure_ascii=False)
if not is_video:
self._exif_utils.append_recipe_metadata(normalized_image_path, recipe_data)
self._exif_utils.append_recipe_metadata(
normalized_image_path,
recipe_data,
pixel_preserving=skip_optimize,
)
matching_recipes = await self._find_matching_recipes(recipe_scanner, fingerprint, exclude_id=recipe_id)
await recipe_scanner.add_recipe(recipe_data)
+1
View File
@@ -116,6 +116,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
"sticky_controls": False,
# AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "",
-26
View File
@@ -20,8 +20,6 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking
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
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock()
@@ -193,14 +191,6 @@ class WebSocketManager:
# Broadcast via WebSocket
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]]:
"""Get current auto-organize progress"""
return self._auto_organize_progress
@@ -209,22 +199,6 @@ class WebSocketManager:
"""Clear auto-organize progress data"""
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]):
"""Broadcast recipe rematch progress to connected clients"""
# Store progress data in memory
+26 -5
View File
@@ -1,3 +1,5 @@
from typing import Any
NSFW_LEVELS = {
"PG": 1,
"PG13": 2,
@@ -99,11 +101,30 @@ DEFAULT_HASH_CHUNK_SIZE_MB = 4
# absurd 64-bit header length from forcing a multi-GB allocation during scan.
MAX_SAFETENSORS_HEADER_BYTES = 64 * 1024 * 1024
# First 12 chars of the SHA256 of an empty byte string. Some (re-packaging)
# training tools write this placeholder into safetensors metadata instead of a
# real hash; it must never be treated as a valid AutoV3 — several broken
# models sharing it would collide in the hash index and falsely match recipes.
INVALID_AUTOV3_EMPTY_HASH = "e3b0c44298fc"
# SHA256 of an empty byte string. Some (re-packaging) training tools write a
# truncated form of this placeholder into safetensors metadata (as
# ``modelspec.hash_sha256`` / ``sshs_model_hash``), and hashing an empty or
# unreadable file produces it directly. It must never be treated as a valid
# hash: several broken models share it, CivitAI's by-hash index can contain
# such polluted entries, and matching it falsely attributes recipes.
EMPTY_HASH_SHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
INVALID_AUTOV3_EMPTY_HASH = EMPTY_HASH_SHA256[:12]
INVALID_AUTOV2_EMPTY_HASH = EMPTY_HASH_SHA256[:10]
def is_empty_placeholder_hash(value: Any) -> bool:
"""True for a 10/12/64-hex-char spelling of the empty-hash placeholder.
These are the AutoV2, AutoV3 and full-SHA256 forms of the placeholder;
such values identify no real model and must never be resolved against
local files or CivitAI.
"""
if not isinstance(value, str):
return False
v = value.strip().lower()
if len(v) not in (10, 12, 64):
return False
return v == EMPTY_HASH_SHA256[: len(v)]
# Auto-organize settings
AUTO_ORGANIZE_BATCH_SIZE = (
+67 -3
View File
@@ -348,8 +348,14 @@ class ExifUtils:
return image_path
@staticmethod
def append_recipe_metadata(image_path, recipe_data) -> str:
"""Append recipe metadata to an image's EXIF data"""
def append_recipe_metadata(image_path, recipe_data, pixel_preserving=False) -> str:
"""Append recipe metadata to an image's EXIF data
When ``pixel_preserving`` is True (and the image is a WebP) only the
EXIF container is rewritten at the byte level, so the preview pixels
are never re-encoded. Local re-import uses this because its source is
the recipe's own already-optimized preview image.
"""
try:
if image_path:
ext = os.path.splitext(image_path)[1].lower()
@@ -417,13 +423,71 @@ class ExifUtils:
# Append to existing metadata or create new one
new_metadata = f"{metadata} \n {recipe_metadata_marker}" if metadata else recipe_metadata_marker
# Write back to the image. Re-import keeps the already-optimized
# preview pixels untouched and updates only the WebP EXIF chunk
# instead of re-encoding the whole image.
if pixel_preserving and image_path.lower().endswith(".webp"):
metadata_fields = ExifUtils._load_structured_metadata(image_path)
metadata_fields["parameters"] = new_metadata
exif_bytes = ExifUtils._build_exif_bytes(metadata_fields)
with open(image_path, "rb") as file_obj:
image_bytes = file_obj.read()
try:
updated = ExifUtils._replace_webp_exif(image_bytes, exif_bytes)
except ValueError:
# Container without an EXIF chunk; fall back to re-encoding.
return ExifUtils.update_image_metadata(image_path, new_metadata)
with open(image_path, "wb") as file_obj:
file_obj.write(updated)
return image_path
# Write back to the image
return ExifUtils.update_image_metadata(image_path, new_metadata)
except Exception as e:
logger.error(f"Error appending recipe metadata: {e}", exc_info=True)
return image_path
@staticmethod
def _replace_webp_exif(image_bytes: bytes, exif_bytes: bytes) -> bytes:
"""Replace the EXIF chunk of a WebP file without re-encoding pixels."""
if image_bytes[:4] != b"RIFF" or image_bytes[8:12] != b"WEBP":
raise ValueError("Not a WebP file")
# The WebP EXIF chunk stores raw TIFF data; strip the JPEG-style
# "Exif\\0\\0" prefix that piexif.dump may prepend.
tiff = exif_bytes[6:] if exif_bytes[:6] == b"Exif\x00\x00" else exif_bytes
out = bytearray(image_bytes[:12])
pos = 12
exif_payload = None
while pos + 8 <= len(image_bytes):
fourcc = image_bytes[pos : pos + 4]
size = struct.unpack("<I", image_bytes[pos + 4 : pos + 8])[0]
chunk_data = image_bytes[pos + 8 : pos + 8 + size]
pad = size % 2
if fourcc == b"EXIF":
exif_payload = tiff
else:
out += (
fourcc
+ struct.pack("<I", size)
+ chunk_data
+ (b"\x00" * pad)
)
pos += 8 + size + pad
if exif_payload is None:
raise ValueError("WebP has no EXIF chunk")
out += (
b"EXIF"
+ struct.pack("<I", len(exif_payload))
+ exif_payload
+ (b"\x00" * (len(exif_payload) % 2))
)
out[4:8] = struct.pack("<I", len(out) - 8)
return bytes(out)
@staticmethod
def remove_recipe_metadata(user_comment):
"""Remove recipe metadata from user comment"""
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.1"
version = "1.2.2"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
+8 -3
View File
@@ -31,9 +31,14 @@ body {
--header-height: 48px;
--scrollbar-width: 8px;
--shortcut-bg: var(--color-accent-subtle);
--shortcut-border: var(--color-accent-border);
--shortcut-text: var(--text-primary);
/* Neutral "keycap" style for keyboard shortcut hints (GitHub/Linear-like).
Derived from --text-muted so it adapts to every theme/preset. */
--shortcut-bg: color-mix(in oklch, var(--text-muted) 10%, transparent);
--shortcut-bg-hover: color-mix(in oklch, var(--text-muted) 16%, transparent);
--shortcut-border: color-mix(in oklch, var(--text-muted) 30%, transparent);
--shortcut-border-hover: color-mix(in oklch, var(--text-muted) 45%, transparent);
--shortcut-text: var(--text-muted);
--shortcut-shadow: 0 1.5px 0 color-mix(in oklch, var(--text-muted) 30%, transparent);
--lora-accent-transparent: var(--color-accent-transparent);
+4 -4
View File
@@ -249,10 +249,10 @@
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--text-muted);
/* Subtle tint derived from text color so it adapts to both light & dark themes */
background: color-mix(in oklch, var(--text-muted) 12%, transparent);
border: 1px solid color-mix(in oklch, var(--text-muted) 25%, transparent);
color: var(--shortcut-text);
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
@@ -65,4 +65,13 @@
.add-preset-btn:hover {
opacity: 0.9;
}
.add-preset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.add-preset-btn:hover:disabled {
opacity: 0.5;
}
@@ -115,6 +115,9 @@
position: relative;
overflow: hidden;
border-radius: var(--border-radius-sm);
/* Horizontal touch pans are claimed for swipe navigation (ShowcaseView);
vertical pans still scroll the modal */
touch-action: pan-y;
}
.main-media-container {
@@ -134,6 +137,32 @@
margin-bottom: 0;
}
/* Direction-aware slide on example switches (set by updateMainDisplay) */
.main-media-container.slide-from-right .media-wrapper {
animation: gallery-slide-from-right 0.25s ease;
}
.main-media-container.slide-from-left .media-wrapper {
animation: gallery-slide-from-left 0.25s ease;
}
@keyframes gallery-slide-from-right {
from { transform: translateX(32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes gallery-slide-from-left {
from { transform: translateX(-32px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
.main-media-container.slide-from-right .media-wrapper,
.main-media-container.slide-from-left .media-wrapper {
animation: none;
}
}
.main-media-container .media-wrapper img,
.main-media-container .media-wrapper video {
position: absolute;
+10
View File
@@ -13,6 +13,16 @@
overflow: auto; /* Change from hidden to auto to allow scrolling */
}
/* Software-rendering fallback (set by applyModalBackdropBlurPolicy): a
full-viewport backdrop-filter forces per-frame CPU rasterization of
everything behind the modal and freezes the browser (issue #1092) */
html.no-modal-backdrop-blur .modal,
html.no-modal-backdrop-blur .delete-modal,
html.no-modal-backdrop-blur .batch-preview-select-all {
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
/* Prevent body scroll when modal is open */
body.modal-open {
position: fixed;
@@ -921,8 +921,8 @@
position: sticky;
top: 0;
z-index: 1;
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
}
.batch-preview-select-all input[type="checkbox"] {
+107 -1
View File
@@ -167,6 +167,29 @@
box-shadow: var(--shadow-lg);
}
/* Replay Tutorial button: badge hidden until the button is flagged as new content */
.replay-tutorial-btn .new-content-badge {
display: none;
background-color: rgba(255, 255, 255, 0.22);
color: #fff;
box-shadow: none;
margin-left: 2px;
}
.replay-tutorial-btn.has-new-content .new-content-badge {
display: inline-flex;
}
/* One-time attention pulse when the button is flagged as new content */
@keyframes new-content-glow {
0% { box-shadow: 0 0 0 0 oklch(from var(--lora-accent) l c h / 55%); }
100% { box-shadow: 0 0 0 16px transparent; }
}
.replay-tutorial-btn.has-new-content {
animation: new-content-glow 1.2s ease-out 3;
}
/* Update video list styles */
.video-list {
display: flex;
@@ -304,4 +327,87 @@
/* Dark theme adjustments */
[data-theme="dark"] .video-container {
background-color: var(--surface-hover);
}
}
/* Replay tutorial button styles */
.help-actions {
margin-top: var(--space-3);
}
.replay-tutorial-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--border-radius-sm);
font-weight: 500;
cursor: pointer;
transition: var(--transition-base);
background-color: var(--lora-accent);
color: white;
border: none;
}
.replay-tutorial-btn:hover {
background-color: oklch(from var(--lora-accent) l c h / 85%);
}
/* Shortcuts tab styles */
.shortcuts-section {
margin-bottom: var(--space-3);
}
.shortcuts-section h4 {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: var(--space-1);
}
.shortcuts-list {
list-style-type: none;
padding-left: var(--space-3);
}
.shortcuts-list li {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: var(--space-1);
}
.shortcut-keys {
display: inline-flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
min-width: 150px;
}
.shortcut-sep {
color: var(--text-muted);
font-size: 0.75rem;
margin: 0 1px;
}
.shortcuts-list kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 4px;
font-family: inherit;
font-size: 0.68rem;
font-weight: 500;
color: var(--shortcut-text);
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs, 3px);
line-height: 1;
}
.shortcut-description {
font-size: 0.9em;
opacity: 0.85;
}
+90
View File
@@ -1547,3 +1547,93 @@
width: 20px;
height: calc(1em * 1.3);
}
/* Meta footer: de-emphasized location + recipe ID line below the modal body,
mirroring the hash footnote in the shared model modal. Location sits left
(tail of the path survives truncation), ID + copy button sit right. */
.recipe-meta-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-top: 6px;
margin-top: 8px;
border-top: 1px solid var(--border-color);
font-size: 0.75em;
color: var(--text-muted);
}
.recipe-meta-footer[hidden] {
display: none;
}
.recipe-meta-location {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: pointer;
}
.recipe-meta-location i {
flex-shrink: 0;
opacity: 0.6;
}
.recipe-meta-location-path {
font-family: var(--font-mono, monospace);
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.recipe-meta-location:hover .recipe-meta-location-path,
.recipe-meta-location:focus-visible .recipe-meta-location-path {
opacity: 1;
text-decoration: underline;
}
.recipe-meta-location:focus-visible {
outline: 1px solid var(--lora-accent);
outline-offset: 2px;
border-radius: var(--border-radius-xs);
}
.recipe-meta-id {
display: inline-flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.recipe-meta-id-label {
font-size: 0.9em;
opacity: 0.5;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.recipe-meta-id-value {
font-family: var(--font-mono, monospace);
opacity: 0.7;
white-space: nowrap;
}
.recipe-meta-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 2px;
border: none;
background: none;
color: var(--text-muted);
opacity: 0.35;
font-size: 0.95em;
cursor: pointer;
flex-shrink: 0;
}
.recipe-meta-copy-btn:hover {
opacity: 0.9;
}
+1 -1
View File
@@ -174,7 +174,7 @@
z-index: var(--z-toast);
display: flex;
flex-direction: column;
align-items: flex-end;
/* No align-items (defaults to stretch) so every toast shares one equal width */
gap: 10px;
padding: 8px 20px 0; /* Small breathing room below the header */
pointer-events: none; /* Allow clicking through the container */
+32 -9
View File
@@ -25,6 +25,23 @@
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 */
@media (min-width: 2150px) {
.container {
@@ -201,36 +218,42 @@
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
min-width: 16px;
height: 16px;
padding: 0 3px;
font-size: 11px;
font-weight: 600;
padding: 0 4px;
font-size: 10px;
font-weight: 500;
line-height: 1;
text-transform: uppercase;
border-radius: var(--border-radius-xs);
background-color: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
color: var(--shortcut-text);
vertical-align: middle;
opacity: 0.8;
transition: var(--transition-base);
}
.control-group button:hover .shortcut-key {
opacity: 1;
background-color: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.2);
background-color: var(--shortcut-bg-hover);
border-color: var(--shortcut-border-hover);
}
[data-theme="dark"] .shortcut-key {
--shortcut-bg: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.15);
--shortcut-border: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.3);
/* 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 */
.control-group button span {
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Select dropdown styling */
+1
View File
@@ -205,6 +205,7 @@
display: inline-block;
background: var(--shortcut-bg);
border: 1px solid var(--shortcut-border);
box-shadow: var(--shortcut-shadow);
border-radius: var(--border-radius-xs);
padding: 2px 6px;
font-size: 0.8em;
+108 -7
View File
@@ -12,6 +12,11 @@ import {
} from './apiConfig.js';
import { resetAndReload } from './modelApiFactory.js';
import { sidebarManager } from '../components/SidebarManager.js';
// Shared scan ETA helpers live in a dependency-light module so pages that do
// not use BaseModelApiClient (e.g. recipes) can reuse them without pulling
// this module's import cycle (modelApiFactory -> loraApi -> baseModelApi).
import { createScanEtaTracker, formatScanRemainingTime } from '../utils/scanEtaUtils.js';
export { createScanEtaTracker, formatScanRemainingTime };
/**
* Abstract base class for all model API clients
@@ -507,23 +512,67 @@ export class BaseModelApiClient {
async refreshModels(fullRebuild = false) {
const abortController = new AbortController();
try {
state.loadingManager.show(
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${this.apiConfig.config.displayName}s...`,
0
const displayName = this.apiConfig.config.displayName;
const singularName = this.apiConfig.config.singularName;
const actionText = translate(
fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
{},
fullRebuild ? 'Full rebuild' : 'Refresh'
);
const actionLowerText = translate(
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
{},
fullRebuild ? 'rebuild' : 'refresh'
);
const initialMessage = translate(
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
{ type: displayName },
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} ${displayName}s...`
);
const etaTracker = createScanEtaTracker();
let ws = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.total > 0) {
statusText += ` (${data.processed}/${data.total})`;
if (data.current_name) {
statusText += ` ${data.current_name}`;
}
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
state.loadingManager.setStatus(statusText);
};
try {
state.loadingManager.show(initialMessage, 0);
state.loadingManager.showCancelButton(() => {
this.cancelTask();
abortController.abort();
});
// Connect to the shared progress channel for live scan updates.
// Failure to connect must not block the refresh itself — fall back
// to the plain loading indicator.
ws = await this._connectScanProgressSocket(handleScanProgress, singularName);
const url = new URL(this.apiConfig.endpoints.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`Failed to refresh ${this.apiConfig.config.displayName}s: ${response.status} ${response.statusText}`);
throw new Error(`Failed to refresh ${displayName}s: ${response.status} ${response.statusText}`);
}
const data = await response.json();
@@ -534,20 +583,69 @@ export class BaseModelApiClient {
resetAndReload(true);
showToast('toast.api.refreshComplete', { action: fullRebuild ? 'Full rebuild' : 'Refresh' }, 'success');
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
} catch (error) {
if (error.name === 'AbortError') {
showToast('toast.api.operationCancelled', {}, 'info');
return;
}
console.error('Refresh failed:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: this.apiConfig.config.displayName }, 'error');
showToast('toast.api.refreshFailed', { action: actionLowerText, type: displayName }, 'error');
} finally {
if (ws) {
ws.close();
}
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
/**
* Connect to the shared fetch-progress WebSocket for scan progress updates.
* Returns null when the connection cannot be established (silent fallback).
* @param {Function} onScanProgress - Handler for scan_progress messages
* @param {string} singularName - Model type filter (e.g. 'lora')
* @returns {Promise<WebSocket|null>}
*/
async _connectScanProgressSocket(onScanProgress, singularName) {
let socket = null;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
socket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (parseError) {
return;
}
// Only handle scan progress for this client's model type;
// other operations share this channel and must be ignored.
if (data.type !== 'scan_progress' || data.model_type !== singularName) {
return;
}
onScanProgress(data);
};
return socket;
} catch (error) {
if (socket) {
try {
socket.close();
} catch (closeError) {
// Ignore close errors during fallback
}
}
return null;
}
}
async refreshSingleModelMetadata(filePath) {
try {
state.loadingManager.showSimpleLoading('Refreshing metadata...');
@@ -605,6 +703,9 @@ export class BaseModelApiClient {
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
// Scan progress shares this channel; it is handled by refreshModels
if (data.type === 'scan_progress') return;
switch (data.status) {
case 'started':
loading.setStatus('Starting metadata fetch...');
+100 -38
View File
@@ -1,7 +1,12 @@
import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { captureScrollPosition, restoreScrollPosition } from '../utils/infiniteScroll.js';
import { WS_ENDPOINTS } from './apiConfig.js';
// Import from the dependency-light utils module, not baseModelApi.js, to
// avoid the baseModelApi <-> modelApiFactory import cycle on this page.
import { createScanEtaTracker } from '../utils/scanEtaUtils.js';
const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes',
@@ -15,7 +20,6 @@ const RECIPE_ENDPOINTS = {
move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete',
repairBulk: '/api/lm/recipes/repair-bulk',
rematchBulk: '/api/lm/recipes/rematch-bulk',
rematchSingle: '/api/lm/recipe/{recipe_id}/rematch',
};
@@ -333,11 +337,53 @@ export async function syncChanges() {
}
export async function refreshRecipes(fullRebuild = true) {
const actionLabel = fullRebuild ? 'Rebuilding recipe cache' : 'Refreshing recipes';
const actionToast = fullRebuild ? 'Full rebuild' : 'Refresh';
const actionText = translate(
fullRebuild ? 'common.scanProgress.actionFullRebuild' : 'common.scanProgress.actionRefresh',
{},
fullRebuild ? 'Full rebuild' : 'Refresh'
);
const actionLowerText = translate(
fullRebuild ? 'common.scanProgress.actionRebuildLower' : 'common.scanProgress.actionRefreshLower',
{},
fullRebuild ? 'rebuild' : 'refresh'
);
const initialMessage = translate(
fullRebuild ? 'common.scanProgress.fullRebuilding' : 'common.scanProgress.refreshing',
{ type: RECIPE_SIDEBAR_CONFIG.config.displayName },
`${fullRebuild ? 'Full rebuild' : 'Refreshing'} Recipes...`
);
const etaTracker = createScanEtaTracker();
let ws = null;
const handleScanProgress = (data) => {
if (typeof data.progress === 'number') {
state.loadingManager.setProgress(data.progress);
}
let statusText = translate(
`common.scanProgress.stages.${data.stage}`,
{ total: data.total },
data.stage || ''
);
if (data.status === 'processing' && data.total > 0) {
statusText += ` (${data.processed}/${data.total})`;
if (data.current_name) {
statusText += ` ${data.current_name}`;
}
const etaText = etaTracker.update(data.processed, data.total);
if (etaText) {
statusText += ` | ${etaText}`;
}
}
state.loadingManager.setStatus(statusText);
};
try {
state.loadingManager.show(`${actionLabel}...`, 0);
state.loadingManager.show(initialMessage, 0);
// Connect to the shared progress channel for live scan updates.
// Failure to connect must not block the refresh itself — fall back
// to the plain loading indicator.
ws = await connectScanProgressSocket(handleScanProgress);
const url = new URL(RECIPE_ENDPOINTS.scan, window.location.origin);
url.searchParams.append('full_rebuild', fullRebuild);
@@ -356,16 +402,64 @@ export async function refreshRecipes(fullRebuild = true) {
await resetAndReload(false);
showToast('toast.api.refreshComplete', { action: actionToast }, 'success');
showToast('toast.api.refreshComplete', { action: actionText }, 'success');
} catch (error) {
console.error('Error refreshing recipes:', error);
showToast('toast.api.refreshFailed', { action: fullRebuild ? 'rebuild' : 'refresh', type: 'recipe' }, 'error');
showToast('toast.api.refreshFailed', { action: actionLowerText, type: 'recipe' }, 'error');
} finally {
if (ws) {
ws.close();
}
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
/**
* Connect to the shared fetch-progress WebSocket for recipe scan progress.
* Returns null when the connection cannot be established (silent fallback).
* @param {Function} onScanProgress - Handler for scan_progress messages
* @returns {Promise<WebSocket|null>}
*/
async function connectScanProgressSocket(onScanProgress) {
let socket = null;
try {
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
socket = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = reject;
});
socket.onmessage = (event) => {
let data;
try {
data = JSON.parse(event.data);
} catch (parseError) {
return;
}
// Only handle recipe scan progress; other operations share this
// channel and must be ignored.
if (data.type !== 'scan_progress' || data.model_type !== 'recipe') {
return;
}
onScanProgress(data);
};
return socket;
} catch (error) {
if (socket) {
try {
socket.close();
} catch (closeError) {
// Ignore close errors during fallback
}
}
return null;
}
}
/**
* Load more recipes with pagination - updated to work with VirtualScroller
* @param {boolean} resetPage - Whether to reset to the first page
@@ -583,38 +677,6 @@ export class RecipeSidebarApiClient {
};
}
async repairBulkModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
}
const response = await fetch(this.apiConfig.endpoints.repairBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to repair recipes');
}
return result;
}
async rematchBulkModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
+4
View File
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
import { createPageControls } from './components/controls/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the Checkpoints page
export class CheckpointsPageManager {
@@ -32,6 +33,9 @@ export class CheckpointsPageManager {
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync(MODEL_TYPES.CHECKPOINT);
console.log('Checkpoints Manager initialized');
}
}
@@ -41,13 +41,9 @@ export class BulkContextMenu extends BaseContextMenu {
const autoOrganizeItem = this.menu.querySelector('[data-action="auto-organize"]');
const deleteAllItem = this.menu.querySelector('[data-action="delete-all"]');
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 rematchMetadataItem = this.menu.querySelector('[data-action="rematch-metadata"]');
if (repairMetadataItem) {
repairMetadataItem.style.display = config.repairMetadata ? 'flex' : 'none';
}
if (reimportMetadataItem) {
reimportMetadataItem.style.display = config.reimportMetadata ? 'flex' : 'none';
}
@@ -283,9 +279,6 @@ export class BulkContextMenu extends BaseContextMenu {
case 'delete-all':
bulkManager.showBulkDeleteModal();
break;
case 'repair-metadata':
bulkManager.repairSelectedRecipes();
break;
case 'rematch-metadata':
bulkManager.rematchSelectedRecipes();
break;
@@ -23,7 +23,6 @@ export class GlobalContextMenu extends BaseContextMenu {
const downloadExamplesItem = this.menu.querySelector('[data-action="download-example-images"]');
const cleanupExamplesItem = this.menu.querySelector('[data-action="cleanup-example-images-folders"]');
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 groupByModelItem = this.menu.querySelector('[data-action="toggle-group-by-model"]');
const groupByModelCheck = groupByModelItem?.querySelector('.check-indicator');
@@ -41,7 +40,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.add('hidden');
excludedModelsItem?.classList.add('hidden');
groupByModelItem?.classList.add('hidden');
repairRecipesItem?.classList.remove('hidden');
rematchRecipesItem?.classList.remove('hidden');
} else {
modelUpdateItem?.classList.remove('hidden');
@@ -50,7 +48,6 @@ export class GlobalContextMenu extends BaseContextMenu {
cleanupExamplesItem?.classList.remove('hidden');
excludedModelsItem?.classList.remove('hidden');
groupByModelItem?.classList.remove('hidden');
repairRecipesItem?.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);
});
break;
case 'repair-recipes':
this.repairRecipes(menuItem).catch((error) => {
console.error('Failed to repair recipes:', error);
});
break;
case 'rematch-recipes':
this.rematchRecipes(menuItem).catch((error) => {
console.error('Failed to rematch recipes:', error);
@@ -371,99 +363,6 @@ export class GlobalContextMenu extends BaseContextMenu {
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) {
if (this._rematchInProgress) {
return;
@@ -6,6 +6,7 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu {
constructor() {
@@ -93,10 +94,6 @@ export class RecipeContextMenu extends BaseContextMenu {
// Download missing LoRAs
this.downloadMissingLoRAs(recipeId);
break;
case 'repair':
// Repair recipe metadata
this.repairRecipe(recipeId);
break;
case 'rematch':
// Rematch recipe resources to local models
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) {
if (!recipeId) {
showToast('toast.recipes.rematchFailed', { message: 'Missing recipe ID' }, 'error');
@@ -397,6 +356,24 @@ export class RecipeContextMenu extends BaseContextMenu {
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...');
try {
@@ -419,6 +396,34 @@ export class RecipeContextMenu extends BaseContextMenu {
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
+229 -29
View File
@@ -10,7 +10,7 @@ import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { renderCompactTags, setupTagTooltip, escapeAttribute } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js';
import { Combobox } from './Combobox.js';
@@ -66,6 +66,39 @@ function escapeHtml(value) {
.replace(/'/g, '&#39;');
}
/**
* Call backend to open the recipe's file location and select the file.
* Mirrors the model modal's openFileLocation, including the Docker
* clipboard fallback.
* @param {string} filePath
*/
async function openRecipeFileLocation(filePath) {
try {
const resp = await fetch('/api/lm/open-file-location', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 'file_path': filePath })
});
if (!resp.ok) throw new Error('Failed to open file location');
const data = await resp.json();
if (data.mode === 'clipboard' && data.path) {
try {
await navigator.clipboard.writeText(data.path);
showToast('recipes.modal.openFileLocation.copied', { path: data.path }, 'success');
} catch (clipboardErr) {
console.warn('Clipboard API not available:', clipboardErr);
showToast('recipes.modal.openFileLocation.clipboardFallback', { path: data.path }, 'info');
}
} else {
showToast('recipes.modal.openFileLocation.success', {}, 'success');
}
} catch (err) {
showToast('recipes.modal.openFileLocation.failed', {}, 'error');
}
}
// Fallback English strings for the collapsed "Why no LoRAs?" panel.
// Translations live in locales/*.json under recipes.resources.
const NO_LORAS_REASON_FALLBACKS = {
@@ -95,6 +128,9 @@ class RecipeModal {
this.recipeHydrationRequestId = 0;
this.navigationKeyHandler = null;
this.navigationInProgress = false;
this._disposed = false;
this._deferredTimerIds = new Set();
this._documentClickHandler = null;
this.resetLocalEditState();
this.init();
}
@@ -164,7 +200,7 @@ class RecipeModal {
this.setupDeleteControl();
// Set up document click handler to close edit fields
document.addEventListener('click', (event) => {
this._documentClickHandler = (event) => {
const recipeModal = document.getElementById('recipeModal');
if (recipeModal && recipeModal.style.display !== 'none') {
const mediaEl = event.target.closest('.recipe-preview-media');
@@ -202,7 +238,8 @@ class RecipeModal {
this.hideReconnectInput(container);
}
});
});
};
document.addEventListener('click', this._documentClickHandler);
}
setupNavigationControls() {
@@ -266,6 +303,39 @@ class RecipeModal {
this._destroyAllReconnectComboboxes();
}
/**
* Run a callback on a tracked timer so it can be cancelled when the
* modal is disposed. Used for render/wiring work that must never touch
* the DOM after the modal has been torn down (test teardown, close).
*/
_scheduleDeferred(fn, delay) {
const timerId = setTimeout(() => {
this._deferredTimerIds.delete(timerId);
fn();
}, delay);
this._deferredTimerIds.add(timerId);
return timerId;
}
/**
* Tear the modal down: mark it disposed (in-flight async chains and any
* later render/wiring become no-ops), cancel pending deferred work, and
* detach global listeners. Safe to call multiple times.
*/
dispose() {
if (this._disposed) {
return;
}
this._disposed = true;
this._deferredTimerIds.forEach(timerId => clearTimeout(timerId));
this._deferredTimerIds.clear();
if (this._documentClickHandler) {
document.removeEventListener('click', this._documentClickHandler);
this._documentClickHandler = null;
}
this.cleanupNavigationShortcuts();
}
setupNavigationShortcuts() {
const modalElement = document.getElementById('recipeModal');
if (!modalElement) return;
@@ -320,6 +390,9 @@ class RecipeModal {
}
showRecipeDetails(recipe) {
if (this._disposed) {
return;
}
const hydratedRecipe = recipe || {};
this.resetLocalEditState();
// Store the full recipe for editing
@@ -410,7 +483,7 @@ class RecipeModal {
// Delay binding slightly so modal layout is stable, but skip if this render was torn down.
const sourceUrlContainerRef = sourceUrlContainer;
const sourceUrlEditorRef = sourceUrlEditor;
setTimeout(() => {
this._scheduleDeferred(() => {
if (!document.body.contains(sourceUrlContainerRef) || !document.body.contains(sourceUrlEditorRef)) {
return;
}
@@ -421,6 +494,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
this.syncHeaderActions();
this.syncMetaFooter();
// Show the modal
modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts());
@@ -446,10 +520,83 @@ class RecipeModal {
}
}
/**
* Render the meta footer: clickable file location (opens the recipe JSON
* in the OS file manager) plus the truncated recipe ID with copy button.
* De-emphasized by design, mirroring the model modal's hash footnote.
*/
syncMetaFooter() {
const footer = document.getElementById('recipeMetaFooter');
if (!footer) {
return;
}
const recipeId = this.currentRecipe?.id || '';
const filePath = this.currentRecipe?.file_path || '';
const openTarget = this.currentRecipe?.recipe_json_path || filePath;
const folderPath = filePath.replace(/[^/\\]+$/, '');
if (!recipeId && !folderPath) {
footer.hidden = true;
footer.innerHTML = '';
return;
}
const truncatedId = recipeId.length > 14
? `${recipeId.slice(0, 8)}${recipeId.slice(-4)}`
: recipeId;
const openLocationLabel = translate('recipes.modal.actions.openFileLocation', {}, 'Open File Location');
const copyIdLabel = translate('recipes.modal.actions.copyId', {}, 'Copy recipe ID');
const locationMarkup = folderPath ? `
<span class="recipe-meta-location" role="button" tabindex="0"
title="${escapeAttribute(folderPath)}"
aria-label="${escapeAttribute(openLocationLabel)}"
data-filepath="${escapeAttribute(openTarget)}">
<i class="fas fa-folder-open" aria-hidden="true"></i>
<span class="recipe-meta-location-path">${escapeHtml(folderPath)}</span>
</span>` : '';
const idMarkup = recipeId ? `
<span class="recipe-meta-id">
<span class="recipe-meta-id-label">${translate('recipes.modal.metadata.id', {}, 'ID')}</span>
<span class="recipe-meta-id-value" title="${escapeAttribute(recipeId)}">${escapeHtml(truncatedId)}</span>
<button class="recipe-meta-copy-btn" title="${escapeAttribute(copyIdLabel)}" aria-label="${escapeAttribute(copyIdLabel)}">
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</span>` : '';
footer.innerHTML = locationMarkup + idMarkup;
footer.hidden = false;
const locationEl = footer.querySelector('.recipe-meta-location');
if (locationEl) {
const openLocation = () => {
if (locationEl.dataset.filepath) {
openRecipeFileLocation(locationEl.dataset.filepath);
}
};
locationEl.addEventListener('click', openLocation);
locationEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
openLocation();
}
});
}
const copyBtn = footer.querySelector('.recipe-meta-copy-btn');
if (copyBtn && recipeId) {
copyBtn.addEventListener('click', () => {
copyToClipboard(recipeId);
});
}
}
async hydrateRecipeDetails(recipeId, requestId, requestEditVersions = {}) {
try {
const fullRecipe = await fetchRecipeDetails(recipeId);
if (requestId !== this.recipeHydrationRequestId || !fullRecipe) {
if (this._disposed || requestId !== this.recipeHydrationRequestId || !fullRecipe) {
return;
}
@@ -471,6 +618,9 @@ class RecipeModal {
if (fullRecipe.file_path !== undefined) {
nextRecipe.file_path = fullRecipe.file_path;
}
if (fullRecipe.recipe_json_path !== undefined) {
nextRecipe.recipe_json_path = fullRecipe.recipe_json_path;
}
if (fullRecipe.file_url !== undefined) {
nextRecipe.file_url = fullRecipe.file_url;
}
@@ -568,6 +718,7 @@ class RecipeModal {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
this.syncHeaderActions();
this.syncMetaFooter();
}
getPreviewMediaUrl(recipe = {}) {
@@ -841,6 +992,9 @@ class RecipeModal {
}
syncResourcesSection(recipe = {}) {
if (this._disposed) {
return;
}
const checkpointContainer = document.getElementById('recipeCheckpoint');
const resourceDivider = document.getElementById('recipeResourceDivider');
const lorasListElement = document.getElementById('recipeLorasList');
@@ -905,7 +1059,7 @@ class RecipeModal {
missingStatus.addEventListener('click', () => this.showDownloadMissingLorasModal());
}
setTimeout(() => {
this._scheduleDeferred(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
if (viewRecipeLorasBtn) {
viewRecipeLorasBtn.addEventListener('click', () => this.navigateToLorasPage());
@@ -1046,7 +1200,7 @@ class RecipeModal {
`;
}).join('');
setTimeout(() => {
this._scheduleDeferred(() => {
this.setupReconnectButtons();
this.setupLoraItemActions();
this.setupLoraItemsClickable();
@@ -2002,13 +2156,13 @@ class RecipeModal {
try {
const suggestions = await this._fetchReconnectSuggestions(loraIndex);
// Stale guard: panel closed or another item opened while fetching
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return;
}
this._renderReconnectSuggestions(container, suggestions, loraIndex);
} catch (error) {
console.error('Error fetching reconnect suggestions:', error);
if (token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
if (this._disposed || token !== this._reconnectSuggestionsToken || !container.classList.contains('active')) {
return;
}
this._renderReconnectSuggestions(container, [], loraIndex);
@@ -2120,6 +2274,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Hide the reconnect input
@@ -2145,7 +2302,7 @@ class RecipeModal {
}
// Refresh modal to show updated content
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2179,6 +2336,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Swap the entry back to its pre-reconnect state
@@ -2186,7 +2346,7 @@ class RecipeModal {
showToast('toast.recipes.loraRestored', {}, 'success');
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2231,6 +2391,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Hide the reconnect input
@@ -2256,7 +2419,7 @@ class RecipeModal {
}
// Refresh modal to show updated content
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2289,6 +2452,9 @@ class RecipeModal {
});
const result = await response.json();
if (this._disposed) {
return;
}
if (result.success) {
// Swap the entry back to its pre-reconnect state
@@ -2296,7 +2462,7 @@ class RecipeModal {
showToast('toast.recipes.checkpointRestored', {}, 'success');
setTimeout(() => {
this._scheduleDeferred(() => {
this.showRecipeDetails(this.currentRecipe);
}, 500);
@@ -2331,6 +2497,9 @@ class RecipeModal {
recipe_id: recipeId,
}),
});
if (this._disposed) {
return;
}
if (this.currentRecipe?.checkpoint) {
this.currentRecipe.checkpoint.hashInvalid = true;
this.syncResourcesSection(this.currentRecipe);
@@ -2708,12 +2877,14 @@ class RecipeModal {
canDownloadLora(lora) {
if (!lora) return false;
const modelId = lora.modelId || lora.modelID || lora.model_id;
const versionId = lora.id || lora.modelVersionId;
// Direct download needs both identifiers; a hash alone is enough
// because downloadRecipeLora resolves it to a version on demand —
// the same fallback the bulk "download missing" flow uses.
return !!((modelId && versionId) || lora.hash);
// A bare CivitAI version id is enough: it uniquely pins the exact
// file, and downloadRecipeLora resolves the owning model id from the
// version on demand (the same fallback the bulk "download missing"
// 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) {
@@ -2822,6 +2993,9 @@ class RecipeModal {
* Resolve the Civitai model/version identifiers needed for download.
* Recipe LoRAs parsed from PNG metadata often carry only a hash; resolve
* 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) {
let modelId = lora.modelId || lora.modelID || lora.model_id;
@@ -2832,21 +3006,41 @@ class RecipeModal {
return { modelId, versionId, versionName };
}
if (!lora.hash) {
return null;
// Hash-only entries (PNG/recipe-JSON imports): resolve the owning
// 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}`);
const versionInfo = await response.json();
if (versionInfo?.error) {
return null;
// Version-only entries (page-imported recipes whose CivitAI versions
// expose no sha256): the version id still pins the exact file, so
// resolve the owning model id from the version endpoint on demand.
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;
versionId = versionInfo.id;
versionName = versionInfo.name || versionName;
return modelId && versionId ? { modelId, versionId, versionName } : null;
return null;
}
/**
@@ -2864,6 +3058,9 @@ class RecipeModal {
return;
}
const updated = await fetchRecipeDetails(recipeId);
if (this._disposed) {
return;
}
if (!updated) {
return;
}
@@ -2972,6 +3169,9 @@ class RecipeModal {
lora_index: loraIndex,
}),
});
if (this._disposed) {
return;
}
if (this.currentRecipe?.loras?.[loraIndex]) {
this.currentRecipe.loras[loraIndex].hashInvalid = true;
this.syncResourcesSection(this.currentRecipe);
+58 -1
View File
@@ -1,7 +1,8 @@
// PageControls.js - Manages controls for both LoRAs and Checkpoints pages
import { state, getCurrentPageState, setCurrentPageType } from '../../state/index.js';
import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { showToast, openCivitaiByMetadata, isTypingContext } from '../../utils/uiHelpers.js';
import { eventManager } from '../../utils/EventManager.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
@@ -146,6 +147,62 @@ export class PageControls {
// Page-specific event listeners
this.initPageSpecificListeners();
// Keyboard shortcuts for the actions toolbar (R / F / D)
this.registerKeyboardShortcuts();
}
/**
* Register keyboard shortcuts for the actions toolbar buttons
* (R = refresh, F = fetch metadata, D = download)
*/
registerKeyboardShortcuts() {
eventManager.addHandler('keydown', 'pageControls-actions', (e) => {
return this.handleActionShortcut(e);
}, {
priority: 90,
skipWhenModalOpen: true
});
}
/**
* Handle a keydown event for the actions toolbar shortcuts
* @param {KeyboardEvent} e
* @returns {boolean} True when the event was handled and propagation should stop
*/
handleActionShortcut(e) {
// Plain letters only — leave modified combos (Ctrl/Cmd/Alt) alone
if (e.ctrlKey || e.metaKey || e.altKey) {
return false;
}
// Don't hijack keys while typing in a text entry context
if (isTypingContext(e.target)) {
return false;
}
const actionByKey = {
r: 'refresh',
f: 'fetch',
d: 'download'
};
const action = actionByKey[e.key.toLowerCase()];
if (!action) {
return false;
}
// The button may not exist on this page (e.g. recipes has no
// fetch/download) — let other handlers run in that case
const button = document.querySelector(`[data-action="${action}"]`);
if (!button) {
return false;
}
e.preventDefault();
// Native disabled buttons ignore .click(), so an in-progress
// refresh is safe
button.click();
return true;
}
initExcludedViewControls() {
+2
View File
@@ -607,9 +607,11 @@ export function createModelCard(model, modelType) {
sendTitle = translate('modelCard.actions.sendToWorkflow', {}, 'Send to ComfyUI (Click: Append, Shift+Click: Replace)');
copyTitle = translate('modelCard.actions.copyLoRASyntax', {}, 'Copy LoRA Syntax');
} else if (modelType === MODEL_TYPES.CHECKPOINT) {
// Checkpoint send sets the widget value directly; no append/replace modes.
sendTitle = translate('modelCard.actions.sendCheckpointToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyCheckpointName', {}, 'Copy checkpoint name');
} else if (modelType === MODEL_TYPES.EMBEDDING) {
// Embedding send always appends to the prompt; no replace mode.
sendTitle = translate('modelCard.actions.sendEmbeddingToWorkflow', {}, 'Send to ComfyUI');
copyTitle = translate('modelCard.actions.copyEmbeddingName', {}, 'Copy embedding name');
} else {
+36 -5
View File
@@ -877,8 +877,9 @@ function renderLoraSpecificContent(lora, escapedWords) {
<option value="clip_strength">${translate('modals.model.usageTips.clipStrength', {}, 'Clip Strength')}</option>
<option value="clip_skip">${translate('modals.model.usageTips.clipSkip', {}, 'Clip Skip')}</option>
</select>
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;">
<button class="add-preset-btn">${translate('modals.model.usageTips.add', {}, 'Add')}</button>
<!-- autofill opt-out attrs prevent password managers / email-alias extensions from attaching popups -->
<input type="number" id="preset-value" step="0.01" placeholder="${translate('modals.model.usageTips.valuePlaceholder', {}, 'Value')}" style="display:none;" autocomplete="off" data-1p-ignore data-lpignore="true" data-bwignore data-form-type="other">
<button class="add-preset-btn" disabled>${translate('modals.model.usageTips.add', {}, 'Add')}</button>
</div>
<div class="preset-tags">
${renderPresetTags(parsePresets(lora.usage_tips))}
@@ -1086,6 +1087,11 @@ function setupLoraSpecificFields(filePath) {
if (!presetSelector || !presetValue || !addPresetBtn || !presetTags) return;
// Add button stays disabled until both a parameter and a value are provided
const updateAddPresetButtonState = () => {
addPresetBtn.disabled = !(presetSelector.value && presetValue.value.trim());
};
presetSelector.addEventListener('change', function () {
const selected = this.value;
if (selected) {
@@ -1111,12 +1117,16 @@ function setupLoraSpecificFields(filePath) {
} else {
presetValue.style.display = 'none';
}
updateAddPresetButtonState();
});
presetValue.addEventListener('input', updateAddPresetButtonState);
addPresetBtn.addEventListener('click', async function () {
const key = presetSelector.value;
const value = presetValue.value;
const value = presetValue.value.trim();
// Unreachable via UI while the button is disabled; kept as a safety net
if (!key || !value) return;
const currentPath = resolveFilePath();
@@ -1131,9 +1141,11 @@ function setupLoraSpecificFields(filePath) {
document.querySelector(`.model-card[data-filepath="${escapedFilePath}"]`);
const currentPresets = parsePresets(loraCard?.dataset.usage_tips);
let isUpdate;
if (key === 'strength_range') {
const rangeMatch = value.match(/^(-?\d*\.?\d+)\s*[-~]\s*(-?\d*\.?\d+)$/);
if (rangeMatch) {
isUpdate = 'strength_min' in currentPresets || 'strength_max' in currentPresets;
currentPresets['strength_min'] = parseFloat(rangeMatch[1]);
currentPresets['strength_max'] = parseFloat(rangeMatch[2]);
} else {
@@ -1141,17 +1153,36 @@ function setupLoraSpecificFields(filePath) {
return;
}
} else {
currentPresets[key] = parseFloat(value);
const numericValue = parseFloat(value);
if (!Number.isFinite(numericValue)) {
showToast('modals.model.usageTips.invalidValue', {}, 'error', 'Please enter a valid number');
return;
}
isUpdate = key in currentPresets;
currentPresets[key] = numericValue;
}
const newPresetsJson = JSON.stringify(currentPresets);
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
try {
await getModelApiClient().saveModelMetadata(currentPath, { usage_tips: newPresetsJson });
} catch (error) {
console.error('Failed to save preset parameter:', error);
showToast('modals.model.usageTips.saveFailed', {}, 'error', 'Failed to save preset parameter');
return;
}
presetTags.innerHTML = renderPresetTags(currentPresets);
showToast(
isUpdate ? 'modals.model.usageTips.updated' : 'modals.model.usageTips.added',
{},
'success',
isUpdate ? 'Preset parameter updated' : 'Preset parameter added'
);
presetSelector.value = '';
presetValue.value = '';
presetValue.style.display = 'none';
addPresetBtn.disabled = true;
});
// Add keydown event for preset value
+4 -4
View File
@@ -227,7 +227,7 @@ export function renderTriggerWords(words, filePath) {
const escapedWord = escapeHtml(word);
const escapedAttr = escapeAttribute(word);
return `
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyWord')}">
<div class="trigger-word-tag" data-word="${escapedAttr}" title="${translate('modals.model.triggerWords.copyOrEditWord')}">
<span class="trigger-word-content">${escapedWord}</span>
<span class="trigger-word-copy">
<i class="fas fa-copy"></i>
@@ -455,7 +455,7 @@ function resetTriggerWordsUIState(section) {
// Restore click-to-copy functionality
tag.removeEventListener('click', startEditTriggerWord);
setupDisplayTriggerWordTag(tag);
tag.title = translate('modals.model.triggerWords.copyWord');
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
// Show copy icon, hide delete button
if (copyIcon) copyIcon.style.display = '';
@@ -503,7 +503,7 @@ function createTriggerWordTag(word, isEditMode = false) {
const tag = document.createElement('div');
tag.className = 'trigger-word-tag';
tag.dataset.word = word;
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyWord');
tag.title = translate(isEditMode ? 'modals.model.triggerWords.editWord' : 'modals.model.triggerWords.copyOrEditWord');
const escapedWord = escapeHtml(word);
tag.innerHTML = `
@@ -537,7 +537,7 @@ function setupDisplayTriggerWordTag(tag) {
tag.addEventListener('click', handleDisplayTriggerWordClick);
tag.addEventListener('dblclick', handleDisplayTriggerWordDoubleClick);
tag.title = translate('modals.model.triggerWords.copyWord');
tag.title = translate('modals.model.triggerWords.copyOrEditWord');
}
/**
@@ -76,6 +76,7 @@ export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel,
alt="Preview"
width="${media.width}"
height="${media.height}"
fetchpriority="high"
class="lazy ${shouldBlur ? 'blurred' : ''}">
${shouldBlur ? `
<div class="nsfw-overlay">
@@ -22,8 +22,8 @@ import {
} from './MediaUtils.js';
import { generateMetadataPanel } from './MetadataPanel.js';
import { generateImageWrapper, generateVideoWrapper } from './MediaRenderers.js';
import { getShowcaseUrl, getThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer } from '../MediaViewer.js';
import { getShowcaseUrl, getDisplayUrl, getGalleryThumbnailUrl } from '../../../utils/civitaiUtils.js';
import { openMediaViewer, isMediaViewerOpen } from '../MediaViewer.js';
import { escapeAttribute } from '../utils.js';
/**
@@ -54,6 +54,13 @@ export async function loadExampleImages(images, modelHash, previewUrl = '') {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab) return;
// Fresh load of a model's examples: reset the gallery position so a
// previously viewed model's active index / expansion state never leaks
// into this one (the modal is a singleton, state is module-level)
galleryState.activeIndex = 0;
galleryState.expanded = false;
lastNavDirection = 1;
// First fetch local example files
let localFiles = [];
@@ -224,10 +231,10 @@ export function renderShowcaseContent(images, exampleFiles = [], previewUrl = ''
${renderMediaItem(activeImg, galleryState.activeIndex, exampleFiles)}
${renderPositionBadge(positionText)}
</div>
${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example')}">
${showNav ? `<button class="gallery-nav prev" id="galleryPrevBtn" title="${translate('modals.model.showcase.previousExample', {}, 'Previous example ([)')}">
<i class="fas fa-chevron-left"></i>
</button>
<button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example')}">
<button class="gallery-nav next" id="galleryNextBtn" title="${translate('modals.model.showcase.nextExample', {}, 'Next example (])')}">
<i class="fas fa-chevron-right"></i>
</button>` : ''}
</div>
@@ -275,7 +282,7 @@ function renderThumbnail(img, index, exampleFiles) {
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
const thumbUrl = localFile ? localFile.path : getThumbnailUrl(originalRemoteUrl, mediaType);
const thumbUrl = localFile ? localFile.path : getGalleryThumbnailUrl(originalRemoteUrl, mediaType);
const nsfwLevel = img.nsfwLevel !== undefined ? img.nsfwLevel : 0;
const matureBlurThreshold = getMatureBlurThreshold(state.settings);
@@ -284,9 +291,9 @@ function renderThumbnail(img, index, exampleFiles) {
const activeClass = index === galleryState.activeIndex ? ' active' : '';
const blurClass = shouldBlur ? ' blurred' : '';
const mediaHtml = isVideo ?
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="metadata"></video>
`<video class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" muted playsinline preload="none" data-lazy-video></video>
<i class="fas fa-play thumb-video-badge"></i>` :
`<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" alt="">`;
`<img class="thumb-media${blurClass}" src="${escapeAttribute(thumbUrl)}" loading="lazy" fetchpriority="low" alt="">`;
const nsfwBadge = shouldBlur ? '<i class="fas fa-eye-slash thumb-nsfw-badge"></i>' : '';
return `<button class="gallery-thumb${activeClass}" data-index="${index}">${mediaHtml}${nsfwBadge}</button>`;
@@ -311,8 +318,9 @@ function renderMediaItem(img, index, exampleFiles) {
originalRemoteUrl.endsWith('.mp4') || originalRemoteUrl.endsWith('.webm');
const mediaType = isVideo ? 'video' : 'image';
// Optimize CivitAI URLs for showcase display (full quality)
const remoteUrl = getShowcaseUrl(originalRemoteUrl, mediaType);
// Optimize CivitAI URLs for in-modal display (images capped at width=2400;
// the full-size media viewer uses getShowcaseUrl separately)
const remoteUrl = getDisplayUrl(originalRemoteUrl, mediaType);
const localUrl = localFile ? localFile.path : '';
@@ -438,6 +446,48 @@ function findLocalFile(img, index, exampleFiles) {
return localFile;
}
// URLs already warmed in the HTTP cache, so repeat navigations and re-renders
// never issue duplicate prefetch requests
const prefetchedUrls = new Set();
// Direction of the last main-viewer navigation (+1 next / -1 prev); users
// tend to keep clicking the same arrow, so prefetch reaches one further
// ahead along it. Defaults to forward (Next is the most common navigation)
let lastNavDirection = 1;
/**
* Warm the HTTP cache for the examples most likely to be shown next: both
* indices adjacent to the active one, plus one extra ahead along the last
* navigation direction, so prev/next navigation feels instant. Images only:
* video payloads are too heavy for speculative prefetch, and locally stored
* examples need no network fetch at all.
*/
function prefetchAdjacentMedia() {
const { images, exampleFiles, activeIndex, expanded } = galleryState;
if (!expanded || images.length < 2) return;
[1, -1, lastNavDirection * 2].forEach(offset => {
const index = ((activeIndex + offset) % images.length + images.length) % images.length;
const img = images[index];
if (!img?.url || findLocalFile(img, index, exampleFiles)) return;
const isVideo = img.url.endsWith('.mp4') || img.url.endsWith('.webm');
if (isVideo) return;
// Must match the main viewer's URL (display mode) or the warmed
// cache entry is never used
const url = getDisplayUrl(img.url, 'image');
if (prefetchedUrls.has(url)) return;
prefetchedUrls.add(url);
// Off-DOM image: fills the HTTP/memory cache without affecting layout.
// Low priority keeps it from competing with the active media's load.
const preloader = new Image();
preloader.fetchPriority = 'low';
preloader.src = url;
});
}
/**
* Switch the main viewer to another example (wraps around)
* @param {number} index - Target index in galleryState.images
@@ -446,6 +496,11 @@ export function updateMainDisplay(index) {
const count = galleryState.images.length;
if (!count || !galleryState.expanded) return;
// Remember the navigation direction for direction-aware prefetching
// (a raw index of -1 / count means wrap-around prev / next)
const delta = index - galleryState.activeIndex;
if (delta !== 0) lastNavDirection = delta > 0 ? 1 : -1;
galleryState.activeIndex = ((index % count) + count) % count;
const container = document.getElementById('mainMediaContainer');
@@ -453,6 +508,13 @@ export function updateMainDisplay(index) {
const activeImg = galleryState.images[galleryState.activeIndex];
container.style.setProperty('--media-aspect', mediaAspectRatio(activeImg));
// Direction-aware slide makes every switch (wheel, keys, buttons,
// thumbnails) perceivable instead of an instant, unexplained swap
container.classList.remove('slide-from-left', 'slide-from-right');
if (delta !== 0) {
void container.offsetWidth; // restart the animation on rapid switches
container.classList.add(delta > 0 ? 'slide-from-right' : 'slide-from-left');
}
// The badge lives inside the container, so rebuild it together with the media
container.innerHTML = renderMediaItem(
activeImg,
@@ -470,6 +532,7 @@ export function updateMainDisplay(index) {
});
initMainMediaInteractions(container);
prefetchAdjacentMedia();
}
/**
@@ -624,6 +687,211 @@ function setupScrollToExpand(gallery) {
}, { passive: true });
}
// Wheel-navigation tuning: one gesture = one step. Trackpads emit a stream
// of small deltas, so deltas accumulate until the threshold; the cooldown
// keeps the tail of the same gesture from stepping again
const WHEEL_STEP_THRESHOLD = 50;
const WHEEL_COOLDOWN_MS = 250;
const WHEEL_ACCUM_RESET_MS = 200;
/**
* Wheel navigation on the main viewer area. Bound to .gallery-main (not the
* media element) so it works wherever the cursor rests within the viewer
* including over the nav buttons and the dead zones beside the media, and
* regardless of whether the hover-triggered metadata panel is showing.
*
* - Horizontal-dominant deltas (trackpad two-finger swipe) always navigate;
* the modal never scrolls horizontally, so nothing is hijacked.
* - Vertical deltas navigate only when the modal content cannot scroll
* further in that direction (same boundary pass-through pattern as the
* metadata panel's wheel handler), so wheel-scrolling the modal through
* the gallery is never trapped mid-way.
* - Once a boundary crossing triggers a vertical switch, a "wheel session"
* starts: while the pointer stays over .gallery-main, vertical wheel in
* BOTH directions switches examples (down = next, up = prev the reverse
* gesture must undo, not scroll the modal away). The session ends when the
* pointer leaves the area, returning vertical scroll to the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initWheelNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let accumulated = 0;
let lastEventAt = 0;
let lastStepAt = 0;
let verticalSession = false;
// Leaving the viewer area releases the vertical wheel back to the modal
main.addEventListener('pointerleave', () => {
verticalSession = false;
accumulated = 0;
});
main.addEventListener('wheel', (event) => {
// The metadata panel and media controls keep their own behavior;
// the panel passes boundary scrolls through to the modal by itself
if (event.target.closest('.image-metadata-panel, .media-controls')) return;
const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);
const delta = horizontal ? event.deltaX : event.deltaY;
if (delta === 0) return;
if (!horizontal && !verticalSession) {
const scroller = main.closest('.modal-content');
if (scroller) {
const atTop = scroller.scrollTop <= 0;
const atBottom = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight <= 1;
if ((delta < 0 && !atTop) || (delta > 0 && !atBottom)) return;
}
}
event.preventDefault();
const now = performance.now();
if (now - lastEventAt > WHEEL_ACCUM_RESET_MS) accumulated = 0;
lastEventAt = now;
if (now - lastStepAt < WHEEL_COOLDOWN_MS) return;
accumulated += delta;
if (Math.abs(accumulated) < WHEEL_STEP_THRESHOLD) return;
const direction = accumulated > 0 ? 1 : -1;
accumulated = 0;
lastStepAt = now;
if (!horizontal) verticalSession = true;
updateMainDisplay(galleryState.activeIndex + direction);
}, { passive: false });
}
// Touch/pen swipe tuning. Mouse is excluded: it already has wheel, keys and
// buttons, and mouse-drag would fight the media's click-to-view gesture
const SWIPE_THRESHOLD_PX = 50;
const SWIPE_CLICK_SUPPRESS_MS = 400;
/**
* Horizontal swipe navigation on the main viewer area (touch/pen). Requires
* `touch-action: pan-y` on .gallery-main so horizontal pans reach these
* handlers while vertical pans still scroll the modal.
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initSwipeNavigation(gallery) {
const main = gallery.querySelector('.gallery-main');
if (!main || galleryState.images.length < 2) return;
let startX = 0;
let startY = 0;
let tracking = false;
let lastSwipeAt = 0;
main.addEventListener('pointerdown', (event) => {
if (event.pointerType === 'mouse') return;
// Native video controls own their pointer gestures (scrubbing etc.)
if (event.target.closest('video, .image-metadata-panel, .media-controls, .gallery-nav')) return;
startX = event.clientX;
startY = event.clientY;
tracking = true;
});
main.addEventListener('pointercancel', () => { tracking = false; });
main.addEventListener('pointerup', (event) => {
if (!tracking) return;
tracking = false;
const dx = event.clientX - startX;
const dy = event.clientY - startY;
if (Math.abs(dx) < SWIPE_THRESHOLD_PX || Math.abs(dx) < Math.abs(dy) * 1.5) return;
lastSwipeAt = performance.now();
updateMainDisplay(galleryState.activeIndex + (dx < 0 ? 1 : -1));
});
// A completed swipe still produces a click on the media — swallow it in
// the capture phase (beats the media element's own handler) so the
// full-size viewer does not open
main.addEventListener('click', (event) => {
if (performance.now() - lastSwipeAt < SWIPE_CLICK_SUPPRESS_MS) {
event.stopPropagation();
event.preventDefault();
}
}, true);
}
/**
* True when the showcase tab is the active pane of an open modal
* @returns {boolean}
*/
function isShowcaseTabVisible() {
const showcaseTab = document.getElementById('showcase-tab');
if (!showcaseTab || !showcaseTab.classList.contains('active')) return false;
const modalEl = showcaseTab.closest('.modal');
// No .modal ancestor: standalone/test rendering, treat as visible
if (!modalEl) return true;
return modalEl.classList.contains('show') || modalEl.style.display === 'block';
}
/**
* Typing-target guard for the example shortcuts. Unlike the model-level
* navigation guard, buttons are NOT excluded: clicking a thumbnail or nav
* button leaves focus on it, which would make [ ] feel dead right after the
* most common interaction and buttons consume Space/Enter natively, never
* bracket keys.
* @param {EventTarget|null} target - keydown event target
* @returns {boolean}
*/
function isTypingTarget(target) {
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select'].includes(tagName);
}
// '[' / ']' switch examples while the gallery is expanded. ArrowLeft/Right
// stay reserved for model-level navigation (ModelModal), and the full-size
// media viewer owns its keys while open.
document.addEventListener('keydown', (event) => {
if (event.key !== '[' && event.key !== ']') return;
if (!galleryState.expanded || galleryState.images.length < 2) return;
if (isTypingTarget(event.target)) return;
if (isMediaViewerOpen()) return;
if (!isShowcaseTabVisible()) return;
event.preventDefault();
updateMainDisplay(galleryState.activeIndex + (event.key === ']' ? 1 : -1));
});
/**
* Defer metadata fetches for video thumbnails until they scroll into view:
* with preload="metadata" on every strip video, expanding the gallery would
* otherwise hit the network for all of them at once
* @param {HTMLElement} gallery - The .showcase-gallery element
*/
function initStripVideoLazyLoading(gallery) {
const videos = gallery.querySelectorAll('.gallery-strip video[data-lazy-video]');
if (!videos.length) return;
const enable = (video) => {
video.preload = 'metadata';
video.load();
video.removeAttribute('data-lazy-video');
};
if (typeof IntersectionObserver === 'undefined') {
videos.forEach(enable);
return;
}
// No explicit root: intersection accounts for the strip's overflow
// clipping, so off-screen thumbnails stay at preload="none"
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
enable(entry.target);
observer.unobserve(entry.target);
}
});
});
videos.forEach(video => observer.observe(video));
}
/**
* Initialize all gallery interactions
* @param {HTMLElement} gallery - The .showcase-gallery element
@@ -683,6 +951,13 @@ export function initShowcaseContent(gallery) {
const container = gallery.querySelector('.main-media-container');
if (container && galleryState.expanded) {
initMainMediaInteractions(container);
initWheelNavigation(gallery);
initSwipeNavigation(gallery);
// Gallery just (re)rendered expanded: warm the cache for the
// examples adjacent to the active one
prefetchAdjacentMedia();
// Video thumbnails start at preload="none"; enable them on visibility
initStripVideoLazyLoading(gallery);
}
// Reposition controls on window resize
+6 -1
View File
@@ -12,6 +12,7 @@ import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
import { bannerService } from './managers/BannerService.js';
import { initTheme, initBackToTop } from './utils/uiHelpers.js';
import { applyModalBackdropBlurPolicy } from './utils/renderingCapability.js';
import { initializeInfiniteScroll } from './utils/infiniteScroll.js';
import { i18n } from './i18n/index.js';
import { onboardingManager } from './managers/OnboardingManager.js';
@@ -33,7 +34,11 @@ export class AppCore {
if (this.initialized) return;
console.log('AppCore: Initializing...');
// Disable full-viewport backdrop blur under software rendering before
// anything can open a modal (issue #1092)
applyModalBackdropBlurPolicy();
// Initialize i18n first
window.i18n = i18n;
// Wait for i18n to be ready
+4
View File
@@ -3,6 +3,7 @@ import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } fr
import { createPageControls } from './components/controls/index.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { MODEL_TYPES } from './api/apiConfig.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the Embeddings page
class EmbeddingsPageManager {
@@ -32,6 +33,9 @@ class EmbeddingsPageManager {
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync(MODEL_TYPES.EMBEDDING);
console.log('Embeddings Manager initialized');
}
}
+4
View File
@@ -4,6 +4,7 @@ import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
// Initialize the LoRA page
export class LoraPageManager {
@@ -41,6 +42,9 @@ export class LoraPageManager {
// Initialize common page features (including context menus and virtual scroll)
appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync('loras');
}
}
+62 -75
View File
@@ -10,6 +10,7 @@ import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../componen
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
export class BulkManager {
@@ -103,7 +104,6 @@ export class BulkManager {
skipMetadataRefresh: false,
setFavorite: true,
unfavorite: true,
repairMetadata: true,
reimportMetadata: true,
rematchMetadata: true
}
@@ -858,17 +858,74 @@ export class BulkManager {
`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 {
for (let i = 0; i < filePaths.length; i++) {
const filePath = filePaths[i];
const processedBeforeNative = completed + failed;
for (let i = 0; i < nativeFilePaths.length; i++) {
const filePath = nativeFilePaths[i];
const recipeItem = recipeMap.get(filePath);
const recipeId = recipeItem?.id;
const recipeName = recipeItem?.title || recipeId || 'Unknown';
const processed = processedBeforeNative + i;
progressUI.updateProgress(
Math.floor((i / total) * 100),
Math.floor((processed / total) * 100),
recipeName,
`Re-importing recipe ${Math.min(i + 1, total)}/${total}...`
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
);
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() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
+7 -4
View File
@@ -511,18 +511,21 @@ export class FilterManager {
filteredModels.forEach(model => {
const tag = document.createElement('div');
tag.className = 'filter-tag base-model-tag';
tag.dataset.baseModel = model.name;
// Display name may differ from the filter value (e.g. the "Unknown"
// bucket shows "Unknown" but filters via a dedicated marker).
const filterValue = model.value ?? model.name;
tag.dataset.baseModel = filterValue;
tag.innerHTML = `${model.name} <span class="tag-count">${model.count}</span>`;
tag.addEventListener('click', async () => {
tag.classList.toggle('active');
if (tag.classList.contains('active')) {
if (!this.filters.baseModel.includes(model.name)) {
this.filters.baseModel.push(model.name);
if (!this.filters.baseModel.includes(filterValue)) {
this.filters.baseModel.push(filterValue);
}
} else {
this.filters.baseModel = this.filters.baseModel.filter(m => m !== model.name);
this.filters.baseModel = this.filters.baseModel.filter(m => m !== filterValue);
}
this.updateActiveFiltersCount();
+126 -66
View File
@@ -1,23 +1,16 @@
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { onboardingManager } from './OnboardingManager.js';
/**
* Manages help modal functionality and tutorial update notifications
*/
export class HelpManager {
constructor() {
this.lastViewedTimestamp = getStorageItem('help_last_viewed', 0);
this.latestContentTimestamp = new Date('2025-10-11').getTime(); // Will be updated from server or config
// Version of the help content the user has seen. Compared against the
// data-help-content-version marker rendered into the help modal markup,
// so badge state is always derived from the content actually served.
this.viewedContentVersion = getStorageItem('help_viewed_content_version', null);
this.isInitialized = false;
// Default latest content data - could be fetched from server
this.latestVideoData = {
timestamp: new Date('2024-06-09').getTime(), // Default timestamp
walkthrough: {
id: 'hvKw31YpE-U',
title: 'Getting Started with LoRA Manager'
},
playlistUpdated: true
};
}
/**
@@ -34,9 +27,6 @@ export class HelpManager {
// Check if we need to show the badge
this.updateHelpBadge();
// Fetch latest video data (could be implemented to fetch from remote source)
this.fetchLatestVideoData();
this.isInitialized = true;
return this;
}
@@ -55,77 +45,147 @@ export class HelpManager {
const tabButtons = document.querySelectorAll('.help-tabs .tab-btn');
tabButtons.forEach(button => {
button.addEventListener('click', (event) => {
// Remove active class from all buttons and panes
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
pane.classList.remove('active');
});
// Add active class to clicked button
event.currentTarget.classList.add('active');
// Show corresponding tab content
const tabId = event.currentTarget.getAttribute('data-tab');
document.getElementById(tabId).classList.add('active');
this.activateHelpTab(event.currentTarget.getAttribute('data-tab'));
});
});
// Replay tutorial button in the Getting Started tab
const replayTutorialBtn = document.getElementById('replayTutorialBtn');
if (replayTutorialBtn) {
replayTutorialBtn.addEventListener('click', () => {
// Close the help modal, then restart the onboarding tutorial
if (window.modalManager) {
window.modalManager.closeModal('helpModal');
}
onboardingManager.reset();
onboardingManager.startTutorial();
});
}
// Global "?" shortcut opens the help modal on the Shortcuts tab
document.addEventListener('keydown', (event) => {
if (event.key !== '?') return;
if (this.isTypingContext(event.target)) return;
if (window.modalManager?.isAnyModalOpen()) return;
event.preventDefault();
this.openHelpModal('shortcuts');
});
}
/**
* Check if the event target is a text entry context where "?" is literal input
*/
isTypingContext(target) {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Activate a specific help modal tab by its data-tab id
* @param {string} tabId - The tab id (matches data-tab and pane element id)
*/
activateHelpTab(tabId) {
const tabButton = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
const tabPane = document.getElementById(tabId);
if (!tabButton || !tabPane) return;
// Remove active class from all buttons and panes
document.querySelectorAll('.help-tabs .tab-btn').forEach(btn => {
btn.classList.remove('active');
});
document.querySelectorAll('.help-content .tab-pane').forEach(pane => {
pane.classList.remove('active');
});
// Activate the requested tab
tabButton.classList.add('active');
tabPane.classList.add('active');
}
/**
* Open the help modal
* @param {string} [tabId] - Optional tab id to activate after opening
*/
openHelpModal() {
openHelpModal(tabId) {
// Use modalManager to open the help modal
if (window.modalManager) {
window.modalManager.toggleModal('helpModal');
// Add visual indicator to Documentation tab if there's new content
this.updateDocumentationTabIndicator();
// Update the last viewed timestamp
this.markContentAsViewed();
// Hide the badge
this.hideHelpBadge();
if (!window.modalManager) return;
const hadNewContent = this.hasNewContent();
window.modalManager.toggleModal('helpModal');
if (tabId) {
this.activateHelpTab(tabId);
}
// Only acknowledge the content as viewed when the user opened the
// modal while it actually contained new content. Opening a stale
// (pre-upgrade) page must not suppress the badge after a refresh.
if (hadNewContent) {
this.updateNewContentTabIndicators();
this.markContentAsViewed();
}
// Hide the badge
this.hideHelpBadge();
}
/**
* Add visual indicator to Documentation tab for new content
* Add visual indicator to tabs that received new content
*/
updateDocumentationTabIndicator() {
const docTab = document.querySelector('.tab-btn[data-tab="documentation"]');
if (docTab && this.hasNewContent()) {
docTab.classList.add('has-new-content');
updateNewContentTabIndicators() {
if (!this.hasNewContent()) return;
// Tabs updated in the 2026-09-03 discoverability release:
// getting-started (Replay Tutorial button) and shortcuts (new cheat-sheet tab)
const NEW_CONTENT_TABS = ['getting-started', 'shortcuts'];
NEW_CONTENT_TABS.forEach(tabId => {
const tab = document.querySelector(`.help-tabs .tab-btn[data-tab="${tabId}"]`);
if (tab) {
tab.classList.add('has-new-content');
}
});
// Point the indicator at the specific new element inside the
// Getting Started tab, and scroll it into view so it is not lost
// below the fold of the modal body.
const replayBtn = document.getElementById('replayTutorialBtn');
if (replayBtn) {
replayBtn.classList.add('has-new-content');
const gettingStartedActive = document.querySelector('#getting-started.tab-pane.active');
if (gettingStartedActive && typeof replayBtn.scrollIntoView === 'function') {
replayBtn.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
/**
* Mark content as viewed by saving current timestamp
* Mark content as viewed by persisting the version rendered in the DOM.
* No-op when the served markup carries no version marker (stale assets),
* so viewing old content never suppresses the badge for new content.
*/
markContentAsViewed() {
this.lastViewedTimestamp = Date.now();
setStorageItem('help_last_viewed', this.lastViewedTimestamp);
const currentVersion = this.getCurrentContentVersion();
if (!currentVersion) return;
this.viewedContentVersion = currentVersion;
setStorageItem('help_viewed_content_version', this.viewedContentVersion);
}
/**
* Fetch latest video data (could be implemented to actually fetch from a remote source)
* Read the help content version from the rendered modal markup
* @returns {string|null} Version marker, or null if the served markup has none
*/
fetchLatestVideoData() {
// In a real implementation, you'd fetch this from your server
// For now, we'll just use the hardcoded data from constructor
// Update the timestamp with the latest data
this.latestContentTimestamp = Math.max(this.latestContentTimestamp, this.latestVideoData.timestamp);
// Check again if we need to show the badge with this new data
this.updateHelpBadge();
getCurrentContentVersion() {
const marker = document.querySelector('[data-help-content-version]');
return marker ? marker.getAttribute('data-help-content-version') : null;
}
/**
* Update help badge visibility based on timestamps
* Update help badge visibility based on viewed vs. served content version
*/
updateHelpBadge() {
if (this.hasNewContent()) {
@@ -134,13 +194,13 @@ export class HelpManager {
this.hideHelpBadge();
}
}
/**
* Check if there's new content the user hasn't seen
* Check if the served help content is newer than what the user has viewed
*/
hasNewContent() {
// If user has never viewed the help, or the content is newer than last viewed
return this.lastViewedTimestamp === 0 || this.latestContentTimestamp > this.lastViewedTimestamp;
const currentVersion = this.getCurrentContentVersion();
return Boolean(currentVersion) && currentVersion !== this.viewedContentVersion;
}
/**
+22 -2
View File
@@ -43,7 +43,7 @@ export class OnboardingManager {
{
target: '.controls .action-buttons [data-action="bulk"]',
title: () => translate('onboarding.steps.bulk.title', {}, 'Bulk Operations'),
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span>. Select multiple models and perform batch operations. Use <span class="onboarding-shortcut">Ctrl+A</span> to select all visible models.'),
content: () => translate('onboarding.steps.bulk.content', {}, 'Enter bulk mode by clicking this button or pressing <span class="onboarding-shortcut">B</span> to select multiple models and perform batch operations.<br>• <span class="onboarding-shortcut">Ctrl/Cmd+A</span> select all visible models, <span class="onboarding-shortcut">Shift+Click</span> select a range.<br>• <span class="onboarding-shortcut">Esc</span> or clicking an empty area exits bulk mode.'),
position: 'bottom'
},
{
@@ -71,10 +71,30 @@ export class OnboardingManager {
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.marqueeSelect.title', {}, 'Drag to Select'),
content: () => translate('onboarding.steps.marqueeSelect.content', {}, 'Hold the <strong>left mouse button</strong> on an empty area of the grid and drag to draw a marquee that selects multiple cards at once.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '#folderSidebar',
title: () => translate('onboarding.steps.dragToSidebar.title', {}, 'Organize by Dragging'),
content: () => translate('onboarding.steps.dragToSidebar.content', {}, 'Drag a model card onto a folder in the sidebar to move the file there. This also works with multiple selected cards in bulk mode.'),
position: 'right'
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.contextMenu.title', {}, 'Context Menu'),
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with additional actions.'),
content: () => translate('onboarding.steps.contextMenu.content', {}, '<strong>Right-click</strong> any model card for a context menu with card actions like moving, deleting, or editing metadata.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
},
{
target: '.card-grid',
title: () => translate('onboarding.steps.contextMenus.title', {}, 'More Context Menus'),
content: () => translate('onboarding.steps.contextMenus.content', {}, 'In bulk mode, <strong>right-click a selected card</strong> for bulk actions. <strong>Right-click an empty area</strong> of the page for global actions like update checks and managing excluded models.'),
position: 'top',
customPosition: { top: '20%', left: '50%' }
}
+10
View File
@@ -1047,6 +1047,12 @@ export class SettingsManager {
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
const modelNameDisplaySelect = document.getElementById('modelNameDisplay');
if (modelNameDisplaySelect) {
@@ -3396,6 +3402,10 @@ export class SettingsManager {
const groupByModel = !!state.global.settings.group_by_model;
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,
use_new_license_icons: true,
group_by_model: false,
sticky_controls: false,
llm_provider: 'openai',
llm_api_key: '',
llm_api_base: '',
+61
View File
@@ -0,0 +1,61 @@
/**
* Mirrors the manager page's active filter state to the backend's in-memory
* store, so the ComfyUI-side autocomplete can apply it even when the manager
* page and ComfyUI run in different browsers/origins (localStorage is not
* shared there).
*/
import { getStorageItem, setActiveFiltersListener } from './storageHelpers.js';
import { debounce } from './debounce.js';
const SYNC_DEBOUNCE_MS = 300;
const debouncedPushByPage = {};
function buildActiveFiltersPayload(pageType) {
const activeFolder = getStorageItem(`${pageType}_activeFolder`);
const recursiveSearch = getStorageItem(`${pageType}_recursiveSearch`, true);
const filters = getStorageItem(`${pageType}_filters`);
return {
// null stays null; legacy "null" string is normalized to null
activeFolder: activeFolder && activeFolder !== 'null' ? activeFolder : null,
recursiveSearch: recursiveSearch !== false,
filters: filters && typeof filters === 'object' ? filters : null,
};
}
export async function pushActiveFilters(pageType) {
try {
const response = await fetch(`/api/lm/${pageType}/active-filters`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildActiveFiltersPayload(pageType)),
});
if (!response.ok) {
console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}: HTTP ${response.status}`);
}
} catch (error) {
console.warn(`[Lora Manager] Failed to sync active filters for ${pageType}:`, error);
}
}
export function syncActiveFilters(pageType) {
if (!debouncedPushByPage[pageType]) {
debouncedPushByPage[pageType] = debounce(() => {
pushActiveFilters(pageType);
}, SYNC_DEBOUNCE_MS);
}
debouncedPushByPage[pageType]();
}
/**
* Register the storage listener and push the current (restored) state once.
* The initial push covers server restarts, where the backend store is empty
* until the manager page re-publishes its localStorage-restored filters.
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings'
*/
export function initActiveFiltersSync(pageType) {
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
pushActiveFilters(pageType);
}
+39 -4
View File
@@ -9,8 +9,13 @@
export const OptimizationMode = {
/** Full quality for showcase/display - uses /optimized=true only */
SHOWCASE: 'showcase',
/** In-modal display - caps image width at 2400 (covers the ~1200 CSS px
* main viewer at DPR 2); videos stay full quality */
DISPLAY: 'display',
/** Thumbnail size for cards - uses /width=450,optimized=true */
THUMBNAIL: 'thumbnail',
/** Small thumbnails for the showcase gallery strip (72px display) - uses /width=160,optimized=true */
GALLERY_THUMBNAIL: 'gallery-thumbnail',
};
export const DEFAULT_CIVITAI_PAGE_HOST = 'civitai.com';
@@ -95,15 +100,21 @@ export function rewriteCivitaiUrl(sourceUrl, mediaType = null, mode = Optimizati
}
// Determine replacement based on mode and media type
const isVideo = Boolean(mediaType && mediaType.toLowerCase() === 'video');
let replacement;
if (mode === OptimizationMode.SHOWCASE) {
// Full quality for showcase - no width restriction
replacement = '/optimized=true';
} else if (mode === OptimizationMode.DISPLAY) {
// Display mode caps image width for in-modal viewing; videos stay
// full quality (CDN transcoding costs more than it saves here)
replacement = isVideo ? '/optimized=true' : '/width=2400,optimized=true';
} else {
// Thumbnail mode with width restriction
replacement = '/width=450,optimized=true';
if (mediaType && mediaType.toLowerCase() === 'video') {
replacement = '/transcode=true,width=450,optimized=true';
// Thumbnail modes with width restriction
const width = mode === OptimizationMode.GALLERY_THUMBNAIL ? 160 : 450;
replacement = `/width=${width},optimized=true`;
if (isVideo) {
replacement = `/transcode=true,width=${width},optimized=true`;
}
}
@@ -150,6 +161,19 @@ export function getShowcaseUrl(url, type = 'image') {
return getOptimizedUrl(url, type, OptimizationMode.SHOWCASE);
}
/**
* Get display-optimized URL for the in-modal main viewer (images capped at
* width=2400; videos full quality). Use getShowcaseUrl for full-size viewing
* (e.g. the media viewer overlay)
*
* @param {string} url - Original URL
* @param {string} type - Media type ("image" or "video")
* @returns {string} - Optimized URL for in-modal display
*/
export function getDisplayUrl(url, type = 'image') {
return getOptimizedUrl(url, type, OptimizationMode.DISPLAY);
}
/**
* Get thumbnail-optimized URL (width=450)
*
@@ -161,6 +185,17 @@ export function getThumbnailUrl(url, type = 'image') {
return getOptimizedUrl(url, type, OptimizationMode.THUMBNAIL);
}
/**
* Get gallery-strip-thumbnail-optimized URL (width=160, for the 72px strip)
*
* @param {string} url - Original URL
* @param {string} type - Media type ("image" or "video")
* @returns {string} - Optimized URL for gallery strip thumbnail display
*/
export function getGalleryThumbnailUrl(url, type = 'image') {
return getOptimizedUrl(url, type, OptimizationMode.GALLERY_THUMBNAIL);
}
/**
* Check if a URL is from CivitAI
*
+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 };
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Software-rendering detection for degrading expensive visual effects.
*
* With hardware acceleration disabled (or a GPU blocklisted), Chrome rasterizes
* in software. A full-viewport `backdrop-filter: blur()` then forces a per-frame
* CPU blur over everything painted behind the modal, freezing the entire
* browser (issue #1092). When software rendering is detected we add the
* `no-modal-backdrop-blur` class to <html>, and CSS drops the backdrop blur.
*/
const SOFTWARE_RENDERER_PATTERN = /swiftshader|llvmpipe|softpipe|software|basic render/i;
/**
* Check a WebGL renderer string against known software rasterizers.
* @param {string} renderer - UNMASKED_RENDERER_WEBGL string
* @returns {boolean}
*/
export function isSoftwareRendererString(renderer) {
return SOFTWARE_RENDERER_PATTERN.test(renderer || '');
}
/**
* Read the unmasked WebGL renderer string, or null when unavailable/masked.
* @returns {string|null}
*/
function getWebGLRendererString() {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
if (!gl) return null;
const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
const renderer = debugInfo
? String(gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) || '')
: '';
const loseContext = gl.getExtension('WEBGL_lose_context');
if (loseContext) loseContext.loseContext();
return renderer || null;
}
/**
* Heuristic: is the browser rasterizing in software?
* - No WebGL at all: no evidence of GPU acceleration, assume software.
* - Masked renderer string or detection failure: cannot tell, keep effects on.
* @returns {boolean}
*/
export function isSoftwareRendering() {
try {
const renderer = getWebGLRendererString();
if (renderer === null) {
return true;
}
return isSoftwareRendererString(renderer);
} catch (error) {
return true;
}
}
/**
* Toggle the blur-disabling class on <html>. Runs once at app startup.
* @param {boolean} [isSoftware] - Override for tests; defaults to detection.
*/
export function applyModalBackdropBlurPolicy(isSoftware = isSoftwareRendering()) {
document.documentElement.classList.toggle('no-modal-backdrop-blur', isSoftware);
}
+61
View File
@@ -0,0 +1,61 @@
import { translate } from './i18nHelpers.js';
/**
* Format a remaining-time estimate for scan progress display.
* @param {number} remainingMs - Estimated remaining time in milliseconds
* @returns {string} Localized ETA text
*/
export function formatScanRemainingTime(remainingMs) {
if (remainingMs < 60000) {
return translate('common.scanProgress.eta.lessThanMinute', {}, 'Less than a minute remaining');
}
if (remainingMs < 3600000) {
const minutes = Math.round(remainingMs / 60000);
return translate('common.scanProgress.eta.minutes', { minutes }, `~${minutes} min remaining`);
}
const hours = Math.floor(remainingMs / 3600000);
const minutes = Math.round((remainingMs % 3600000) / 60000);
return translate('common.scanProgress.eta.hours', { hours, minutes }, `~${hours} hr ${minutes} min remaining`);
}
/**
* Create an ETA tracker for scan progress. Uses an exponential moving
* average (0.7/0.3) over the observed per-file processing time, mirroring
* the estimator in components/initialization.js.
* @returns {{ update: (processed: number, total: number) => (string|null) }}
*/
export function createScanEtaTracker() {
let startTime = null;
let lastProcessed = 0;
let averageMsPerFile = null;
return {
/**
* Update with the latest counters.
* @returns {string|null} Localized ETA text, or null when not applicable
*/
update(processed, total) {
if (!total || total <= 0 || processed >= total) {
return null;
}
const now = Date.now();
if (startTime === null) {
// First sample only anchors the timer; not enough data yet
startTime = now;
lastProcessed = processed;
return translate('initialization.estimatingTime', {}, 'Estimating time...');
}
if (processed > lastProcessed) {
const msPerFile = (now - startTime) / processed;
averageMsPerFile = averageMsPerFile === null
? msPerFile
: averageMsPerFile * 0.7 + msPerFile * 0.3;
lastProcessed = processed;
}
if (averageMsPerFile === null) {
return translate('initialization.estimatingTime', {}, 'Estimating time...');
}
return formatScanRemainingTime((total - lastProcessed) * averageMsPerFile);
}
};
}
+30 -1
View File
@@ -6,6 +6,31 @@
// Namespace prefix for all localStorage keys
const STORAGE_PREFIX = 'lora_manager_';
// Matches keys that carry the manager page's active filter state
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/;
let activeFiltersListener = null;
/**
* Register a listener invoked with the page type whenever one of the
* active-filter storage keys changes. Used to mirror filter state to the
* backend so the ComfyUI-side autocomplete can pick it up across
* browsers/origins where localStorage is not shared.
* @param {function(string): void} listener
*/
export function setActiveFiltersListener(listener) {
activeFiltersListener = listener;
}
function notifyActiveFiltersChanged(key) {
if (!activeFiltersListener) return;
const match = ACTIVE_FILTER_KEY_PATTERN.exec(key);
if (match) {
activeFiltersListener(match[1]);
}
}
/**
* Get an item from localStorage with namespace support and fallback to legacy keys
* @param {string} key - The key without prefix
@@ -51,13 +76,15 @@ export function getStorageItem(key, defaultValue = null) {
*/
export function setStorageItem(key, value) {
const prefixedKey = STORAGE_PREFIX + key;
// Convert objects and arrays to JSON strings
if (typeof value === 'object' && value !== null) {
localStorage.setItem(prefixedKey, JSON.stringify(value));
} else {
localStorage.setItem(prefixedKey, value);
}
notifyActiveFiltersChanged(key);
}
/**
@@ -67,6 +94,8 @@ export function setStorageItem(key, value) {
export function removeStorageItem(key) {
localStorage.removeItem(STORAGE_PREFIX + key);
localStorage.removeItem(key); // Also remove legacy key
notifyActiveFiltersChanged(key);
}
/**
+14
View File
@@ -311,6 +311,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
toast.append(closeBtn);
}
/**
* Check whether the event target is a text-entry context (input, textarea,
* select, or contenteditable) where single-letter shortcuts should be treated
* as literal input.
* @param {EventTarget|null} target - The DOM event target
* @returns {boolean}
*/
export function isTypingContext(target) {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
+2
View File
@@ -54,8 +54,10 @@
{% endblock %}
{% block content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
-6
View File
@@ -94,9 +94,6 @@
<div class="context-menu-item" data-action="check-updates">
<i class="fas fa-bell"></i> <span>{{ t('loras.bulkOperations.checkUpdates') }}</span>
</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">
<i class="fas fa-link"></i> <span>{{ t('loras.bulkOperations.rematchMetadata') }}</span>
</div>
@@ -202,9 +199,6 @@
<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>
</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">
<i class="fas fa-link"></i> <span>{{ t('globalContextMenu.rematchRecipes.label') }}</span>
</div>
+4 -4
View File
@@ -65,7 +65,7 @@
</select>
</div>
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span>{{ t('common.actions.refresh') }}</span></button>
<button data-action="refresh" class="dropdown-main"><i class="fas fa-sync"></i> <span><span>{{ t('common.actions.refresh') }}</span> <kbd class="shortcut-key">R</kbd></span></button>
<button class="dropdown-toggle" aria-label="Show refresh options">
<i class="fas fa-caret-down"></i>
</button>
@@ -78,11 +78,11 @@
{% if page_id != 'recipes' %}
<div class="control-group">
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span>{{ t('loras.controls.fetch.action') }}</span></button>
<button data-action="fetch" title="{{ t('loras.controls.fetch.title') }}"><i class="fas fa-download"></i> <span><span>{{ t('loras.controls.fetch.action') }}</span> <kbd class="shortcut-key">F</kbd></span></button>
</div>
<div class="control-group">
<button data-action="download" title="{{ t('loras.controls.download.title') }}">
<i class="fas fa-cloud-download-alt"></i> <span>{{ t('loras.controls.download.action') }}</span>
<i class="fas fa-cloud-download-alt"></i> <span><span>{{ t('loras.controls.download.action') }}</span> <kbd class="shortcut-key">D</kbd></span>
</button>
</div>
{% endif %}
@@ -96,7 +96,7 @@
{% endif %}
<div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <div class="shortcut-key">B</div></span>
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span> <kbd class="shortcut-key">B</kbd></span>
</button>
</div>
<div class="control-group">
+129 -1
View File
@@ -1,5 +1,5 @@
<!-- Help Modal -->
<div id="helpModal" class="modal">
<div id="helpModal" class="modal" data-help-content-version="2026-09-03">
<div class="modal-content help-modal">
<button class="close" onclick="modalManager.closeModal('helpModal')">&times;</button>
<div class="help-header">
@@ -10,6 +10,7 @@
<button class="tab-btn active" data-tab="getting-started">{{ t('help.tabs.gettingStarted') }}</button>
<button class="tab-btn" data-tab="update-vlogs">{{ t('help.tabs.updateVlogs') }}</button>
<button class="tab-btn" data-tab="documentation">{{ t('help.tabs.documentation') }}</button>
<button class="tab-btn" data-tab="shortcuts">{{ t('help.tabs.shortcuts') }}</button>
</div>
<div class="help-content">
@@ -39,6 +40,13 @@
<li><strong>Recipe System:</strong> Create, save and share your perfect combinations</li>
</ul>
</div>
<div class="help-actions">
<button id="replayTutorialBtn" class="replay-tutorial-btn">
<i class="fas fa-graduation-cap"></i>
<span>{{ t('help.gettingStarted.replayTutorial') }}</span>
<span class="new-content-badge">{{ t('help.newContentBadge') }}</span>
</button>
</div>
</div>
<!-- Update Vlogs Tab -->
@@ -136,6 +144,126 @@
</ul>
</div>
</div>
<!-- Shortcuts Tab -->
<div class="tab-pane" id="shortcuts">
<h3>{{ t('help.shortcuts.title') }}</h3>
<div class="shortcuts-section">
<h4><i class="fas fa-keyboard"></i> {{ t('help.shortcuts.groups.general') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>F</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.focusSearch') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeModal') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>?</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.openShortcuts') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-bolt"></i> {{ t('help.shortcuts.groups.actions') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>R</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.refresh') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>F</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.fetchMetadata') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>D</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.downloadModel') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-object-group"></i> {{ t('help.shortcuts.groups.selection') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>B</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.toggleBulkMode') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Ctrl</kbd><span class="shortcut-sep">/</span><kbd>Cmd</kbd><span class="shortcut-sep">+</span><kbd>A</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.selectAll') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Shift</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.click') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.rangeSelect') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.drag') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.marqueeSelect') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.exitBulkMode') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.bulkActions') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.rightClick') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.globalActions') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-arrows-alt-v"></i> {{ t('help.shortcuts.groups.navigation') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>PageUp</kbd><span class="shortcut-sep">/</span><kbd>PageDown</kbd><span class="shortcut-sep">/</span><kbd>Home</kbd><span class="shortcut-sep">/</span><kbd>End</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.scrollPages') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Alt</kbd><span class="shortcut-sep">+</span><kbd>{{ t('help.shortcuts.keys.letter') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.jumpAlphabet') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-window-restore"></i> {{ t('help.shortcuts.groups.modelModal') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>&larr;</kbd><span class="shortcut-sep">/</span><kbd>&rarr;</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.prevNext') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Delete</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.deleteEntry') }}</span>
</li>
</ul>
</div>
<div class="shortcuts-section">
<h4><i class="fas fa-images"></i> {{ t('help.shortcuts.groups.mediaViewer') }}</h4>
<ul class="shortcuts-list">
<li>
<span class="shortcut-keys"><kbd>&larr;</kbd><span class="shortcut-sep">/</span><kbd>&rarr;</kbd><span class="shortcut-sep">/</span><kbd>[</kbd><span class="shortcut-sep">/</span><kbd>]</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.cycleMedia') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>{{ t('help.shortcuts.keys.swipe') }}</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.swipeTouch') }}</span>
</li>
<li>
<span class="shortcut-keys"><kbd>Esc</kbd></span>
<span class="shortcut-description">{{ t('help.shortcuts.entries.closeViewer') }}</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
@@ -79,6 +79,9 @@
<!-- Group by model toggle -->
{{ 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', [
('always', 'settings.layoutSettings.cardInfoDisplayOptions.always'),
('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'),
+4 -1
View File
@@ -144,5 +144,8 @@
</div>
</div>
</div>
</div>
<!-- Meta footer: file location + recipe ID, populated by RecipeModal.syncMetaFooter() -->
<footer class="recipe-meta-footer" id="recipeMetaFooter" hidden></footer>
</div>
</div>
+2
View File
@@ -53,8 +53,10 @@
{% endblock %}
{% block content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.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 content %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
+3 -4
View File
@@ -18,9 +18,6 @@
<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> -->
<!-- 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">
<i class="fas fa-link"></i> {{ t('loras.contextMenu.rematchMetadata') }}
</div>
@@ -64,10 +61,12 @@
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
{% block content %}
<!-- Recipe controls -->
<!-- Sticky topbar: controls + breadcrumb -->
<div class="sticky-topbar">
{% include 'components/controls.html' %}
<!-- Breadcrumb Navigation -->
{% include 'components/breadcrumb.html' %}
</div>
<!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
@@ -0,0 +1,348 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
BASE_MODEL_API_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
STORAGE_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
SIDEBAR_MANAGER_MODULE,
} = vi.hoisted(() => ({
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const showMock = vi.fn();
const showCancelButtonMock = vi.fn();
const hideMock = vi.fn();
const restoreProgressBarMock = vi.fn();
const setProgressMock = vi.fn();
const setStatusMock = vi.fn();
const resetAndReloadMock = vi.fn();
vi.mock(STATE_MODULE, () => ({
state: {
loadingManager: {
show: showMock,
showCancelButton: showCancelButtonMock,
hide: hideMock,
restoreProgressBar: restoreProgressBarMock,
setProgress: setProgressMock,
setStatus: setStatusMock,
},
},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => {
if (fallback) {
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
fallback
);
}
return key;
}),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
getSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
saveMapToStorage: vi.fn(),
}));
vi.mock(API_CONFIG_MODULE, () => ({
getCompleteApiConfig: vi.fn(() => ({
endpoints: { scan: '/api/lm/loras/scan' },
config: { displayName: 'LoRA', singularName: 'lora' },
})),
getCurrentModelType: vi.fn(() => 'loras'),
isValidModelType: vi.fn(() => true),
DOWNLOAD_ENDPOINTS: {},
HF_ENDPOINTS: {},
WS_ENDPOINTS: { fetchProgress: '/ws/fetch-progress' },
}));
vi.mock(API_FACTORY_MODULE, () => ({
resetAndReload: resetAndReloadMock,
}));
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
sidebarManager: { refresh: vi.fn() },
}));
class FakeWebSocket {
static instances = [];
static failNextConnection = false;
constructor(url) {
this.url = url;
this.onopen = null;
this.onerror = null;
this.onmessage = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
const shouldFail = FakeWebSocket.failNextConnection;
FakeWebSocket.failNextConnection = false;
queueMicrotask(() => {
if (shouldFail) {
this.onerror?.(new Error('connection refused'));
} else {
this.onopen?.();
}
});
}
emit(data) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
async function createClient() {
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
class TestClient extends BaseModelApiClient {}
return new TestClient('loras');
}
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('BaseModelApiClient.refreshModels scan progress', () => {
beforeEach(() => {
showToastMock.mockReset();
showMock.mockReset();
showCancelButtonMock.mockReset();
hideMock.mockReset();
restoreProgressBarMock.mockReset();
setProgressMock.mockReset();
setStatusMock.mockReset();
resetAndReloadMock.mockReset();
FakeWebSocket.instances = [];
FakeWebSocket.failNextConnection = false;
vi.stubGlobal('WebSocket', FakeWebSocket);
});
afterEach(() => {
delete global.fetch;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function mockFetchPending() {
let resolveFetch;
global.fetch = vi.fn(() => new Promise((resolve) => { resolveFetch = resolve; }));
return {
resolveOk: (payload = { status: 'success' }) =>
resolveFetch({ ok: true, json: async () => payload }),
};
}
async function startRefresh(client, fullRebuild = false) {
const promise = client.refreshModels(fullRebuild);
await vi.waitFor(() => {
expect(FakeWebSocket.instances.length).toBe(1);
});
await flushMicrotasks();
const socket = FakeWebSocket.instances[0];
await vi.waitFor(() => {
expect(socket.onmessage).toBeTruthy();
});
return { promise, socket };
}
it('shows scan progress updates from the WebSocket channel', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise, socket } = await startRefresh(client);
expect(socket.url).toBe(`ws://${window.location.host}/ws/fetch-progress`);
socket.emit({
type: 'scan_progress',
status: 'started',
stage: 'scan_folders',
model_type: 'lora',
pageType: 'loras',
full_rebuild: false,
progress: 0,
});
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'lora',
pageType: 'loras',
full_rebuild: false,
progress: 50,
processed: 5,
total: 10,
current_name: 'style.safetensors',
});
expect(setProgressMock).toHaveBeenCalledWith(0);
expect(setProgressMock).toHaveBeenCalledWith(50);
const lastStatus = setStatusMock.mock.calls.at(-1)[0];
expect(lastStatus).toContain('(5/10)');
expect(lastStatus).toContain('style.safetensors');
// First ETA sample only anchors the timer
expect(lastStatus).toContain('Estimating time...');
fetchControl.resolveOk();
await promise;
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Refresh' },
'success'
);
expect(socket.close).toHaveBeenCalled();
expect(hideMock).toHaveBeenCalled();
});
it('ignores messages for other types or other model types', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise, socket } = await startRefresh(client);
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'checkpoint',
progress: 33,
processed: 1,
total: 3,
});
socket.emit({
type: 'example_images_progress',
status: 'running',
model_type: 'lora',
progress: 66,
processed: 2,
total: 3,
});
expect(setProgressMock).not.toHaveBeenCalled();
expect(setStatusMock).not.toHaveBeenCalled();
fetchControl.resolveOk();
await promise;
});
it('falls back to plain loading when the WebSocket connection fails', async () => {
FakeWebSocket.failNextConnection = true;
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ status: 'success' }),
});
const client = await createClient();
await client.refreshModels(true);
expect(global.fetch).toHaveBeenCalled();
const [url] = global.fetch.mock.calls[0];
expect(url.searchParams.get('full_rebuild')).toBe('true');
expect(showMock).toHaveBeenCalledWith('Full rebuild LoRAs...', 0);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Full rebuild' },
'success'
);
});
it('computes an ETA with EMA smoothing once enough samples arrive', async () => {
const fetchControl = mockFetchPending();
let now = 1000;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const client = await createClient();
const { promise, socket } = await startRefresh(client);
const emitProcessing = (processed, total) => socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'lora',
progress: Math.floor((processed / total) * 100),
processed,
total,
});
// First sample anchors the timer
emitProcessing(1, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('Estimating time...');
// 100s elapsed for 2 files -> 50s per file -> 400s remaining -> ~7 min
now = 101000;
emitProcessing(2, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~7 min remaining');
// 110s elapsed for 4 files -> EMA = 50000*0.7 + 27500*0.3 = 43250ms/file
// remaining 6 files -> 259.5s -> ~4 min
now = 111000;
emitProcessing(4, 10);
expect(setStatusMock.mock.calls.at(-1)[0]).toContain('~4 min remaining');
fetchControl.resolveOk();
await promise;
});
it('shows the cancelled toast when the server reports cancellation', async () => {
const fetchControl = mockFetchPending();
const client = await createClient();
const { promise } = await startRefresh(client);
fetchControl.resolveOk({ status: 'cancelled' });
await promise;
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
expect(resetAndReloadMock).not.toHaveBeenCalled();
});
});
describe('createScanEtaTracker / formatScanRemainingTime', () => {
it('estimates remaining time from EMA of per-file cost', async () => {
const { createScanEtaTracker } = await import(BASE_MODEL_API_MODULE);
let now = 0;
vi.spyOn(Date, 'now').mockImplementation(() => now);
const tracker = createScanEtaTracker();
expect(tracker.update(1, 10)).toBe('Estimating time...');
now = 60000; // 60s for 3 files -> 20s/file -> 7 * 20s = 140s -> ~2 min
expect(tracker.update(3, 10)).toBe('~2 min remaining');
now = 61000; // tiny delta keeps EMA near 20s/file
expect(tracker.update(4, 10)).toBe('~2 min remaining');
// Done: no ETA
expect(tracker.update(10, 10)).toBeNull();
expect(tracker.update(0, 0)).toBeNull();
vi.restoreAllMocks();
});
it('formats hours and sub-minute remainders', async () => {
const { formatScanRemainingTime } = await import(BASE_MODEL_API_MODULE);
expect(formatScanRemainingTime(30000)).toBe('Less than a minute remaining');
expect(formatScanRemainingTime(5 * 60000)).toBe('~5 min remaining');
expect(formatScanRemainingTime(3600000 + 30 * 60000)).toBe('~1 hr 30 min remaining');
});
});
@@ -0,0 +1,285 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({
show: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
setProgress: vi.fn(),
setStatus: vi.fn(),
}));
const virtualScrollerMock = vi.hoisted(() => ({
refreshWithData: vi.fn(),
}));
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
const etaUpdateMock = vi.hoisted(() => vi.fn(() => 'ETA soon'));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
},
getCurrentPageState: getCurrentPageStateMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: vi.fn((key, params, fallback) => {
if (fallback) {
return Object.entries(params || {}).reduce(
(text, [name, value]) => text.replaceAll(`{${name}}`, value),
fallback
);
}
return key;
}),
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: vi.fn(),
restoreScrollPosition: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
WS_ENDPOINTS: { fetchProgress: '/ws/fetch-progress' },
}));
vi.mock('../../../static/js/utils/scanEtaUtils.js', () => ({
createScanEtaTracker: () => ({ update: etaUpdateMock }),
}));
import { refreshRecipes } from '../../../static/js/api/recipeApi.js';
class FakeWebSocket {
static instances = [];
static failNextConnection = false;
constructor(url) {
this.url = url;
this.onopen = null;
this.onerror = null;
this.onmessage = null;
this.close = vi.fn();
FakeWebSocket.instances.push(this);
const shouldFail = FakeWebSocket.failNextConnection;
FakeWebSocket.failNextConnection = false;
queueMicrotask(() => {
if (shouldFail) {
this.onerror?.(new Error('connection refused'));
} else {
this.onopen?.();
}
});
}
emit(data) {
this.onmessage?.({ data: JSON.stringify(data) });
}
}
async function flushMicrotasks() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe('refreshRecipes scan progress', () => {
beforeEach(() => {
vi.clearAllMocks();
getCurrentPageStateMock.mockReturnValue({
pageSize: 50,
currentPage: 1,
hasMore: true,
isLoading: false,
sortBy: 'date:desc',
showFavoritesOnly: false,
activeFolder: null,
searchOptions: { recursive: true },
customFilter: { active: false },
filters: {},
});
FakeWebSocket.instances = [];
FakeWebSocket.failNextConnection = false;
vi.stubGlobal('WebSocket', FakeWebSocket);
});
afterEach(() => {
delete global.fetch;
vi.unstubAllGlobals();
});
function mockFetchPendingScan() {
let resolveScan;
global.fetch = vi.fn((input) => {
const url = String(input);
if (url.includes('/scan')) {
return new Promise((resolve) => { resolveScan = resolve; });
}
// Recipe list reload after the scan completes
return Promise.resolve({
ok: true,
json: async () => ({ items: [], total: 0, total_pages: 0 }),
});
});
return {
resolveOk: (payload = { status: 'success' }) =>
resolveScan({ ok: true, json: async () => payload }),
resolveNotOk: () =>
resolveScan({ ok: false, status: 500, statusText: 'Server Error' }),
};
}
async function startRefresh(fullRebuild = true) {
const promise = refreshRecipes(fullRebuild);
await vi.waitFor(() => {
expect(FakeWebSocket.instances.length).toBe(1);
});
await flushMicrotasks();
const socket = FakeWebSocket.instances[0];
await vi.waitFor(() => {
expect(socket.onmessage).toBeTruthy();
});
return { promise, socket };
}
it('shows scan progress updates from the WebSocket channel', async () => {
const fetchControl = mockFetchPendingScan();
const { promise, socket } = await startRefresh();
expect(socket.url).toBe(`ws://${window.location.host}/ws/fetch-progress`);
socket.emit({
type: 'scan_progress',
status: 'started',
stage: 'scan_folders',
model_type: 'recipe',
pageType: 'recipes',
full_rebuild: true,
progress: 0,
});
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'recipe',
pageType: 'recipes',
full_rebuild: true,
progress: 50,
processed: 5,
total: 10,
current_name: 'style.recipe.json',
});
expect(loadingManagerMock.setProgress).toHaveBeenCalledWith(0);
expect(loadingManagerMock.setProgress).toHaveBeenCalledWith(50);
const lastStatus = loadingManagerMock.setStatus.mock.calls.at(-1)[0];
expect(lastStatus).toContain('(5/10)');
expect(lastStatus).toContain('style.recipe.json');
expect(lastStatus).toContain('ETA soon');
expect(etaUpdateMock).toHaveBeenCalledWith(5, 10);
fetchControl.resolveOk();
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Full rebuild' },
'success'
);
expect(socket.close).toHaveBeenCalled();
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
it('ignores messages for other types or other model types', async () => {
const fetchControl = mockFetchPendingScan();
const { promise, socket } = await startRefresh();
socket.emit({
type: 'scan_progress',
status: 'processing',
stage: 'process_models',
model_type: 'lora',
progress: 33,
processed: 1,
total: 3,
});
socket.emit({
type: 'example_images_progress',
status: 'running',
model_type: 'recipe',
progress: 66,
processed: 2,
total: 3,
});
expect(loadingManagerMock.setProgress).not.toHaveBeenCalled();
expect(loadingManagerMock.setStatus).not.toHaveBeenCalled();
fetchControl.resolveOk();
await promise;
});
it('falls back to plain loading when the WebSocket connection fails', async () => {
FakeWebSocket.failNextConnection = true;
global.fetch = vi.fn((input) => {
const url = String(input);
if (url.includes('/scan')) {
return Promise.resolve({
ok: true,
json: async () => ({ status: 'success' }),
});
}
return Promise.resolve({
ok: true,
json: async () => ({ items: [], total: 0, total_pages: 0 }),
});
});
await refreshRecipes(false);
expect(global.fetch).toHaveBeenCalled();
const [url] = global.fetch.mock.calls[0];
expect(url.searchParams.get('full_rebuild')).toBe('false');
expect(loadingManagerMock.show).toHaveBeenCalledWith('Refreshing Recipes...', 0);
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshComplete',
{ action: 'Refresh' },
'success'
);
});
it('shows the cancelled toast when the server reports cancellation', async () => {
const fetchControl = mockFetchPendingScan();
const { promise } = await startRefresh();
fetchControl.resolveOk({ status: 'cancelled' });
await promise;
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
expect(showToastMock).not.toHaveBeenCalledWith(
'toast.api.refreshComplete',
expect.anything(),
expect.anything()
);
});
it('reports refresh failures through the error toast', async () => {
const fetchControl = mockFetchPendingScan();
const { promise } = await startRefresh();
fetchControl.resolveNotOk();
await promise;
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.refreshFailed',
{ action: 'rebuild', type: 'recipe' },
'error'
);
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
});
@@ -0,0 +1,256 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
};
vi.mock(API_MODULE, () => ({
api: {
fetchApi: fetchApiMock,
},
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: {
ds: { scale: 1 },
},
extensionManager: {
setting: {
get: settingGetMock,
set: vi.fn(),
},
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => caretHelperInstance),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
}));
async function createAutoComplete(modelType, activeFiltersEnabled) {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return activeFiltersEnabled;
}
if (key === 'loramanager.autocomplete_append_comma') return false;
if (key === 'loramanager.autocomplete_auto_format') return false;
if (key === 'loramanager.autocomplete_accept_key') return 'both';
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, relative_paths: [] }),
});
const input = document.createElement('textarea');
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, modelType, { debounceDelay: 0, showPreview: false });
input.value = 'example';
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
return autoComplete;
}
describe('AutoComplete active-filters flag', () => {
beforeEach(() => {
vi.useFakeTimers();
document.body.innerHTML = '';
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset();
settingGetMock.mockReset();
caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 0, top: 0 });
});
afterEach(() => {
vi.useRealTimers();
});
it('sends use_active_filters for loras when the setting is enabled', async () => {
await createAutoComplete('loras', true);
expect(fetchApiMock).toHaveBeenCalledWith(
'/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true'
);
});
it('omits the flag when the setting is disabled', async () => {
await createAutoComplete('loras', false);
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('omits the flag for non-lora model types even when enabled', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [] }),
});
await createAutoComplete('prompt', true);
for (const call of fetchApiMock.mock.calls) {
expect(call[0]).not.toContain('use_active_filters');
}
});
it('does not read filter state from localStorage anymore', async () => {
localStorage.setItem('lora_manager_loras_activeFolder', 'SD_XL');
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({ baseModel: ['SDXL 1.0'] }));
await createAutoComplete('loras', true);
for (const call of fetchApiMock.mock.calls) {
expect(call[0]).not.toContain('folder=');
expect(call[0]).not.toContain('base_model=');
}
});
const typeLorasSlashCommand = async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = 1;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
return autoComplete;
};
it('shows the active-filters state below the loras slash command list', async () => {
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: OFF');
expect(footer.textContent).toContain('/activefilters to enable');
});
it('shows how to disable active-filters search in the footer when it is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
await typeLorasSlashCommand();
const footer = document.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('Active Filters Search: ON');
expect(footer.textContent).toContain('/noactivefilters to disable');
});
it('shows a dismissible first-run hint on loras suggestions and remembers dismissal', async () => {
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const triggerSearch = async () => {
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
return autoComplete;
};
const autoComplete = await triggerSearch();
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
expect(hint).not.toBeNull();
expect(hint.textContent).toContain('/activefilters');
hint.querySelector('button').click();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
expect(localStorage.getItem('lm:activefilters-tip-dismissed')).toBe('1');
// A fresh instance no longer shows the hint once dismissed
const autoComplete2 = await triggerSearch();
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
it('does not show the loras first-run hint when active-filters search is already on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
}
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
relative_paths: ['models/example.safetensors'],
}),
});
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = 7;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'loras', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runOnlyPendingTimersAsync();
await vi.runOnlyPendingTimersAsync();
await Promise.resolve();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
});
@@ -1789,7 +1789,7 @@ describe('AutoComplete widget interactions', () => {
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('appends active filter params to loras autocomplete requests when enabled', async () => {
it('sends only the use_active_filters flag when enabled (filters resolved server-side)', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1799,12 +1799,11 @@ describe('AutoComplete widget interactions', () => {
return undefined;
});
// Stored manager-page filters must NOT leak into the request URL; the
// backend injects them from its server-side store.
localStorage.setItem('lora_manager_loras_filters', JSON.stringify({
baseModel: ['SD 1.5'],
tags: { anime: 'include', nsfw: 'exclude', __no_tags__: 'exclude' },
autoTags: { I2V: 'include' },
modelTypes: ['standard'],
tagLogic: 'all',
tags: { anime: 'include', nsfw: 'exclude' },
license: { noCredit: 'include', allowSelling: 'exclude' },
}));
localStorage.setItem('lora_manager_loras_activeFolder', 'MyLoras');
@@ -1830,19 +1829,7 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('/lm/loras/relative-paths?search=example&limit=100');
expect(calledUrl).toContain('folder=MyLoras');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('tag_include=anime');
expect(calledUrl).toContain('tag_exclude=nsfw');
expect(calledUrl).toContain('tag_exclude=__no_tags__');
expect(calledUrl).toContain('auto_tag_include=I2V');
expect(calledUrl).toContain('tag_logic=all');
expect(calledUrl).toContain('credit_required=false');
expect(calledUrl).toContain('allow_selling_generated_content=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('base_model')).toBe('SD 1.5');
expect(parsed.searchParams.get('model_type')).toBe('standard');
expect(calledUrl).toBe('/lm/loras/relative-paths?search=example&limit=100&use_active_filters=true');
});
it('keeps the default loras autocomplete URL when active-filters mode is off', async () => {
@@ -1870,10 +1857,12 @@ describe('AutoComplete widget interactions', () => {
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/relative-paths?search=example&limit=100');
});
it('sends the filter-pipeline signal even when no filters are stored', async () => {
it('sends the filter-pipeline flag even when no filters are stored', async () => {
// Regression: with filter mode on but no folder/filters stored, the request
// carried no params, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint.
// carried no signal, so the backend skipped the filter pipeline and global
// settings like show_only_sfw diverged from the list endpoint. The flag
// makes the backend run the pipeline (injecting nothing when its store
// is empty).
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1907,10 +1896,13 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('use_active_filters=true');
});
it('omits folder param when active folder is root and recursion is enabled', async () => {
it('leaves folder params to the backend when active folder is root with recursion enabled', async () => {
// The root-folder/recursion semantics now live server-side (see
// active_filters_store.active_filters_to_query_kwargs); the client only
// sends the flag.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1948,10 +1940,12 @@ describe('AutoComplete widget interactions', () => {
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('use_active_filters=true');
});
it('sends an empty folder param for root with recursion disabled, mirroring the page list', async () => {
it('leaves the root+non-recursive folder mapping to the backend', async () => {
// Root with recursion disabled maps to folder='' server-side (mirroring
// the page list); the client no longer encodes this in the URL.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -1988,15 +1982,14 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=');
expect(calledUrl).toContain('recursive=false');
const parsed = new URL(calledUrl, 'https://example.com');
expect(parsed.searchParams.get('folder')).toBe('');
expect(calledUrl).not.toContain('folder=');
expect(calledUrl).toContain('use_active_filters=true');
});
it('applies the active folder even when no filter-panel filters are set', async () => {
it('sends the flag even when only a folder is stored (no filter-panel filters)', async () => {
// Regression: folder was skipped when lora_manager_loras_filters was
// missing because the filters key gate returned early.
// missing because the filters key gate returned early. The flag is now
// unconditional, and the backend injects the folder from its store.
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
@@ -2029,8 +2022,8 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const calledUrl = fetchApiMock.mock.calls[0][0];
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
expect(calledUrl).toContain('recursive=true');
expect(calledUrl).toContain('use_active_filters=true');
expect(calledUrl).not.toContain('folder=');
});
describe('discoverability hints', () => {
@@ -0,0 +1,234 @@
import { describe, it, expect, vi } from 'vitest';
const {
API_MODULE,
APP_MODULE,
CARET_HELPER_MODULE,
PREVIEW_COMPONENT_MODULE,
AUTOCOMPLETE_MODULE,
} = vi.hoisted(() => ({
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
CARET_HELPER_MODULE: new URL('../../../web/comfyui/textarea_caret_helper.js', import.meta.url).pathname,
PREVIEW_COMPONENT_MODULE: new URL('../../../web/comfyui/preview_tooltip.js', import.meta.url).pathname,
AUTOCOMPLETE_MODULE: new URL('../../../web/comfyui/autocomplete.js', import.meta.url).pathname,
}));
vi.mock(API_MODULE, () => ({
api: { fetchApi: vi.fn() },
}));
vi.mock(APP_MODULE, () => ({
app: {
canvas: { ds: { scale: 1 } },
extensionManager: {
setting: { get: vi.fn(), set: vi.fn() },
},
registerExtension: vi.fn(),
},
}));
vi.mock(CARET_HELPER_MODULE, () => ({
TextAreaCaretHelper: vi.fn(() => ({
getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
})),
}));
vi.mock(PREVIEW_COMPONENT_MODULE, () => ({
PreviewTooltip: vi.fn(() => ({ show: vi.fn(), hide: vi.fn(), cleanup: vi.fn() })),
}));
const METADATA_NAME = '__lm_autocomplete_meta_text';
function makeMetadataValue() {
return {
version: 1,
textWidgetName: 'text',
lastAccepted: {
start: 0,
end: 6,
insertedText: '1girl ',
textSnapshot: 'old prompt text, 1girl ',
},
};
}
describe('stripAutocompleteLastAccepted', () => {
let stripAutocompleteLastAccepted;
beforeAll(async () => {
const module = await import(AUTOCOMPLETE_MODULE);
stripAutocompleteLastAccepted = module.stripAutocompleteLastAccepted;
});
it('removes lastAccepted while keeping the metadata base fields', () => {
const value = makeMetadataValue();
const stripped = stripAutocompleteLastAccepted(value);
expect(stripped).toEqual({ version: 1, textWidgetName: 'text' });
expect('lastAccepted' in stripped).toBe(false);
// Original value must not be mutated
expect(value.lastAccepted).toBeDefined();
});
it('returns values without lastAccepted as-is (same reference)', () => {
const value = { version: 1, textWidgetName: 'text' };
expect(stripAutocompleteLastAccepted(value)).toBe(value);
});
it('returns non-object values as-is', () => {
expect(stripAutocompleteLastAccepted(null)).toBe(null);
expect(stripAutocompleteLastAccepted(undefined)).toBe(undefined);
expect(stripAutocompleteLastAccepted('text')).toBe('text');
expect(stripAutocompleteLastAccepted([1, 2])).toEqual([1, 2]);
});
});
describe('stripAutocompleteMetadataFromPromptResult', () => {
let stripResult;
beforeAll(async () => {
const module = await import(AUTOCOMPLETE_MODULE);
stripResult = module.stripAutocompleteMetadataFromPromptResult;
});
function makeWorkflowNode() {
const metadataValue = makeMetadataValue();
return {
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
widgets_values: ['current text', metadataValue],
widgets_values_named: {
text: 'current text',
[METADATA_NAME]: metadataValue,
},
};
}
it('strips lastAccepted from workflow widgets_values using __lm_widget_ids alignment', () => {
const result = {
workflow: { nodes: [makeWorkflowNode()] },
output: {},
};
const returned = stripResult(result);
expect(returned).toBe(result);
expect(result.workflow.nodes[0].widgets_values[1])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('strips lastAccepted from widgets_values_named and leaves other widgets untouched', () => {
const result = {
workflow: { nodes: [makeWorkflowNode()] },
output: {},
};
stripResult(result);
const node = result.workflow.nodes[0];
expect(node.widgets_values_named[METADATA_NAME])
.toEqual({ version: 1, textWidgetName: 'text' });
expect(node.widgets_values_named.text).toBe('current text');
expect(node.widgets_values[0]).toBe('current text');
});
it('handles null entries in widgets_values (bypass compatibility padding)', () => {
const node = makeWorkflowNode();
node.properties.__lm_widget_ids = ['text', 'seed', METADATA_NAME];
node.widgets_values = ['current text', null, makeMetadataValue()];
const result = { workflow: { nodes: [node] }, output: {} };
stripResult(result);
expect(result.workflow.nodes[0].widgets_values[1]).toBe(null);
expect(result.workflow.nodes[0].widgets_values[2])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('still strips widgets_values_named when __lm_widget_ids is missing (legacy files)', () => {
const node = makeWorkflowNode();
delete node.properties;
const arrayValue = node.widgets_values[1];
const result = { workflow: { nodes: [node] }, output: {} };
stripResult(result);
// Array entries cannot be located without widget ids — left untouched
expect(result.workflow.nodes[0].widgets_values[1]).toBe(arrayValue);
expect(result.workflow.nodes[0].widgets_values_named[METADATA_NAME])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('strips lastAccepted from output (API prompt) inputs', () => {
const result = {
workflow: { nodes: [] },
output: {
'7': {
class_type: 'Prompt (LoraManager)',
inputs: {
text: 'current text',
[METADATA_NAME]: makeMetadataValue(),
},
},
},
};
stripResult(result);
const inputs = result.output['7'].inputs;
expect(inputs[METADATA_NAME]).toEqual({ version: 1, textWidgetName: 'text' });
expect(inputs.text).toBe('current text');
});
it('strips lastAccepted inside subgraph definitions', () => {
const result = {
workflow: {
nodes: [],
definitions: {
subgraphs: [{ nodes: [makeWorkflowNode()] }],
},
},
output: {},
};
stripResult(result);
const subgraphNode = result.workflow.definitions.subgraphs[0].nodes[0];
expect(subgraphNode.widgets_values_named[METADATA_NAME])
.toEqual({ version: 1, textWidgetName: 'text' });
});
it('leaves results without lastAccepted unchanged', () => {
const metadataValue = { version: 1, textWidgetName: 'text' };
const result = {
workflow: {
nodes: [{
properties: { __lm_widget_ids: ['text', METADATA_NAME] },
widgets_values: ['abc', metadataValue],
widgets_values_named: { text: 'abc', [METADATA_NAME]: metadataValue },
}],
},
output: {
'1': { inputs: { text: 'abc', [METADATA_NAME]: metadataValue } },
},
};
stripResult(result);
expect(result.workflow.nodes[0].widgets_values[1]).toBe(metadataValue);
expect(result.output['1'].inputs[METADATA_NAME]).toBe(metadataValue);
});
it('tolerates malformed results', () => {
expect(stripResult(null)).toBe(null);
expect(stripResult(undefined)).toBe(undefined);
expect(stripResult({})).toEqual({});
const result = {
workflow: { nodes: [null, { widgets_values: null }] },
output: { '1': { inputs: null }, '2': {} },
};
expect(() => stripResult(result)).not.toThrow();
});
});
@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
SETTINGS_MODULE,
LORA_LOADER_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
SETTINGS_MODULE: new URL("../../../web/comfyui/settings.js", import.meta.url).pathname,
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
}));
const extensionState = { current: null };
const registerExtensionMock = vi.fn((extension) => {
extensionState.current = extension;
});
vi.mock(APP_MODULE, () => ({
app: {
registerExtension: registerExtensionMock,
graph: {},
},
}));
vi.mock(API_MODULE, () => ({
api: {
addEventListener: vi.fn(),
},
}));
const showToastMock = vi.fn();
vi.mock(UTILS_MODULE, () => ({
collectActiveLorasFromChain: vi.fn(),
updateConnectedTriggerWords: vi.fn(),
mergeLoras: vi.fn(),
chainCallback: (proto, property, callback) => {
proto[property] = callback;
},
getAllGraphNodes: vi.fn(),
getNodeFromGraph: vi.fn(),
getWidgetByName: vi.fn(),
getWidgetSerializedValue: vi.fn(),
showToast: showToastMock,
}));
const getActiveFiltersPreferenceMock = vi.fn();
const setSettingValueMock = vi.fn();
vi.mock(SETTINGS_MODULE, () => ({
LORA_ACTIVE_FILTERS_AUTOCOMPLETE_SETTING_ID:
"loramanager.lora_active_filters_autocomplete",
getLoraActiveFiltersAutocompletePreference: getActiveFiltersPreferenceMock,
setLoraManagerSettingValue: setSettingValueMock,
}));
async function registerNodeType(comfyClass) {
await import(LORA_LOADER_MODULE);
const extension = extensionState.current;
expect(extension).toBeDefined();
const nodeType = { comfyClass, prototype: {} };
await extension.beforeRegisterNodeDef(nodeType, {}, {});
return nodeType;
}
function getMenuOption(nodeType, enabled) {
getActiveFiltersPreferenceMock.mockReturnValue(enabled);
const options = [];
nodeType.prototype.getExtraMenuOptions(null, options);
return options.find(
(option) =>
option &&
typeof option.content === "string" &&
option.content.startsWith("Active Filters Search:")
);
}
describe("Lora Loader active-filters context menu", () => {
beforeEach(() => {
vi.resetModules();
extensionState.current = null;
registerExtensionMock.mockClear();
showToastMock.mockClear();
getActiveFiltersPreferenceMock.mockReset();
setSettingValueMock.mockReset();
setSettingValueMock.mockResolvedValue(true);
});
it.each([
"Lora Loader (LoraManager)",
"Lora Stacker (LoraManager)",
"WanVideo Lora Select (LoraManager)",
"Create Hook LoRA (LoraManager)",
])("adds the toggle entry to the %s context menu", async (comfyClass) => {
const nodeType = await registerNodeType(comfyClass);
const option = getMenuOption(nodeType, false);
expect(option).toBeDefined();
expect(option.content).toContain("Active Filters Search: OFF");
expect(option.content).toContain("/activefilters to enable");
});
it("shows the disable hint when active-filters search is on", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const option = getMenuOption(nodeType, true);
expect(option.content).toContain("Active Filters Search: ON");
expect(option.content).toContain("/noactivefilters to disable");
});
it("toggles the setting and toasts feedback", async () => {
const nodeType = await registerNodeType("Lora Loader (LoraManager)");
const enableOption = getMenuOption(nodeType, false);
await enableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
true
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Enabled" })
);
const disableOption = getMenuOption(nodeType, true);
await disableOption.callback();
expect(setSettingValueMock).toHaveBeenCalledWith(
"loramanager.lora_active_filters_autocomplete",
false
);
expect(showToastMock).toHaveBeenCalledWith(
expect.objectContaining({ summary: "Active Filters Search Disabled" })
);
});
});
@@ -43,6 +43,12 @@ vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
openCivitaiByMetadata: openCivitaiByMetadataMock,
updatePanelPositions: updatePanelPositionsMock,
// Faithful stand-in for the real helper in uiHelpers.js
isTypingContext: (target) => {
if (!(target instanceof Element)) return false;
const tagName = target.tagName?.toLowerCase();
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
},
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
@@ -350,6 +356,49 @@ describe('FilterManager tag and base model filters', () => {
expect(baseModelChip.classList.contains('active')).toBe(false);
});
it('filters recipes by the unknown base model bucket via its marker value', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
base_models: [
{ name: 'Unknown', value: '__unknown__', count: 3 },
{ name: 'SDXL', count: 2 },
],
}),
});
renderControlsDom('recipes');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('recipes');
const { getCurrentPageState } = stateModule;
const { FilterManager } = await import('../../../static/js/managers/FilterManager.js');
const loadRecipesMock = vi.fn().mockResolvedValue(undefined);
window.recipeManager = { loadRecipes: loadRecipesMock };
new FilterManager({ page: 'recipes' });
await vi.waitFor(() => {
const chip = document.querySelector('[data-base-model="__unknown__"]');
expect(chip).not.toBeNull();
});
const unknownChip = document.querySelector('[data-base-model="__unknown__"]');
// Display label is "Unknown" even though the filter value is the marker
expect(unknownChip.textContent).toContain('Unknown');
unknownChip.dispatchEvent(new Event('click', { bubbles: true }));
await vi.waitFor(() => expect(loadRecipesMock).toHaveBeenCalledTimes(1));
expect(getCurrentPageState().filters.baseModel).toEqual(['__unknown__']);
expect(unknownChip.classList.contains('active')).toBe(true);
const storageKey = 'lora_manager_recipes_filters';
const storedFilters = JSON.parse(localStorage.getItem(storageKey));
expect(storedFilters.baseModel).toEqual(['__unknown__']);
});
it('filters base model chips locally without changing selected state', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
@@ -1157,4 +1206,87 @@ describe('PageControls favorites, sorting, and duplicates scenarios', () => {
vi.useRealTimers();
});
});
});
describe('PageControls action keyboard shortcuts', () => {
async function setupLorasControls() {
renderControlsDom('loras');
const stateModule = await import('../../../static/js/state/index.js');
stateModule.initPageState('loras');
const { LorasControls } = await import('../../../static/js/components/controls/LorasControls.js');
return new LorasControls();
}
function keydownEvent(key, { target = document.body, ...init } = {}) {
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init });
Object.defineProperty(event, 'target', { value: target });
return event;
}
it('registers a pageControls-actions keydown handler with the event manager', async () => {
await setupLorasControls();
const { eventManager } = await import('../../../static/js/utils/EventManager.js');
const keydownHandlers = eventManager.handlers.get('keydown') || [];
expect(keydownHandlers.some((h) => h.source === 'pageControls-actions')).toBe(true);
});
it('triggers refresh, fetch, and download via the R / F / D keys', async () => {
const controls = await setupLorasControls();
expect(controls.handleActionShortcut(keydownEvent('r'))).toBe(true);
expect(refreshModelsMock).toHaveBeenCalledWith(false);
expect(controls.handleActionShortcut(keydownEvent('f'))).toBe(true);
expect(fetchCivitaiMetadataMock).toHaveBeenCalledTimes(1);
expect(controls.handleActionShortcut(keydownEvent('d'))).toBe(true);
expect(downloadManagerMock.showDownloadModal).toHaveBeenCalledTimes(1);
});
it('handles a real keydown dispatched on the document', async () => {
await setupLorasControls();
const event = new KeyboardEvent('keydown', { key: 'r', bubbles: true, cancelable: true });
document.dispatchEvent(event);
expect(event.defaultPrevented).toBe(true);
expect(refreshModelsMock).toHaveBeenCalledWith(false);
});
it('ignores R / F / D while typing in an input', async () => {
const controls = await setupLorasControls();
const input = document.getElementById('searchInput');
for (const key of ['r', 'f', 'd']) {
const event = keydownEvent(key, { target: input });
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
}
expect(refreshModelsMock).not.toHaveBeenCalled();
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
expect(downloadManagerMock.showDownloadModal).not.toHaveBeenCalled();
});
it('ignores R / F / D when combined with modifier keys', async () => {
const controls = await setupLorasControls();
const event = keydownEvent('r', { ctrlKey: true });
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(refreshModelsMock).not.toHaveBeenCalled();
});
it('passes the event through when the action button does not exist', async () => {
const controls = await setupLorasControls();
// Recipes page has no fetch/download buttons
document.querySelector('[data-action="fetch"]').closest('.control-group').remove();
const event = keydownEvent('f');
expect(controls.handleActionShortcut(event)).toBe(false);
expect(event.defaultPrevented).toBe(false);
expect(fetchCivitaiMetadataMock).not.toHaveBeenCalled();
});
});

Some files were not shown because too many files have changed in this diff Show More