Compare commits

...

84 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
Will Miao d761ac77f7 fix(recipes): align LoRA reconnect affordances with checkpoint rules
- Offer reconnect for name-only LoRA entries with no CivitAI
  identifiers, matching the checkpoint "broken" classification
  instead of rendering no action at all
- Mark a LoRA hash-invalid when a direct (modelId/versionId) download
  fails with a clearly unresolvable error, mirroring the checkpoint
  path; transient failures leave the entry untouched
2026-08-30 18:33:24 +08:00
Will Miao c8b9db5bf4 feat(recipes): add manual checkpoint reconnect for broken recipe entries
Checkpoint entries that cannot be restored by download (deleted,
unresolvable hash, or name-only remnants with no CivitAI identifiers)
now get the same remediation chain LoRAs already had:

- scanner: parameterized reconnect-suggestion ranking, update/restore/
  set-hash-invalid for the checkpoint entry, and clear hashInvalid on
  rematch write-back (was only done for LoRAs)
- persistence/handlers/routes: reconnect/restore/reconnect-suggestions/
  mark-hash-invalid endpoints under /api/lm/recipe/checkpoint/*
- modal: checkpoint reconnect UI (deleted/hash-invalid badges, inline
  form with suggestions, undo for reconnected entries); download
  failures mark the hash invalid only on explicit unresolvable signals
  (not found/deleted/404/410), matching the LoRA rule
- css: checkpoint undo button shares the LoRA undo styles
- i18n: the 14 new keys translated in all 9 locales
2026-08-30 18:02:15 +08:00
Will Miao bce7d1d30c docs(i18n): resolve R1 vs R8/§7 contradiction on proactive translation
R1 instructed agents to "translate the newly added keys in every locale"
right after syncing, while R8 and §7 make [TODO: Translate] placeholders
the sanctioned end state during feature development until the feature
owner explicitly asks for translations. Reword R1 and the AGENTS.md
Localization section to say stop after syncing and never translate
proactively.
2026-08-30 16:28:53 +08:00
Will Miao bccd494a56 feat(recipes): explain empty LoRA lists with collapsible "Why no LoRAs?" panel
Record import provenance on every recipe: a new import_info block
(channel, machine-readable no-LoRA reason, diagnostic details) built at
import time across all channels (batch import, single URL, local file,
upload, widget save, re-imports) and persisted in the recipe JSON plus
the SQLite persistent cache (new import_info_json column with ALTER
TABLE migration).

The recipe modal renders the empty LoRA list with a collapsed details
panel showing the import method, the reason (CivitAI API returned no
LoRA resource data, API meta missing, no embedded metadata, ComfyUI
workflow metadata, video, unparsable format), and recorded diagnostics.
Legacy recipes without import_info fall back to heuristics labeled as
inferred. Genuine no-LoRA generations show no panel.

CivitAI images are always classified by API meta shape: the onsite
generator writes A1111-style EXIF without LoRA references, so parsed
EXIF cannot prove "no LoRAs used".

Adds recipes.resources.noLoras* i18n keys (all 10 locales) plus
frontend vitest and backend pytest coverage.
2026-08-30 16:28:41 +08:00
Will Miao 3fd29f6943 remove(nodes): delete Random Checkpoint Loader and Random Unet Loader nodes
- Remove py/nodes/random_checkpoint_loader.py and random_unet_loader.py
- Remove their dedicated test file
- Clean up imports and NODE_CLASS_MAPPINGS in __init__.py
- Update loader-pool comments/docstrings to reference the remaining Checkpoint/Unet Loader nodes' control_after_generate feature
2026-08-30 11:38:01 +08:00
Will Miao 838a374a56 feat(recipes): reconnect suggestions, undo, and base-model family tolerance
Enhance the deleted-LoRA reconnect flow in the recipe modal:

- Suggest local reconnect candidates when the panel opens, ranked by
  identity (same hash / same CivitAI version) then filename/name
  similarity, with a hard filter on confident base-model mismatches;
  the input gets a Combobox backed by the same endpoint as you type.
- Snapshot the pre-reconnect entry and offer a permanent restore:
  reconnected entries show an undo icon at the right end of the info
  row, with the original filename in the tooltip.
- Relax the manual reconnect base-model guard to a three-tier check:
  exact/unknown labels pass silently, same-architecture families
  (e.g. Pony <-> Illustrious) pass with a warning toast, and only
  cross-architecture mismatches stay hard-rejected.
2026-08-30 08:17:35 +08:00
Will Miao 6e31da7a70 fix(recipes): polish deleted-LoRA reconnect panel UI
- fix .reconnect-input overflow (calc(100% - 20px) -> border-box 100%)
- replace nested-card border/background with a dashed top separator
- route reconnect copy through translate(); add recipes.resources
  .reconnectInstructions/reconnectExample/reconnectPlaceholder keys
  and translate them in all 9 locales
- show reconnect failures inline in the panel (role=alert) instead of
  a transient toast; errors clear on input/show/hide
- drop dead .reconnect-instructions code CSS; add regression test
2026-08-29 18:09:33 +08:00
Will Miao fc9088bfd6 feat(recipes): restore Copy Recipe Syntax button in recipe modal header
- Add an icon-only copy button next to Send to ComfyUI in the header
  actions row, styled as a textless variant of the neighboring pill
  buttons
- Restore fetchAndCopyRecipeSyntax() wiring against the existing
  /api/lm/recipe/{id}/syntax endpoint (context menu action unaffected)
- Add recipes.actions.copyRecipeSyntax i18n key, reusing the existing
  per-locale translations of the identical context menu string
- Sync modal test fixtures and add copySyntax tests
2026-08-29 17:05:31 +08:00
Will Miao 675421ea84 fix(recipes): render reconnect form for hash-invalid LoRAs in recipe modal
The Reconnect action button was rendered for both deleted and hash-invalid
(Unresolvable Hash) LoRA entries, but the .lora-reconnect-container input
form was only rendered for deleted ones. Clicking Reconnect on a
hash-invalid item silently did nothing because showReconnectInput() could
not find the container. Align the container render condition with the
button condition, and extend the resource-items test to assert the form
opens on click.
2026-08-29 16:49:50 +08:00
Will Miao 2ff98ae089 docs(i18n): defer non-en translations until UI wording is final
[TODO: Translate] placeholders are now the sanctioned intermediate state
during feature development; translate all pending keys in one pass only
when the feature owner asks. R8 notes the exemption so placeholders are
not 'fixed' prematurely.
2026-08-29 16:41:53 +08:00
Will Miao c972c755fc fix(recipes): distinguish unobtainable LoRAs in recipe status and skip them in syntax
The recipe card pill counted LoRAs deleted from the source (isDeleted) as
available, showing a green 'ready 2/2' for recipes that cannot be fully
reproduced. LoRAs with an unresolvable hash (hashInvalid) were counted as
missing/downloadable even though downloads always fail, and recipe syntax
generation emitted broken tokens for them.

- Four-state status on RecipeCard pill and RecipeTab badge: ready (all in
  library), missing (downloadable, red, keeps the action cue), partial
  (unobtainable entries skipped when used, amber, fa-circle-minus),
  unavailable (nothing usable, gray, fa-ban)
- Pill numerator is now the real in-library count; tooltips spell out
  missing vs unavailable (deleted from source or unresolvable hash)
- get_recipe_syntax_tokens skips hashInvalid entries like deleted ones
  instead of emitting tokens pointing at nonexistent files
- Bulk missing-download manager and recipe context menu exclude
  hashInvalid LoRAs, matching the modal's per-item download block
- New locale keys loraStatus.missingAndUnavailable/partial/noneUsable,
  translated for all 9 non-en locales
2026-08-29 16:41:48 +08:00
Will Miao ebe3df7d22 docs(i18n): mark guidelines as the post-sweep target state; translate de playlist title
§3/§5/§6 now describe the resolved state (regression watch-list instead of a
to-do list), §4 documents the single intentional placeholder deviation
(mappingsUpdated drops {plural} where '<noun>s' cannot be appended). de
help.updateVlogs.playlistTitle translated.
2026-08-29 12:49:38 +08:00
Will Miao be44a75b74 fix(i18n): punctuation polish — ASCII colons/parens, '...' ellipsis, fr apostrophe
- fullwidth ':{message}/{error}' in fr/de/es/ru/he toasts -> ASCII
  (fr keeps the spaced ' : ' convention)
- fullwidth parens in bulk skip/resume count labels -> ASCII
- ru modals.download.selectHfFiles trailing fullwidth colon -> ASCII
- '…' -> '...' in all 9 locales (project style)
- fr header.filter.allowSellingGeneratedContentTooltip: d"images -> d'images
2026-08-29 12:48:05 +08:00
Will Miao fd1227d3b8 fix(i18n): translate banners, license labels, doctor UI and remaining leftovers
- banners.communitySupport.* (title/content/CTA/learnMore): 8 locales (zh-CN done)
- modals.model.license.noImageSell/noRentCivit/noRent/noSell: all 9 locales
- globalContextMenu.fetchMissingLicenses.*: 7 locales
- doctor.* issue titles, action labels, conflicts/version labels + es title
- toasts/settings: libraryLoadFailed/libraryActivateFailed, moveFailed,
  restartRequired, recipeSaved across locales; fr recipes storage path strings;
  zh-CN import lora count; ru/ko Recipe Manager init title
- checkpoints.modelTypes.diffusion_model translated in 7 locales (ja/ko keep
  the English loanword, consistent with their model-type names)
2026-08-29 12:47:17 +08:00
Will Miao 3a9e02137d fix(i18n): translate the batch-import UI for fr/de/es/ru/he/ja/ko
The whole recipes.batchImport section (~54 keys) and the
toast.recipes.batchImport* toasts (~8 keys) were byte-identical to en.json.
Translated using the normalized terminology (Recipe/Rezept/receta/рецепт/
מתכון/レシピ/레시피, bulk names: groupé/Massenimport/por lotes/пакетный/
בכמות גדולה/一括/일괄). URL/path placeholders stay as-is; identical words
(French 'Total', 'images') are legitimately unchanged.
2026-08-29 12:45:04 +08:00
Will Miao d8a2be8edc fix(i18n): normalize terminology and register across all locales
One term = one rendering per language; the mandatory fixes (see
docs/i18n-translation-guidelines.md §2/§5):
- fr: recette(s) -> Recipe(s) per glossary decision; checkpoint literal
  'Point de contrôle'/'hachage'/'étiquettes'/'dupliquées'/'mode lot' unified
- de: leftover English 'Recipe' -> Rezept; Basis-Modell -> Basismodell;
  Modelldaten -> Metadaten; bulk action label; du -> Sie (formal)
- es: 'Punto(s) de control' -> Checkpoint(s); flujo de trabajo -> workflow;
  palabras clave -> palabras de activación; preset -> preajuste; bulk -> por lotes
- ru: Контрольные точки/Чекпойнт -> Checkpoint; Эмбеддинг -> Embedding;
  запрос -> промпт (prompt sense only); рабочий процесс -> workflow; хэш -> хеш;
  безпотерьного typo
- he: נקודות ביקורת -> Checkpoint(s) (was literal road checkpoint); הטמעות ->
  Embedding; האש/גיבוב -> hash (האש reads as 'the fire'); הנחיה -> פרומפט;
  מטא-דאטה -> מטא-נתונים; דגם -> מודל; bulk feature name unified
- ja: チェックポイント/checkpoint -> Checkpoint; バルクモード -> 一括モード;
  recipe counter 個 -> 件; leftover English Recipe Manager translated
- ko: 체크포인트 -> Checkpoint; 임베딩 -> Embedding; 기본 모델 -> 베이스 모델;
  워크플로우 -> 워크플로; 벌크 모드 -> 일괄 모드; Checkpoint을 -> Checkpoint를
- zh-CN: 食谱 -> 配方; 检查点 -> Checkpoint; 基模型 -> 基础模型; 您 -> 你
- zh-TW: 食譜 -> 配方; 檢查點 -> Checkpoint; 你 -> 您 (18 keys)
2026-08-29 12:42:32 +08:00
Will Miao 1c46b2e8c3 fix(i18n): normalize CivitAI brand casing and civitai.red URL placeholders
- en.json: 49 values used 'Civitai' (lowercase 'ai'); normalize to the
  official 'CivitAI' casing and mirror in all 9 locales (key names like
  relinkCivitai/civitaiApiKey intentionally untouched)
- modals.relinkCivitai.helpText.format4: fix 'CivitArchive' typo -> 'CivArchive'
  in all locales (mirrored from en.json)
- recipes.controls.import.urlPlaceholder / modals.relinkCivitai.urlPlaceholder:
  restore the dropped 'https://civitai.red/...' alternative in 8 locales
  (zh-CN already had it)
2026-08-29 12:37:30 +08:00
Will Miao 3c3ac49f2f fix(i18n): correct stale help texts, inverted ko tag logic and placeholder contracts
- viewLocalTooltip: all 9 locales said 'coming soon'; describe the actual
  action (show local versions on main page)
- settings.downloadSkipBaseModels.help / hideEarlyAccessUpdates.help /
  aiProvider.apiBaseHelp: retranslate all locales to the current en wording
  (previous translations described an older source string)
- ko header.filter.tagLogicAny: 'all tags match' was inverted and identical
  to tagLogicAll; fix zh-TW typo 票籤 -> 標籤
- modals.checkUpdates.title/message: restore {typePlural} in zh-CN/zh-TW/ja/ko
- zh-CN recipes.controls.import.downloadLocationPreview: drop invented {path}
  (caller passes no params; it rendered literally)
- zh-TW toast.controls.refreshFailed: restore {action} placeholder
- toast.settings.mappingsUpdated: drop English-inflection {plural} where '<noun>s'
  would corrupt the noun (zh-CN/zh-TW/ja/ko/de/ru/he); caller passes hardcoded 's'
2026-08-29 12:36:18 +08:00
Will Miao 1a1be95a64 docs(i18n): add translation guidelines with per-locale term conventions
Audit of all 10 locale files found recipe/checkpoint mistranslations,
inverted ko tag logic, stale help texts, placeholder contract deviations,
and untranslated feature blocks. Document the conventions (R1-R9), per-
language term maps, confusion hot-spots, and the translation workflow so
future agents and translators follow the established decisions (e.g. keep
'Recipe' untranslated in French, use 配方 in Chinese).
2026-08-29 12:16:52 +08:00
Will Miao 7a36659a20 fix(downloads): preserve aria2 partial pair and refresh expired CivitAI signed URLs
A failed aria2 transfer deleted the partial payload while keeping its
.aria2 control file, and "No URI available" (expired CivitAI signed URL)
was treated as a permanent failure, wasting nearly-complete downloads.

- Re-schedule the transfer with a freshly resolved signed URL and
  continue=true when aria2 reports "No URI available", bounded by
  MAX_TRANSFER_RECOVERY_ATTEMPTS
- Keep payload and .aria2 control file together as a resumable pair
  after a failed transfer instead of deleting the payload
- Report and remove orphaned .aria2 control files that have no payload,
  both after failures and when restoring persisted downloads

Fixes #1088
2026-08-29 11:31:16 +08:00
Will Miao cb18281b14 fix(recipes): pin recipe modal badge sizing against import-modal.css collision
import-modal.css is loaded after recipe-modal.css and its unscoped
.missing-badge/.deleted-badge (equal specificity) were clobbering the
recipe modal's badge family, leaving invalid-hash-badge (no import
counterpart) at a different size. Scope the recipe status-badge sizing
under #recipeModal so import-modal.css can't override it. Also remove the
duplicate .deleted-badge block in import-modal.css.
2026-08-28 22:54:25 +08:00
Will Miao 856c9a87ac fix(recipes): resolve stale LoRA hash on import and add hashInvalid state
- import: prefer A1111 Lora hashes (12-char AutoV3) over conflicting Hashes
  JSON values; recover the quote-wrapped AutoV3 from CivitAI image API meta;
  merge EXIF-parsed LoRAs when the API-only parse yields none (meta=null)
- rematch: treat entries whose hash failed CivitAI resolution (hashInvalid)
  as unresolved candidates; clear the flag on rematch/reconnect write-back
- download: persist hashInvalid and show a distinct toast when hash lookup
  returns "Model not found", so unresolvable entries become recoverable
- ui: add Unresolvable Hash badge styling and reconnect affordance
- i18n: translate the new keys across all 10 locales
2026-08-28 22:24:07 +08:00
Will Miao a7d65fe84a feat(recipes): redesign resource item badges and actions in recipe modal
- Badges are pure status indicators with tooltips; remediation moves to a
  per-item action row (Download / Reconnect), matching the versions-tab
  badge/button pattern
- Civitai link inlines with the model title; the action row renders only
  when real actions exist, removing empty-row whitespace
- Single-LoRA download resolves identifiers from hash on demand (same
  fallback as the bulk missing-download flow) and shows immediate
  'Preparing download' feedback while resolving
- Successful downloads (LoRA and checkpoint) refresh the resources
  section and the recipe card in place, mirroring the bulk flow
- Row navigation is limited to in-library items with keyboard support;
  checkpoint type renders as muted text instead of a chip; badges use
  tonal styling; the local-path hover tooltip is removed
- Add resourceItems frontend tests and translate the new keys for all
  10 locales
2026-08-28 09:30:37 +08:00
Will Miao 15bf079af2 docs: remove git commit message guidelines from AGENTS.md 2026-08-27 22:38:52 +08:00
Will Miao 65ba750634 feat(recipes): improve recipe LoRA status indicators and missing-badge affordance (#1076)
- Recipe card: compact status pill with state icon + available/total
  fraction (e.g. "2/3"), pinned to the footer bottom-right like model
  card actions; status is encoded by icon + color, never color alone
- Recipe modal: "N missing" is now a real <button> with a persistent
  border, leading download icon, focus-visible ring and aria-label;
  clicking opens the download-missing flow
- Fix context menu missing-LoRA detection selector after badge refactor
- i18n: add recipes.status/loraStatus keys with translations for all
  10 locales, and fill pending rate-limit translations
2026-08-27 22:37:10 +08:00
Will Miao 17dcbd3d4f fix(delete): merge delete batches manifest-only, never move files
Bulk delete merged staged batches by physically moving each loser's
files into the winner's batch dir with os.rename. Cross-volume bulks
(winner and loser on different filesystems) always hit EXDEV, forcing a
rollback and degrading to the batch_ids array with per-batch undo.

Merge is now manifest-only: loser entries are appended to the winner's
manifest with their staged paths unchanged, so staged files keep living
in each model's own .lm-pending-delete/<batch_id> dir (no data IO, no
EXDEV). Loser dirs are recorded in the winner manifest's merged_sources
and each loser manifest is stamped merged_into so its own purge timer, a
post-restart sweep or a direct undo call no-op. A cross-volume bulk is
one undoable batch again, and undo/purge clean up the loser dirs once
the merged batch settles.
2026-08-27 19:32:17 +08:00
Will Miao e914a0e19d fix(ui): reconcile model listing in place after download (#1078)
Stop resetting the whole listing after a successful download. The legacy
flow reloaded page 1, scrolled to the top and hijacked the sidebar's
active folder whenever a custom target folder was used, which made the
Updates view lose its place (and sometimes render as an empty page).

Downloads only flip the update flag for one model, so the listing is now
reconciled in place through the virtual scroller:

- Updates view: the model's cards are removed once its newest eligible
  version is installed (the flag is model-level).
- Normal listing: the card stays; only update_available is cleared.
- Model not in the current view (different folder/filter/window):
  no-op; the sidebar folder tree alone is refreshed.
- Falling back to the legacy reload only when no virtual scroller is
  available (e.g. recipes page, duplicates mode, HF downloads).
2026-08-27 18:41:57 +08:00
Will Miao 2ba04bb1bd docs: merge CLAUDE.md content into AGENTS.md and remove CLAUDE.md 2026-08-27 18:41:57 +08:00
Will Miao 1b7314591a docs(skill): streamline lora-manager-e2e and gate usage to true integration checks
- Add a 'when to use / when not to use' gate: UI behavior questions
  default to Vitest/jsdom, E2E only for behavior spanning server +
  browser; description updated so the skill triggers less eagerly
- Pin the browser driver to Chrome DevTools MCP and explain why
  kimi-webbridge (user's real browser) is not a substitute
- Drop generic MCP pattern boilerplate duplicated by
  references/mcp-cheatsheet.md (SKILL.md 385 -> 145 lines)
- Move recipe rematch fixture / fresh-state / cancel-gap notes to
  references/recipe-rematch-fixtures.md
2026-08-27 18:15:04 +08:00
Will Miao 2bfb987312 feat(models): add shared searchable base model picker and overhaul bulk base model modal
- Extract a shared BaseModelPicker (search, keyboard navigation,
  filename-based suggestions, dynamic API models such as MiniMax H3
  under 'Other (API)') used by both the single-model metadata modal
  and the bulk base model modal
- Rework the bulk base model modal into a dedicated inline-list
  layout: fixed modal size, sticky-free footer with app-standard
  modal-actions/primary-btn/cancel-btn buttons, and an inline option
  list that scrolls itself instead of an overlay dropdown covering
  the footer
- Selecting an option in change mode now filters the list to the
  selection instead of resetting and scroll-jumping to it
- Restore opaque sticky section headers in the bulk modal so scrolled
  items no longer bleed through
2026-08-27 18:07:28 +08:00
Will Miao df34efafbc feat(recipes): skip rate-limited batch-import items and register download 429s (#1085)
Phase 2 of docs/plans/issue-1085-rate-limit-design.md:

- Batch import: items that fail due to vendor rate limiting are now
  SKIPPED with a "re-run the import later" hint instead of FAILED, so a
  transient 429 no longer pollutes failure accounting; the progress
  broadcast carries a rate_limited flag.
- Batch import UI: show a one-time "rate limited — slowing down" toast
  and swap the running status text while rate_limited; i18n keys synced
  to all locales.
- Downloader: download_file / download_to_memory / get_response_headers
  register 429 cooldowns with the RateLimitCoordinator, so subsequent
  API calls queue behind a download-triggered rate-limit window.
2026-08-27 10:08:32 +08:00
Will Miao c2a2048c8b feat(services): add per-destination rate-limit gate for API traffic (#1085)
Implement Phase 1 of docs/plans/issue-1085-rate-limit-design.md:

- New RateLimitCoordinator: per-host shared Retry-After gate with
  exponential backoff (30s base, 1800s cap), minimum inter-request pacing
  (default 0.75s), herd-free waiter serialization via per-destination
  locks, and a bounded wait (default 300s) that raises instead of parking.
- Downloader.make_request: connectivity-guard fail-fast first, then gate
  pacing; on 429 register the cooldown and wait-and-resend (bounded);
  errors that passed through the gate are marked gate_handled.
- FallbackMetadataProvider / MetadataSyncService: a network provider 429
  no longer fails over to other network providers (stops the CivArchive
  flood); sqlite stays as local last resort. Rate-limited lookups now
  report "Rate limited" instead of "Model not found", so transient 429s
  no longer mark models civitai_deleted.
- _RateLimitRetryHelper skips its own sleep for gate_handled errors,
  removing the double wait.
- New settings: rate_limit_gate_enabled, rate_limit_max_wait_seconds,
  rate_limit_min_interval_seconds.
2026-08-27 09:53:07 +08:00
Will Miao 1e1921cabb docs(plans): rate-limit abidance design for recipe ingest (#1085) 2026-08-27 09:02:42 +08:00
Will Miao ee233548e5 fix(recipes): enforce batch-import concurrency bound and harden ingest errors (#1085)
Address the rate-limit flood and secondary errors seen during large
recipe ingestion (example-images directory import):

- batch import: share one adaptive-concurrency semaphore across the whole
  batch (previously each item got a fresh semaphore, so the min/max
  concurrency bounds never applied and every item ran concurrently);
  synchronize the shared semaphore capacity after each completed item.
- comfy parser: guard ckpt_name against list/None values so re.search no
  longer raises TypeError and fails the whole image import.
- civarchive client: normalize empty-string failure payloads to
  "Request failed" and treat a missing payload as an error, fixing the
  "'NoneType' object has no attribute 'get'" crash.
- civarchive client: log connectivity-guard offline-cooldown
  short-circuits at DEBUG instead of one ERROR per request.
2026-08-27 07:58:48 +08:00
Will Miao 574dfbbe55 feat(settings): add explicit settings dir override for sandboxed runs
Add LORA_MANAGER_SETTINGS_DIR env var and standalone --settings-path to pin
the settings location (settings.json, cache/, wildcards/, backups/, logs/,
stats/) to an arbitrary directory. The override takes precedence over
portable mode and the platform user config dir, and skips legacy migration,
so sandboxed dev/E2E runs no longer need to write settings.json in the repo
root or collide with the real instance.

standalone.py pre-scans argv for --settings-path at import time because the
settings location is resolved before main() parses arguments. SettingsManager
portable-switch migration is a no-op while the directory is pinned.

Update the lora-manager-e2e skill (prefer --settings-path sandboxing;
start_server.py passes it through) and the lora-manager-runtime-context
skill (document precedence; inspect script honors the override).
2026-08-27 00:03:50 +08:00
Will Miao 1d3bcdfe47 fix(skill): quote lora-manager-e2e description so YAML frontmatter parses 2026-08-26 22:56:54 +08:00
Will Miao 74369940bf fix(recipes): log batch import progress only when it changes (#1084) 2026-08-26 22:39:51 +08:00
Will Miao d188cec306 fix(recipes): restore batch import modal on reopen and log recipe ingest progress (#1084) 2026-08-26 22:32:20 +08:00
Will Miao 641a61f804 feat(relink): accept CivitArchive URLs when linking models 2026-08-26 21:31:30 +08:00
Will Miao 3025c64fea fix(recipes): serve duplicate scan from cache and guard against re-entry 2026-08-26 20:34:50 +08:00
Will Miao c52cfc7e7a fix(download): serialize concurrent downloads resolving to the same target path 2026-08-26 12:17:14 +08:00
Will Miao 4ed9f775f6 feat(bulk): add shift+click range selection in bulk mode 2026-08-26 10:09:35 +08:00
Will Miao 0b08ad283a fix(bulk): restore card selection when virtual scroller recreates cards 2026-08-26 09:28:17 +08:00
221 changed files with 27175 additions and 7234 deletions
-373
View File
@@ -1,373 +0,0 @@
---
name: lora-manager-e2e
description: End-to-end testing and validation for LoRa Manager features. Use when performing automated E2E validation of LoRa Manager standalone mode in a SANDBOXED, disposable configuration: check the port, start/restart the standalone server on a free port, use Chrome DevTools MCP to interact with the web UI (http://127.0.0.1:{PORT}/loras), and verify frontend-to-backend functionality. Covers workflow validation, UI interaction testing, and integration testing between the standalone Python backend and the browser frontend. Trigger keywords: E2E, standalone, Chrome DevTools MCP, lora-manager-e2e, sandbox.
---
# LoRa Manager E2E Testing
This skill provides workflows and utilities for end-to-end testing of LoRa Manager using Chrome DevTools MCP.
## Conventions Used in This Document
- **`{PORT}`**: The server port. The default candidate is `8188`, but **`8188` is commonly occupied by a live ComfyUI process** and MUST NOT be assumed to be free. Always check availability first (see [Port Selection](#port-selection)) and use a free port (e.g. `8199`) for the E2E run. Substitute the actual port for every `{PORT}` in the commands below.
- **`<repo-root>`**: The repository/worktree root. Always run commands from the repo or worktree root; never assume a specific absolute path (paths such as `/home/<user>/...` differ per machine). The E2E scripts resolve the project root themselves, but fixture/settings paths are relative to `<repo-root>`.
## SANDBOX (MANDATORY)
> **Read this section before running anything.** Every E2E run MUST target a throwaway sandbox, never the real user data. A fresh subagent that skips this section WILL permanently mutate real user recipes.
1. **Portable settings**: create `<repo-root>/settings.json` (gitignored) with `"use_portable_settings": true` plus sandboxed `folder_paths` (lora/checkpoint roots) and `recipes_path`. This keeps the configuration inside the repo instead of the real user config dir (`~/.config/ComfyUI-LoRA-Manager/settings.json`).
2. **Sandboxed paths**: point `folder_paths` / `recipes_path` / `example_images_path` at disposable dirs — e.g. under `/tmp/opencode/<plan-name>-e2e/` (or worktree-local dirs). NEVER point the E2E at the real library (`~/models/...`), real recipe dir, or real settings.
3. **Never touch the real config**: the real user config at `~/.config/ComfyUI-LoRA-Manager/settings.json` and the real recipe dir must remain byte-identical before and after the run.
4. **Record real-data protection proof** before starting and after finishing:
```bash
# BEFORE: snapshot real config + recipe library state
sha256sum ~/.config/ComfyUI-LoRA-Manager/settings.json > /tmp/opencode/<plan>-e2e/settings.before.sha256
ls ~/models/recipes/*.recipe.json 2>/dev/null | wc -l > /tmp/opencode/<plan>-e2e/recipes-count.before.txt
find ~/models/recipes -name '*.recipe.json' -newermt "$(date -Iseconds)" | head # expect empty after run
# AFTER: record again, then diff the two snapshots. Any change = the run leaked into real data.
```
Also confirm `<repo-root>/git status` stays clean for `settings.json`/`cache/` (both are gitignored).
### Portable Settings Example
```json
{
"use_portable_settings": true,
"folder_paths": {
"loras": ["/tmp/opencode/<plan>-e2e/models/loras"],
"checkpoints": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
"unet": ["/tmp/opencode/<plan>-e2e/models/checkpoints"],
"diffusers": []
},
"recipes_path": "/tmp/opencode/<plan>-e2e/recipes",
"example_images_path": "/tmp/opencode/<plan>-e2e/example_images"
}
```
The scanner computes and persists model hashes during the library scan, so the sandbox model dirs just need the model files + `.metadata.json` sidecars (see [Fixture + Fresh-State Guidance](#fixture--fresh-state-guidance)).
## Time Budgets & Abort Guidance
A fresh subagent should complete a sandboxed standalone E2E **in well under 30 minutes**. Budget each phase:
| Phase | Expected duration | Abort if |
| --- | --- | --- |
| Port check + sandbox setup | < 2 min | — |
| Server start (detached) + readiness | < 30 s | > 60 s (2x) → stop |
| Chrome DevTools MCP connect | < 1 min | > 2 min → stop |
| Per entry-point run (after fixtures ready) | < 5 min | > 10 min (2x) → stop |
| Fixture reset + cache clear between runs | < 1 min | > 2 min → stop |
**Abort rule**: if a phase exceeds ~2x its budget, OR any single tool call fails/retries 3+ times in a row, **STOP**. Do not loop or retry blindly. Report `BLOCKED` with: the phase, the last observed state (server PID + `ss -tlnp` output, page snapshot, last API response), and the suspected cause. Record the partial state as evidence; a clean BLOCKED report is more valuable than an hour of retries.
## Prerequisites
- LoRa Manager project cloned and dependencies installed (`pip install -r requirements.txt`) — run everything from `<repo-root>`
- Chrome browser available for debugging
- Chrome DevTools MCP connected
- `ss` (or `lsof`/`netstat`) available for port checks: `ss -tlnp`
## Port Selection
`8188` is only the *default candidate*. Verify it is actually free before every run:
```bash
# Is anything listening on 8188?
ss -tlnp | grep ':8188' || echo "8188 is free"
```
- If a process holds `8188` (e.g. a live ComfyUI — pid 6575 on this machine), pick a different free port, e.g. `8199`:
```bash
ss -tlnp | grep ':8199' || echo "8199 is free"
```
- **Never** kill a process you did not start for this E2E. The live ComfyUI is off-limits. Pick a free port instead.
- Use your chosen port for **all** subsequent commands (server, Chrome launch, browser URLs).
## Quick Start Workflow (sandboxed)
### 1. Prepare the sandbox
```bash
cd <repo-root> # ALWAYS run from the repo/worktree root
mkdir -p /tmp/opencode/<plan>-e2e/models/{loras,checkpoints}
mkdir -p /tmp/opencode/<plan>-e2e/{recipes,example_images,recipes-before}
# write <repo-root>/settings.json per the portable-settings example above
# record real-data protection proof (see SANDBOX section)
```
### 2. Check port availability
```bash
ss -tlnp | grep ':{PORT}' || echo "port {PORT} is free"
```
If `{PORT}` is occupied by an unrelated process, pick a free one and use it everywhere below. When in doubt use `8199`.
### 3. Start LoRa Manager Standalone (detached)
The standalone server **dies with the shell unless launched fully detached** — a plain background `&` from the bash tool is killed when the tool call returns. Launch via the helper script:
```bash
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --wait --timeout 30 --detach
```
Or manually (equivalent detached form):
```bash
setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 < /dev/null \
>> /tmp/opencode/<plan>-e2e/server.log 2>&1 &
echo "started" # record the printed/pidfile PID for cleanup
```
Verify it is listening **before** proceeding (readiness poll is not a substitute for this):
```bash
ss -tlnp | grep ':{PORT}'
```
Record the server PID for cleanup: the helper script writes it to `/tmp/lora-manager-e2e-server-{PORT}.pid`; a manual `setsid` launch has no pidfile, so capture it explicitly (e.g. from `ss -tlnp`).
### 4. Open Chrome Debug Mode
```bash
# Chrome with remote debugging on port 9222 (note the {PORT} URL)
google-chrome --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-lora-manager http://127.0.0.1:{PORT}/loras
```
### 5. Connect Chrome DevTools MCP
Ensure the MCP server is connected to Chrome at `http://localhost:9222`. Verify with `list_pages` — if it fails with "browser is already running", see [Chrome DevTools MCP Troubleshooting](#chrome-devtools-mcp-troubleshooting).
### 6. Navigate and Interact
Use Chrome DevTools MCP tools to:
- Take snapshots: `take_snapshot`
- Click elements: `click`
- Fill forms: `fill` or `fill_form`
- Evaluate scripts: `evaluate_script`
- Wait for elements: `wait_for`
## Common E2E Test Patterns
### Pattern: Full Page Load Verification
```python
# Navigate to LoRA list page
navigate_page(type="url", url="http://127.0.0.1:{PORT}/loras")
# Wait for page to load
wait_for(text="LoRAs", timeout=10000)
# Take snapshot to verify UI state
snapshot = take_snapshot()
```
### Pattern: Restart Server for Configuration Changes
```python
# Stop current server (if running), start with new configuration.
# --restart only kills the E2E server this script started before (via its pidfile);
# it refuses to blindly kill unrelated processes on the port.
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --detach
# Wait and refresh browser
navigate_page(type="reload", ignoreCache=True)
wait_for(text="LoRAs", timeout=15000)
```
### Pattern: Verify Backend API via Frontend
```python
# Execute script in browser to call backend API
result = evaluate_script(function="""
async () => {
const response = await fetch('/loras/api/list');
const data = await response.json();
return { count: data.length, firstItem: data[0]?.name };
}
""")
```
### Pattern: Form Submission Flow
```python
# Fill a form (e.g., search or filter)
fill_form(elements=[
{"uid": "search-input", "value": "character"},
])
# Click submit button
click(uid="search-button")
# Wait for results
wait_for(text="Results", timeout=5000)
# Verify results via snapshot
snapshot = take_snapshot()
```
### Pattern: Modal Dialog Interaction
```python
# Open modal (e.g., add LoRA)
click(uid="add-lora-button")
# Wait for modal to appear
wait_for(text="Add LoRA", timeout=3000)
# Fill modal form
fill_form(elements=[
{"uid": "lora-name", "value": "Test LoRA"},
{"uid": "lora-path", "value": "/path/to/lora.safetensors"},
])
# Submit
click(uid="modal-submit-button")
# Wait for success message or close
wait_for(text="Success", timeout=5000)
```
## Fixture + Fresh-State Guidance
For rematch/repair E2E runs, seed the **sandboxed** `recipes_path` with hand-written fixture recipes. Rules (validated by the task-8 E2E):
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.
### Fresh state between entry-point runs
Each entry point (global / per-recipe / selection-bulk) must start from the same deleted state. Between runs:
```bash
# 1. Reset fixtures to the before-state snapshot (copy back from recipes-before/)
cp /tmp/opencode/<plan>-e2e/recipes-before/*.recipe.json /tmp/opencode/<plan>-e2e/recipes/
# 2. Clear the recipe/FTS caches so the stale in-memory/library state is gone
rm -f <repo-root>/cache/recipe/*.sqlite
rm -rf <repo-root>/cache/fts/*
# 3. Restart the server (fresh process, fresh scan)
python .agents/skills/lora-manager-e2e/scripts/start_server.py --port {PORT} --restart --wait --timeout 30 --detach
# 4. Re-verify server listening + reload the browser page
```
## Server Lifecycle
- **Detached launch is mandatory**: the standalone server dies with the shell unless launched via `setsid` (or the helper script's `--detach`). Use `setsid nohup python standalone.py --port {PORT} --host 127.0.0.1 ... < /dev/null &`.
- **Verify with `ss -tlnp`** after every (re)start; do not proceed on a blind "server starting" message.
- **Never kill pre-existing processes** — only kill the E2E server PID you started (`start_server.py --restart` kills only PIDs it manages via its pidfile). The live ComfyUI or a stale QA Chrome must never be killed as part of cleanup unless explicitly identified as such (see Chrome troubleshooting).
- **Record your PID for cleanup**: note the PID printed/pidfile, and stop exactly that PID at the end (`kill <PID>`, then confirm with `ss -tlnp` that `{PORT}` is released).
## Chrome DevTools MCP Troubleshooting
### Stale profile lock ("browser is already running" / `list_pages` fails)
A Chrome profile can be held by a stale Chrome from a prior MCP session, which makes `list_pages` fail with "browser is already running":
1. Identify the stale Chrome — it owns the profile dir in `--user-data-dir` (e.g. `~/.config/chrome-dev-profile`). Find its process:
```bash
ps -ef | grep -i '[c]hrome.*user-data-dir'
```
2. Confirm it is a QA Chrome from a completed task (its parent is an old MCP/browser process, it is NOT the live ComfyUI server, and it is NOT your current MCP instance).
3. Kill ONLY that stale Chrome:
```bash
kill <stale-chrome-pid>
```
Never kill the live server or unrelated processes.
4. Retry `list_pages`. The current MCP will spawn a fresh browser.
### Screenshot-write restrictions
The chrome-devtools MCP may refuse to write into paths outside its configured workspace roots (e.g. the worktree `.omo/evidence/...` canonicalizing to an unmapped path). Workaround:
```bash
# 1. Save the screenshot to /tmp via the MCP
# take_screenshot(filePath="/tmp/<plan>-e2e/recipe-b-after.png", format="png")
# 2. Copy it into the evidence dir from the shell
mkdir -p <repo-root>/.omo/evidence/screenshots
cp /tmp/<plan>-e2e/recipe-b-after.png <repo-root>/.omo/evidence/screenshots/
```
## 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.
## Available Scripts
### scripts/start_server.py
Starts or restarts the LoRa Manager standalone server for E2E testing.
```bash
python scripts/start_server.py [--port PORT] [--restart] [--wait] [--timeout SECONDS] [--detach]
```
Options:
- `--port`: Server port (default: 8188). The script exits early with a clear message if the port is already in use by an unrelated process.
- `--restart`: Kill the E2E server this script previously managed (tracked via `/tmp/lora-manager-e2e-server-{PORT}.pid`) before starting. If unrelated processes still hold the port after that, the script reports them and aborts instead of killing them.
- `--wait`: Wait for the server to be ready before exiting.
- `--timeout`: Readiness wait timeout in seconds (default: 30).
- `--detach`: Launch the server fully detached (`setsid`-style, survives shell death — REQUIRED for E2E). Default off: a normal background process that dies with the shell.
### scripts/wait_for_server.py
Polls the server until ready or timeout.
```bash
python scripts/wait_for_server.py [--port PORT] [--timeout SECONDS]
```
## Test Scenarios Reference
See [references/test-scenarios.md](references/test-scenarios.md) for detailed test scenarios including:
- LoRA list display and filtering
- Model metadata editing
- Recipe creation and management
- Settings configuration
- Import/export functionality
## Network Request Verification
Use `list_network_requests` and `get_network_request` to verify API calls:
```python
# List recent XHR/fetch requests
requests = list_network_requests(resourceTypes=["xhr", "fetch"])
# Get details of specific request
details = get_network_request(reqid=123)
```
## Console Message Monitoring
```python
# Check for errors or warnings
messages = list_console_messages(types=["error", "warn"])
```
## Performance Testing
```python
# Start performance trace
performance_start_trace(reload=True, autoStop=False)
# Perform actions...
# Stop and analyze
results = performance_stop_trace()
```
## Cleanup
Always ensure proper cleanup after tests:
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. Remove the sandbox: `rm -rf /tmp/opencode/<plan>-e2e` and `<repo-root>/settings.json` + `<repo-root>/cache` (both gitignored).
4. Re-run the real-data protection check from the SANDBOX section and record the result in your evidence.
@@ -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,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)
@@ -9,7 +9,10 @@ description: Inspect ComfyUI LoRA Manager runtime configuration and local diagno
- Treat runtime state as local user data. Prefer read-only inspection unless the user explicitly asks for mutation.
- Never print secret-like settings values. Redact keys containing `key`, `token`, `secret`, `password`, `auth`, or `credential`, including `civitai_api_key`.
- Resolve paths from the runtime configuration before guessing. In this environment the settings file is normally `/home/miao/.config/ComfyUI-LoRA-Manager/settings.json`, but portable settings can override this through the repository `settings.json`.
- Resolve paths from the runtime configuration before guessing. Settings-directory precedence (highest first):
1. **Explicit override** — env `LORA_MANAGER_SETTINGS_DIR` or standalone `--settings-path` (also accepted by the inspect script as `--settings-path DIR`). Pins EVERYTHING (`settings.json`, `cache/`, `wildcards/`, `backups/`, `logs/`, `stats/`) under the given directory; bypasses portable mode and the user config dir. Common when inspecting a sandboxed/E2E instance.
2. **Portable** — repository `<repo-root>/settings.json` with `"use_portable_settings": true` (or `LORA_MANAGER_PORTABLE=1`): settings dir = `<repo-root>`.
3. **Default**`~/.config/ComfyUI-LoRA-Manager` on this machine (`platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`).
- Use the active library when selecting per-library caches and paths. Read `active_library` from settings; fall back to `default` if missing.
- Normalize and expand `~` before comparing paths. Symlinks are common in this repo.
@@ -32,9 +35,17 @@ python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_conte
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py sqlite --db /path/to/cache.sqlite --limit 3
```
To inspect a sandboxed/E2E instance that pins its settings directory:
```bash
# --settings-path DIR (or LORA_MANAGER_SETTINGS_DIR) works with every subcommand:
python .agents/skills/lora-manager-runtime-context/scripts/inspect_runtime_context.py \
--settings-path /tmp/opencode/<plan>-e2e/settings summary
```
## Runtime Path Rules
- Settings directory: use `py/utils/settings_paths.py`. Default platform path is `platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`.
- Settings directory: resolve via `py/utils/settings_paths.py``get_settings_dir()` honors the `LORA_MANAGER_SETTINGS_DIR` / programmatic override first, then portable mode, then `platformdirs.user_config_dir("ComfyUI-LoRA-Manager", appauthor=False)`. The inspect script mirrors this precedence in `resolve_settings_path()`.
- Settings file: `<settings_dir>/settings.json`.
- Cache root: `<settings_dir>/cache`.
- Canonical cache files:
@@ -14,6 +14,7 @@ from typing import Any
SECRET_PATTERN = re.compile(r"(key|token|secret|password|auth|credential)", re.IGNORECASE)
APP_NAME = "ComfyUI-LoRA-Manager"
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
CACHE_SQLITE = {
"model": ("model", "{library}.sqlite"),
"recipe": ("recipe", "{library}.sqlite"),
@@ -30,6 +31,15 @@ CACHE_JSON = {
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect LoRA Manager runtime state read-only.")
parser.add_argument(
"--settings-path",
type=str,
default=None,
metavar="DIR",
help="Explicit settings directory (same as LORA_MANAGER_SETTINGS_DIR / "
"standalone --settings-path). Overrides portable mode and the default "
"user config dir.",
)
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("summary", help="Print redacted settings and resolved paths.")
@@ -44,6 +54,8 @@ def main() -> int:
sqlite_parser.add_argument("--limit", type=int, default=3, help="Rows to sample from each user table.")
args = parser.parse_args()
if args.settings_path:
os.environ[SETTINGS_DIR_ENV] = args.settings_path
context = build_context()
if args.command == "summary":
@@ -78,6 +90,11 @@ def build_context() -> dict[str, Any]:
def resolve_settings_path() -> Path:
# Explicit override: LORA_MANAGER_SETTINGS_DIR env or --settings-path.
explicit = os.environ.get(SETTINGS_DIR_ENV)
if explicit:
return Path(explicit).expanduser() / "settings.json"
repo_root = find_repo_root()
portable = repo_root / "settings.json"
if portable.exists():
+143 -48
View File
@@ -2,6 +2,10 @@
This file provides guidance for agentic coding assistants working in this repository.
## Overview
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
## Development Commands
### Backend Development
@@ -28,16 +32,21 @@ COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py --cov=standalone \
--cov-report=term-missing \
--cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
```
### Frontend Development (LoRA Manager Web UI)
```bash
# Install dependencies (root and Vue widgets)
npm install
cd vue-widgets && npm install && cd ..
npm test # Run all tests (JS + Vue)
npm run test:js # Run JS tests only
npm run test:watch # Watch mode
npm run test:vue # Run Vue widget tests only
npm run test:watch # Watch mode (JS tests only)
npm run test:coverage # Generate coverage report
```
@@ -54,88 +63,193 @@ npm run test:watch # Watch mode
npm run test:coverage # Generate coverage report
```
## Python Code Style
### Localization
### Imports & Formatting
```bash
# Sync translation keys after UI string updates
python scripts/sync_translation_keys.py
```
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
After adding keys to `en.json` and syncing, **stop**: the `[TODO: Translate]` placeholders in
the other locales are the expected end state during feature development. Do NOT translate
proactively — translate only when the feature owner explicitly asks (see
`docs/i18n-translation-guidelines.md` §7).
**Before translating anything, read `docs/i18n-translation-guidelines.md`** — it defines the
term conventions (e.g. "Recipe" stays untranslated in French, 配方 in Chinese; model-type and
brand names are never translated), per-locale preferred renderings, placeholder rules, and
the known confusion hot-spots.
## Code Style
### Python
#### Imports & Formatting
- Use `from __future__ import annotations` for forward references
- Group imports: standard library, third-party, local (blank line separated)
- Use `TYPE_CHECKING` guard for type-checking-only imports
- Absolute imports within `py/`: `from ..services import X`
- PEP 8 with 4-space indentation, type hints required
### Naming Conventions
#### Naming Conventions
- Files: `snake_case.py`, Classes: `PascalCase`, Functions/vars: `snake_case`
- Constants: `UPPER_SNAKE_CASE`, Private: `_protected`, `__mangled`
### Error Handling & Async
#### Error Handling & Async
- Use `logging.getLogger(__name__)`, define custom exceptions in `py/services/errors.py`
- `async def` for I/O, `@pytest.mark.asyncio` for async tests
- Singleton with `asyncio.Lock`: see `ModelScanner.get_instance()`
- Return `aiohttp.web.json_response` or `web.Response`
### Testing
### JavaScript/TypeScript
- `pytest` with `--import-mode=importlib`
- Fixtures in `tests/conftest.py`, use `tmp_path_factory` for isolation
- Mark tests needing real paths: `@pytest.mark.no_settings_dir_isolation`
- Mock ComfyUI dependencies via conftest patterns
## JavaScript/TypeScript Code Style
### Imports & Modules
#### Imports & Modules
- ES modules: `import { app } from "../../scripts/app.js"` for ComfyUI
- Vue: `import { ref, computed } from 'vue'`, type imports: `import type { Foo }`
- Export named functions: `export function foo() {}`
### Naming & Formatting
#### Naming & Formatting
- camelCase for functions/vars/props, PascalCase for classes
- Constants: `UPPER_SNAKE_CASE`, Files: `snake_case.js` or `kebab-case.js`
- 2-space indentation preferred (follow existing file conventions)
- Vue Single File Components: `<script setup lang="ts">` preferred
### Widget Development
#### Widget Development
- Prefer vanilla JS for `web/comfyui/` widgets; avoid framework dependencies (except the Vue widgets in `vue-widgets/`)
- ComfyUI: `app.registerExtension()`, `node.addDOMWidget(name, type, element, options)`
- Event handlers via `addEventListener` or widget callbacks
- Shared utilities: `web/comfyui/utils.js`
- Dual-mode rendering patterns (canvas vs Vue): see `docs/comfyui-dual-mode-widgets.md`
### Vue Composables Pattern
#### Vue Composables Pattern
- Use composition API: `useXxxState(widget)`, return reactive refs and methods
- Guard restoration loops with flag: `let isRestoring = false`
- Build config from state: `const buildConfig = (): Config => { ... }`
## Architecture Patterns
## Architecture
### Dual Mode Operation
The system runs in two modes:
- **ComfyUI plugin mode**: Integrates with ComfyUI's PromptServer, uses `folder_paths` for model discovery
- **Standalone mode**: `standalone.py` mocks ComfyUI dependencies, reads paths from `settings.json`
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
### Backend Entry Points
- `__init__.py` — ComfyUI plugin entry: registers nodes via `NODE_CLASS_MAPPINGS`, sets `WEB_DIRECTORY`, calls `LoraManager.add_routes()`
- `standalone.py` — Standalone server: mocks `folder_paths` and node modules, starts aiohttp server
- `py/lora_manager.py` — Main `LoraManager` class that registers all HTTP routes
### Service Layer
- `ServiceRegistry` singleton for DI, services use `get_instance()` classmethod
- `BaseModelService` abstract base → `LoraService`, `CheckpointService`, `EmbeddingService`
- `ModelScanner` base → `LoraScanner`, `CheckpointScanner`, `EmbeddingScanner` for file discovery with hash-based deduplication
- `PersistentModelCache` (SQLite) for metadata persistence
- `MetadataSyncService` — background sync from CivitAI/CivArchive APIs
- `SettingsManager` — settings with schema migration support
- `WebSocketManager` — real-time progress broadcasting
- `ModelServiceFactory` — creates the right service for each model type
- Use cases in `py/services/use_cases/` orchestrate complex business logic (auto-organize, bulk refresh, downloads)
- Separate scanners (discovery) from services (business logic)
- Handlers in `py/routes/handlers/` are pure functions with deps as params
### Model Types & Routes
- `BaseModelService` base for LoRA, Checkpoint, Embedding
- `ModelScanner` for file discovery, hash deduplication
- `PersistentModelCache` (SQLite) for persistence
- Route registrars: `ModelRouteRegistrar`, endpoints: `/loras/*`, `/checkpoints/*`, `/embeddings/*`
- WebSocket via `WebSocketManager` for real-time updates
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- 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
- Base: `py/recipes/base.py`, Enrichment: `RecipeEnrichmentService`
- Parsers: `py/recipes/parsers/`
- Base: `py/recipes/base.py`, Enrichment: `RecipeEnrichmentService` in `py/recipes/enrichment.py`
- Parsers: `py/recipes/parsers/` for PNG metadata, JSON, and workflow formats
### Custom Nodes
- Location: `py/nodes/`, all nodes registered in `__init__.py`
- Each node class has a `NAME` class attribute used as key in `NODE_CLASS_MAPPINGS`
- Standard ComfyUI node pattern: `INPUT_TYPES()` classmethod, `RETURN_TYPES`, `FUNCTION`
### Configuration
- `py/config.py` manages folder paths for models and handles symlink mappings
- Auto-saves paths to `settings.json` in ComfyUI mode
### Frontend UI Architecture
#### 1. LoRA Manager Web UI
- Location: `./static/` (JS/CSS) and `./templates/` (HTML)
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
- Tests: `tests/frontend/**/*.test.js` (vitest + jsdom)
#### 2. ComfyUI Custom Node Widgets
- Location: `./web/comfyui/` (Vanilla JS) + `./vue-widgets/` (Vue)
- Primary styles: `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
- Vue widgets: Vue 3 + TypeScript + PrimeVue + vue-i18n, e.g. `LoraPoolWidget`, `LoraRandomizerWidget`, `LoraCyclerWidget`, `AutocompleteTextWidget`
- Vue builds to `./web/comfyui/vue-widgets/`; auto-built on ComfyUI startup via `py/vue_widget_builder.py`, typecheck via `vue-tsc`
- Widget registration: `app.registerExtension()` and `getCustomWidgets` hooks; `node.addDOMWidget(...)` embeds HTML in LiteGraph nodes
- See `docs/dom_widget_dev_guide.md` for the DOMWidget development guide
## Testing
### Backend (pytest)
- Config in `pytest.ini`: `--import-mode=importlib`, testpaths=`tests`
- Fixtures in `tests/conftest.py` mock ComfyUI dependencies; use `tmp_path_factory` for isolation
- Markers: `@pytest.mark.asyncio`, `@pytest.mark.no_settings_dir_isolation` (tests needing real settings paths)
### Frontend (vitest)
- 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`)
- **CivitAI/CivArchive:** API clients for metadata sync and model downloads; CivitAI API key stored in settings
- **Symlinks:** Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans
- **WebSocket:** Broadcasts real-time progress for downloads, scans, and metadata sync
- **Model scanning flow:** Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
## Important Notes
- ALWAYS use English for comments (per copilot-instructions.md)
- Dual mode: ComfyUI plugin (folder_paths) vs standalone (settings.json)
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
- Symlinks require normalized paths.
**Business paths vs real paths**: All stored paths and operation routing use the
@@ -143,23 +257,4 @@ npm run test:coverage # Generate coverage report
resolved. `os.path.realpath` is only for scanner dedup and the symlink cache.
Any path passed to `os.remove`/`os.rename`/`shutil.move` or validated by a
containment check MUST use the business path (i.e. `os.path.abspath`, not
`realpath`).
## Git / Commit Messages
- Follow the style of recent repository commits when writing commit messages
- Prefer the repo's existing `feat(...)`, `fix(...)`, `chore:` style where applicable
- If the user has provided a GitHub issue link or issue ID for the task, mention that issue in the commit message, for example `(#871)`
- When unrelated local changes exist, stage and commit only the files relevant to the requested task
## Frontend UI Architecture
### 1. LoRA Manager Web UI
- Location: `./static/` and `./templates/`
- Tech: Vanilla JS + CSS, served by the hosting server (ComfyUI app in plugin mode, `standalone.py` in standalone mode)
- Tests via npm in root directory
### 2. ComfyUI Custom Node Widgets
- Location: `./web/comfyui/` (Vanilla JS) + `./vue-widgets/` (Vue)
- Primary styles: `./web/comfyui/lm_styles.css` (NOT `./static/css/`)
- Vue builds to `./web/comfyui/vue-widgets/`, typecheck via `vue-tsc`
`realpath`).
-189
View File
@@ -1,189 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Overview
ComfyUI LoRA Manager is a comprehensive LoRA management system for ComfyUI that combines a Python backend with browser-based widgets. It provides model organization, downloading from CivitAI/CivArchive, recipe management, and one-click workflow integration.
## Development Commands
### Backend
```bash
pip install -r requirements.txt
pip install -r requirements-dev.txt
# Run standalone server (port 8188 by default)
python standalone.py --port 8188
# Run all backend tests
pytest
# Run specific test file or function
pytest tests/test_recipes.py
pytest tests/test_recipes.py::test_function_name
# Run backend tests with coverage
COVERAGE_FILE=coverage/backend/.coverage pytest \
--cov=py \
--cov=standalone \
--cov-report=term-missing \
--cov-report=html:coverage/backend/html \
--cov-report=xml:coverage/backend/coverage.xml \
--cov-report=json:coverage/backend/coverage.json
```
### Frontend
There are three test suites run by `npm test`: vanilla JS tests (vitest at root) and Vue widget tests (`vue-widgets/` vitest).
```bash
npm install
cd vue-widgets && npm install && cd ..
# Run all frontend tests (JS + Vue)
npm test
# Run only vanilla JS tests
npm run test:js
# Run only Vue widget tests
npm run test:vue
# Watch mode (JS tests only)
npm run test:watch
# Frontend coverage
npm run test:coverage
# Build Vue widgets (output to web/comfyui/vue-widgets/)
cd vue-widgets && npm run build
# Vue widget dev mode (watch + rebuild)
cd vue-widgets && npm run dev
# Typecheck Vue widgets
cd vue-widgets && npm run typecheck
```
### Localization
```bash
# Sync translation keys after UI string updates
python scripts/sync_translation_keys.py
```
Locale files are in `locales/` (en, zh-CN, zh-TW, ja, ko, fr, de, es, ru, he).
## Architecture
### Dual Mode Operation
The system runs in two modes:
- **ComfyUI plugin mode**: Integrates with ComfyUI's PromptServer, uses `folder_paths` for model discovery
- **Standalone mode**: `standalone.py` mocks ComfyUI dependencies, reads paths from `settings.json`
- Detection: `os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"`
### Backend (Python)
**Entry points:**
- `__init__.py` — ComfyUI plugin entry: registers nodes via `NODE_CLASS_MAPPINGS`, sets `WEB_DIRECTORY`, calls `LoraManager.add_routes()`
- `standalone.py` — Standalone server: mocks `folder_paths` and node modules, starts aiohttp server
- `py/lora_manager.py` — Main `LoraManager` class that registers all HTTP routes
**Service layer** (`py/services/`):
- `ServiceRegistry` singleton for dependency injection; services follow `get_instance()` singleton pattern
- `BaseModelService` abstract base → `LoraService`, `CheckpointService`, `EmbeddingService`
- `ModelScanner` base → `LoraScanner`, `CheckpointScanner`, `EmbeddingScanner` for file discovery with hash-based deduplication
- `PersistentModelCache` — SQLite-based metadata cache
- `MetadataSyncService` — Background sync from CivitAI/CivArchive APIs
- `SettingsManager` — Settings with schema migration support
- `WebSocketManager` — Real-time progress broadcasting
- `ModelServiceFactory` — Creates the right service for each model type
- Use cases in `py/services/use_cases/` orchestrate complex business logic (auto-organize, bulk refresh, downloads)
**Routes** (`py/routes/`):
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
- Request handlers in `py/routes/handlers/` implement route logic
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- All routes use aiohttp, return `web.json_response` or `web.Response`
**Recipe system** (`py/recipes/`):
- `base.py` — Recipe metadata structure
- `enrichment.py` — Enriches recipes with model metadata
- `parsers/` — Parsers for PNG metadata, JSON, and workflow formats
**Custom nodes** (`py/nodes/`):
- Each node class has a `NAME` class attribute used as key in `NODE_CLASS_MAPPINGS`
- Standard ComfyUI node pattern: `INPUT_TYPES()` classmethod, `RETURN_TYPES`, `FUNCTION`
- All nodes registered in `__init__.py`
**Configuration** (`py/config.py`):
- Manages folder paths for models, handles symlink mappings
- Auto-saves paths to settings.json in ComfyUI mode
### Frontend — Two Distinct UI Systems
#### 1. Standalone Manager Web UI
- **Location:** `static/` (JS/CSS) and `templates/` (HTML)
- **Tech:** Vanilla JS + CSS, served by standalone server
- **Structure:** `static/js/core.js` (shared), `loras.js`, `checkpoints.js`, `embeddings.js`, `recipes.js`, `statistics.js`
- **Tests:** `tests/frontend/**/*.test.js` (vitest + jsdom)
#### 2. ComfyUI Custom Node Widgets
- **Vanilla JS widgets:** `web/comfyui/*.js` — ES modules extending ComfyUI's LiteGraph UI
- `loras_widget.js` / `loras_widget_events.js` — Main LoRA selection widget
- `autocomplete.js` — Trigger word and embedding autocomplete
- `preview_tooltip.js` — Model card preview tooltips
- `top_menu_extension.js` — "Launch LoRA Manager" menu item
- `utils.js` — Shared utilities and API helpers
- Widget styling in `web/comfyui/lm_styles.css` (NOT `static/css/`)
- **Vue widgets:** `vue-widgets/src/` → built to `web/comfyui/vue-widgets/`
- Vue 3 + TypeScript + PrimeVue + vue-i18n
- Vite build with CSS-injected-by-JS plugin
- Components: `LoraPoolWidget`, `LoraRandomizerWidget`, `LoraCyclerWidget`, `AutocompleteTextWidget`
- Auto-built on ComfyUI startup via `py/vue_widget_builder.py`
- Tests: `vue-widgets/tests/**/*.test.ts` (vitest)
**Widget registration pattern:**
- Widgets use `app.registerExtension()` and `getCustomWidgets` hooks
- `node.addDOMWidget(name, type, element, options)` embeds HTML in LiteGraph nodes
- See `docs/dom_widget_dev_guide.md` for DOMWidget development guide
## Code Style
**Python:**
- PEP 8, 4-space indentation, English comments only
- Use `from __future__ import annotations` for forward references
- Use `TYPE_CHECKING` guard for type-checking-only imports
- Loggers via `logging.getLogger(__name__)`
- Custom exceptions in `py/services/errors.py`
- Async patterns: `async def` for I/O, `@pytest.mark.asyncio` for async tests
- Singleton pattern with class-level `asyncio.Lock` (see `ModelScanner.get_instance()`)
**JavaScript:**
- ES modules, camelCase functions/variables, PascalCase classes
- Widget files use `*_widget.js` suffix
- Prefer vanilla JS for `web/comfyui/` widgets, avoid framework dependencies (except Vue widgets)
## Testing
**Backend (pytest):**
- Config in `pytest.ini`: `--import-mode=importlib`, testpaths=`tests`
- Fixtures in `tests/conftest.py` handle ComfyUI dependency mocking
- Markers: `@pytest.mark.asyncio`, `@pytest.mark.no_settings_dir_isolation`
- Uses `tmp_path_factory` for directory isolation
**Frontend (vitest):**
- Vanilla JS tests: `tests/frontend/**/*.test.js` with jsdom
- Vue widget tests: `vue-widgets/tests/**/*.test.ts` with jsdom + @vue/test-utils
- Setup in `tests/frontend/setup.js`
## Key Integration Points
- **Settings:** Stored in user directory (via `platformdirs`) or portable mode (`"use_portable_settings": true`)
- **CivitAI/CivArchive:** API clients for metadata sync and model downloads; CivitAI API key in settings
- **Symlink handling:** Config scans symlinks to map virtual→physical paths; fingerprinting prevents redundant rescans
- **WebSocket:** Broadcasts real-time progress for downloads, scans, and metadata sync
- **Model scanning flow:** Walk folders → compute hashes → deduplicate → extract safetensors metadata → cache in SQLite → background CivitAI sync → WebSocket broadcast
-10
View File
@@ -3,8 +3,6 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
from .py.nodes.random_unet_loader import RandomUNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM
@@ -42,12 +40,6 @@ except (
"py.nodes.checkpoint_loader"
).CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
RandomCheckpointLoaderLM = importlib.import_module(
"py.nodes.random_checkpoint_loader"
).RandomCheckpointLoaderLM
RandomUNETLoaderLM = importlib.import_module(
"py.nodes.random_unet_loader"
).RandomUNETLoaderLM
TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle"
).TriggerWordToggleLM
@@ -87,8 +79,6 @@ NODE_CLASS_MAPPINGS = {
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM,
RandomCheckpointLoaderLM.NAME: RandomCheckpointLoaderLM,
RandomUNETLoaderLM.NAME: RandomUNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM,
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
+427 -395
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -54,7 +54,7 @@ The dedicated services encapsulate long-running work so handlers stay thin.
| Use case | Entry point | Dependencies | Guarantees |
| --- | --- | --- | --- |
| `RecipeAnalysisService` | `analyze_uploaded_image`, `analyze_remote_image`, `analyze_local_image`, `analyze_widget_metadata` | `ExifUtils`, `RecipeParserFactory`, downloader factory, optional metadata collector/processor | Normalises missing/invalid payloads into `RecipeValidationError`; generates consistent fingerprint data to keep duplicate detection stable; temporary files are cleaned up after every analysis path. |
| `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. |
| `RecipePersistenceService` | `save_recipe`, `delete_recipe`, `update_recipe`, `reconnect_lora`, `get_reconnect_suggestions`, `bulk_delete`, `save_recipe_from_widget` | `ExifUtils`, recipe scanner, card preview sizing constants | Writes images/JSON metadata atomically; updates scanner caches and hash indices before returning; recalculates fingerprints whenever LoRA assignments change. |
| `RecipeSharingService` | `share_recipe`, `prepare_download` | `tempfile`, recipe scanner | Copies originals to TTL-managed temp files; metadata lookups re-use the scanner; expired shares trigger cleanup and `RecipeNotFoundError`. |
## Maintaining critical invariants
+370
View File
@@ -0,0 +1,370 @@
# i18n Translation Guidelines
This document is the canonical set of conventions for translating LoRA Manager UI strings.
It applies to **human translators and AI agents** alike. Read it before editing anything in
`locales/`.
Source of truth: `locales/en.json` (10 locales, 1810 leaf keys; all locales share the exact
same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> **Status (2026-08 sweep):** a full audit was executed and the terminology, placeholder,
> stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales
> (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**,
> not a to-do list — future edits should preserve these renderings and only add what is new.
---
## 1. Hard rules (do not violate)
### R1 — Key structure is sacred
- Only `locales/en.json` may add/remove/rename keys. All other locales must keep the exact
same nested key set. `tests/i18n/test_i18n.py` enforces this.
- When a new UI string is added to `en.json`, run
`python scripts/sync_translation_keys.py` (adds the missing keys to all locales with
`[TODO: Translate]` placeholder copies) — **then stop**. Do NOT translate proactively:
placeholders are the expected end state during feature development, and translations are
filled in only when the feature owner explicitly asks (workflow details in §7).
- Never reorder, re-indent, or reformat a locale file "for tidiness". The sync script
preserves formatting; manual reformatting creates noisy diffs.
### R2 — Placeholders and HTML must be preserved verbatim
- `{name}`-style placeholders must appear in the translation exactly as in `en.json`.
Do not invent placeholders the source string does not have — the caller may not pass them
(example bug: `zh-CN recipes.controls.import.downloadLocationPreview` added `{path}`; the
template renders this key with no parameters, so the literal text `{path}` shows in the UI).
- `{{...}}` in a locale value is an escaped literal brace — keep it identical.
- Keep embedded HTML tags (e.g. `<strong>...</strong>`, `<code>...</code>`) intact.
You may move the tag around the sentence if the target language needs different word order.
### R3 — Never translate or transliterate these
- Model types: **LoRA, Checkpoint, Embedding, Diffusion Model**
- Products/brands: **LoRA Manager, ComfyUI, CivitAI, CivArchive, HuggingFace, Ko-fi**
- Ecosystem names: **LyCORIS, DoRA**, trigger-adjacent jargon **Prompt, Workflow**
(these are used as-is in the target-language SD community; see §2 per-language policy)
- Theme names: **Nord, Midnight, Monokai, Dracula, Solarized**
### R4 — The "Recipe" convention (the most important domain term)
Product intent: a *Recipe* records a **LoRA combination + generation parameters**
(prompt, seed, sampler, …) that reproduces an image style. The metaphor is a **cooking
recipe** — "follow it and you get a similar dish". It is **not** a menu, not a dish list,
not a prescription.
Decision per language — translate only into a word whose everyday primary meaning is a
cooking recipe; where that word would mislead users, **keep the English "Recipe(s)"**:
| Locale | Use | Never use |
|---|---|---|
| fr | **Recipe / Recipes** (keep English) | recette(s) — cooking reading is secondary and it was explicitly judged misleading |
| zh-CN / zh-TW | 配方 | 食谱 (reads as "food cookbook") |
| ja | レシピ | — (leftover English "Recipe" in `initialization.recipes.title` / `toast.recipes.recipeSaved` → translate) |
| ko | 레시피 | — |
| de | Rezept / Rezepte | — (cooking meaning dominant; prescription reading acceptable) |
| es | receta / recetas | — (cooking meaning dominant) |
| ru | рецепт / рецепты | — (leftover English "Recipe" in `initialization.recipes.title` / `toast.recipes.recipeSaved` → translate) |
| he | מתכון / מתכונים | — (cooking meaning dominant) |
Whatever the choice, **one concept = one noun within a locale**. Currently violated in:
- `fr` — "Recipe" (~97 keys, incl. nav) mixed with "recette" (~58 keys)
- `zh-CN` / `zh-TW` — 配方 (126/122 keys) mixed with 食谱 / 食譜 (14/17 keys, all in the
*rematch* flow: `globalContextMenu.rematchRecipes.*`, `toast.recipes.rematch*`)
- `de` — "Rezept" (136 keys) mixed with leftover English "Recipe" (5 keys)
- `ja` / `ru` — leftover English "Recipe" in `initialization.recipes.title` ("Recipe Manager
zu initialisieren" / «Инициализация Recipe Manager») and `toast.recipes.recipeSaved`
### R5 — One term, one rendering (within each locale)
Same source word must not be translated several ways in one file. Known offender areas
(see §5 for the full fix list): recipe, Checkpoint, Embedding, prompt, base model, preset,
workflow, hash, metadata, tags, bulk. Every locale currently mixes variants of at least one
of these — pick the preferred form in the §2 tables and normalize.
### R6 — Register consistency
- `zh-CN` / `zh-TW`: pick 你 or 您 once. Do not mix (zh-CN has 44×你 + 5×您; zh-TW has
27×您 + 18×你).
- `de`: pick "du" or "Sie" once (currently 143×Sie + ~7×du).
- `es`: pick "tú" or "usted" once.
### R7 — Punctuation per script
- Full-width punctuation `:()` is correct **only in CJK locales** (zh-CN, zh-TW, ja, ko).
- Latin/Cyrillic/Hebrew locales must use ASCII `: ()` — full-width colons leaked in there
are machine-translation artifacts. Known: `fr toast.recipes.createError/createFailed`,
`es toast.recipes.createError/createFailed` (e.g. "…de la receta" should be "…de la receta:").
- `fr` apostrophes must be U+2019 `'` / ASCII `'`, never a straight double quote:
`fr header.filter.allowSellingGeneratedContentTooltip` currently reads
`vendre d"images` → fix to `d'images`. Do not mix `'` and `'` in one file (fr has 299 vs 15).
- Ellipsis: use ASCII `...` (project style). Don't introduce `…`.
- Keep the sentence-ending period/omission consistent with the source string where the
language allows it.
- `he` is RTL: mix of Hebrew and Latin scripts is normal; keep Latin term ordering natural.
### R8 — No untranslated English leftovers
Full sentences left byte-identical to `en.json` are bugs (brand names and URL placeholders
are the exception). Every locale has them; see §6 for the per-locale checklist.
`[TODO: Translate]` placeholders are the sanctioned intermediate state during feature
development (see §7) — do not "fix" them unless the feature owner asked for translations.
### R9 — Mirror the source even when the source is wrong
If `en.json` itself contains an inconsistency (e.g. the `Civitai` vs `CivitAI` casing split,
or the `CivitArchive` typo in `modals.relinkCivitai.helpText.format4`), translate/transcribe
it as-is in your locale and instead **fix the source** in `en.json` (then propagate by
re-syncing and re-translating affected keys). Do not silently diverge in one locale only.
---
## 2. Per-language term maps
Preferred rendering per term. "Fix" means the locale currently contains the wrong variant
and must be normalized. `en` = keep the English word as-is.
### fr
| Term | Use | Fix |
|---|---|---|
| recipe | Recipe(s) | Replace all "recette(s)" (58 keys, e.g. `recipes.actions.deleteRecipeWithShortcut`, `toast.recipes.rematchComplete`) with "Recipe(s)" |
| Checkpoint | Checkpoint | `statistics.modelTypes.checkpoint` = "Point de contrôle" → "Checkpoint" |
| trigger words | mot(s)-clé(s) | unify: `modals.model.triggerWords.editWord` uses "mot déclencheur" — pick one |
| prompt / negative prompt | Prompt / prompt négatif | — |
| base model | modèle(s) de base | — |
| preset | préréglage | unify: `modals.model.usageTips.addPresetParameter` "prédéfini", `toast.presets.restored` "par défaut" |
| hash | hash | `conflictConfirm.message` "hachage" → "hash" |
| tags | tags | `settings.sections.priorityTags` "Étiquettes" → "Tags" |
| metadata | métadonnées | `loras.controls.refresh.fullTooltip` keeps English "metadata" |
| duplicates | doublon(s) | unify with "dupliqué(e)s" |
| bulk | groupé(e) | unify with "par lot / mode lot" variants |
### de
| Term | Use | Fix |
|---|---|---|
| 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" |
| register | Sie (formal) | 7 keys use "du/dein" (`settings.backup.managementHelp`, `modals.checkUpdates.message/tip`, `doctor.footer`, …) |
### es
| Term | Use | Fix |
|---|---|---|
| recipe | receta(s) | — |
| Checkpoint | Checkpoint | 5 statistics keys "Punto(s) de control" → "Checkpoints" (`statistics.metrics.checkpoints`, `statistics.insights.unusedCheckpoints.*`, `statistics.modelTypes.checkpoint`) |
| trigger words | palabra(s) de activación | 2 keys already use it; ~15 keys "palabra(s) clave" (reads as search keyword) → unify |
| base model | modelo base | — |
| preset | preajuste | 3 keys keep English "preset", 1 "preestablecido" → preajuste |
| workflow | pick flujo de trabajo or workflow | currently 21× "flujo de trabajo" vs 10× "workflow" |
| bulk | masivo / por lotes | unify; "Batch Import" → traducción |
| tags | etiquetas | — |
### ru
| Term | Use | Fix |
|---|---|---|
| recipe | рецепт(ы) | English leftovers: `initialization.recipes.title`, `recipes.batchImport.*`, `toast.recipes.recipeSaved` → translate |
| Checkpoint | Checkpoint (recommended) | 3 variants today: "Checkpoint" (17 keys), «Чекпойнт», «Контрольная точка» (statistics, 6 keys) — statistics MUST drop «Контрольная точка» |
| Embedding | Embedding | «Эмбеддинг» variant exists in `settings.priorityTags.modelTypes.embedding` — unify |
| prompt | промпт | 8 keys use «запрос» (reads as "database/HTTP request") → «промпт» |
| base model | базовая модель | — |
| preset | пресет | `header.theme.presets` "Предустановки" → пресеты |
| workflow | Workflow (recommended) | «рабочий процесс» used in 4 keys — unify |
| hash | pick хеш or хэш | both spellings co-occur |
| tag(s) | тег(и) | — |
| typos | — | `settings.misc.loraSyntaxFormatHelp`: «безпотерьного» → «беспотерьного» |
### he
| Term | Use | Fix |
|---|---|---|
| recipe | מתכון / מתכונים | — |
| Checkpoint | Checkpoint | 5 statistics keys «נקודת/נקודות ביקורת» (road/security checkpoint) → "Checkpoint(s)" (`statistics.metrics.checkpoints`, `statistics.modelTypes.checkpoint`, `statistics.insights.unusedCheckpoints.*`) |
| Embedding | Embedding | `statistics` keys use הטמעות → Embedding |
| prompt | pick הנחיה or פרומפט | 9 keys הנחיה vs 3 פרומפט — unify (recommend פרומפט, SD-community loanword) |
| preset | קביעה מראש | `header.filter.presetOverwriteConfirm` uses פריסט → unify |
| hash | pick one of האש / גיבוב / hash | 3 variants co-occur — unify (recommend hash or גיבוב) |
| metadata | pick מטא-דאטה or מטא-נתונים | 38 vs 17 keys — unify |
| model | מודל | 13 keys use דגם/דגמים — unify |
| bulk | pick one of 5 variants | 5 different renderings ("כמות גדולה", "המוני", "קבוצתי", "אצווה", …) — unify; `loras.controls.bulk.action` "כמות גדולה" reads as "large quantity" |
### ja
| Term | Use | Fix |
|---|---|---|
| recipe | レシピ | `initialization.recipes.title` keeps English "Recipe Manager" — translate to レシピマネージャー |
| 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 個 | `globalContextMenu.rematchRecipes.success` uses 件, `.cancelled` uses 個 — unify |
### ko
| Term | Use | Fix |
|---|---|---|
| recipe | 레시피 | — |
| Checkpoint | Checkpoint (recommended) | 4 keys transliterate 체크포인트 (`settings.priorityTags.modelTypes.checkpoint`, `toast.recipes.missingCheckpointPath/missingCheckpointInfo/downloadCheckpointFailed`) |
| Embedding | Embedding | 3 keys 임베딩 (`settings.priorityTags.modelTypes.embedding`, `uiHelpers.nodeSelector.embedding`) |
| base model | 베이스 모델 | 6 keys «기본 모델» read as "default model" → 베이스 모델 (`settings.downloadSkipBaseModels.*`, `toast.loras.downloadSkippedByBaseModel`) |
| workflow | pick 워크플로 or 워크플로우 | 26 vs 6 keys — unify |
| bulk | 일괄 | `modals.checkUpdates.tip` "벌크 모드" → 일괄 모드 |
| tag logic | — | `header.filter.tagLogicAny` = "모든 태그 일치 (OR)" is **inverted** (should be "하나 이상의 태그 일치") and identical to `tagLogicAll` |
| particle | — | `modelCard.sendToWorkflow.checkpointNotImplemented`: "Checkpoint을" → "Checkpoint를" |
### zh-CN / zh-TW
| Term | zh-CN | zh-TW |
|---|---|---|
| recipe | 配方 (fix 食谱 → 配方, 14 keys in rematch flow) | 配方 (fix 食譜 → 配方, 17 keys in rematch flow) |
| Checkpoint | Checkpoint (fix 检查点 → Checkpoint, 5 keys: `toast.recipes.missingCheckpointPath/missingCheckpointInfo/downloadCheckpointFailed`, `modelCard.actions.checkpointNameCopied`, `modelCard.sendToWorkflow.checkpointNotImplemented`) | Checkpoint (fix 檢查點 → Checkpoint, 4 keys: `modelCard.actions.copyCheckpointName`, `toast.recipes.missing*`×2, `toast.recipes.downloadCheckpointFailed`) |
| base model | 基础模型 (fix 基模型 → 基础模型, 3 keys in `modals.model.versions.filters.*`) | 基礎模型 ✓ consistent |
| prompt | 提示词 ✓ | 提示詞 ✓ |
| preset | 预设 ✓ | 預設 ✓ |
| workflow | 工作流 ✓ | 工作流 ✓ |
| trigger words | 触发词 ✓ | 觸發詞 ✓ |
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
All items below were **resolved** in the 2026-08 sweep — treat them as a regression
watch-list: do not reintroduce these renderings.
1. **Checkpoint rendered as a literal security/road checkpoint** — fr, es, ru, he, zh-CN,
zh-TW all had 46 keys in the `statistics.*` domain reading as "control point"; reverted
to "Checkpoint".
2. **"recipe" variants that break the one-noun rule** — fr "recette" → "Recipe", zh
食谱/食譜 → 配方, de/ja/ru leftover English "Recipe" translated.
3. **ko `header.filter.tagLogicAny`** — was inverted ("모든 태그 일치 (OR)") and identical
to `tagLogicAll`; now "어느 하나의 태그와 일치 (OR)".
4. **ja `modals.model.versions.actions.viewLocalTooltip`** — was the stale "近日対応予定"
("coming soon"); all 9 locales now describe the actual action.
5. **Stale help texts**`settings.downloadSkipBaseModels.help`,
`settings.aiProvider.apiBaseHelp`, `settings.hideEarlyAccessUpdates.help` retranslated
in all locales to the current `en.json` wording.
6. **en.json source bugs** (fixed in source, then mirrored):
- "Civitai" → "CivitAI" brand casing (values only; key names `relinkCivitai` etc. keep
their lowercase form and must not be renamed)
- `modals.relinkCivitai.helpText.format4` "CivitArchive" typo → "CivArchive"
- `zh-CN recipes.controls.import.downloadLocationPreview` invented `{path}` removed
---
## 4. Placeholder contract deviations (current)
`{...}` token sets must match `en.json` per key. All deviations found in the 2026-08 sweep
were fixed, with one *intentional* exception:
**`toast.settings.mappingsUpdated`** — the caller passes a hardcoded English inflection
(`plural: count !== 1 ? 's' : ''`). Languages that cannot build a plural by appending that
`s` (zh-CN/zh-TW, ja, ko, de, ru, he) **drop `{plural}`** and render a count-friendly form
(`({count})` or a measure word); fr and es keep it (`mappage{plural}`, `mapeo{plural}`).
```python
# keep a copy of this rule next to the key if it ever moves:
# fr/es: "... ({count} mappage{plural})"
# de/ru/he: "... ({count})"
# zh-CN: "{count} 条映射)" / zh-TW: "{count} 個對應)" / ja: "{count} マッピング)"
```
Do NOT add `{...}` tokens the source lacks (the caller will not supply them, and the literal
text renders in the UI), and do NOT rename source tokens (`{typePlural}` stays `{typePlural}`).
---
## 5. One term, one rendering — offender matrix
Cross-locale summary of §2 inconsistencies. "✓" = already consistent. All ✗ cells were
resolved in the 2026-08 sweep; the row shows the single rendering now in force per locale.
| Term | fr | de | es | ru | he | ja | ko | zh-CN | zh-TW |
|---|---|---|---|---|---|---|---|---|---|
| recipe | Recipe | Rezept | receta | рецепт | מתכון | レシピ | 레시피 | 配方 | 配方 |
| Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint | Checkpoint |
| Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding | Embedding |
| prompt | Prompt | Prompt | prompt | промпт | פרומפט | プロンプト | 프롬프트 | 提示词 | 提示詞 |
| base model | modèle de base | Basismodell | modelo base | базовая модель | מודל בסיס | ベースモデル | 베이스 모델 | 基础模型 | 基礎模型 |
| preset | préréglage | Voreinstellung | preajuste | пресет | קביעה מראש | プリセット | 프리셋 | 预设 | 預設 |
| workflow | Workflow | Workflow | workflow | Workflow | workflow | ワークフロー | 워크플로 | 工作流 | 工作流 |
| hash | hash | Hash | hash | хеш | hash | ハッシュ | 해시 | 哈希 | 雜湊 |
| metadata | métadonnées | Metadaten | metadatos | метаданные | מטא-נתונים | メタデータ | 메타데이터 | 元数据 | 中繼資料 |
| tags | Tags | Tags | etiquetas | теги | תגיות | タグ | 태그 | 标签 | 標籤 |
| duplicates | en double | Duplikate | duplicados | дубликаты | כפילויות | 重複 | 중복 | 重复项 | 重複項 |
| bulk | groupé | Massen- | por lotes | пакетный | בכמות גדולה | 一括 | 일괄 | 批量 | 批量 |
Watch: ja/ko keep the model-type names **Checkpoint/Embedding** and `Diffusion Model` in
Latin (consistent with their model-type sections) — do not transliterate them as
チェックポイント/체크포인트.
---
## 6. Untranslated English leftovers (status)
Values byte-identical to `en.json` that are actual UI sentences are bugs (brand names and
URL placeholders are the exception). As of the 2026-08 sweep, **all previously untranslated
blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipes.batchImport*`
(fr/de/es/ru/he/ja/ko), `banners.communitySupport.*`, `modals.model.license.*`,
`globalContextMenu.fetchMissingLicenses.*`, the `doctor.*` issue/action/label subset,
`toast.settings.libraryLoadFailed` / `libraryActivateFailed`, `toast.api.moveFailed`,
`settings.extraFolderPaths.restartRequired`, `toast.recipes.recipeSaved`,
`sidebar.dragDrop.moveUnsupported`, `checkpoints.modelTypes.diffusion_model`
(ja/ko keep the English loanword), `initialization.recipes.title`.
The only values that remain intentionally identical to `en.json` are non-translatable:
URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`),
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
(`CivitAI → CivArchive → Archive DB`), and the external playlist title
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
every locale because that is what the user actually sees in ComfyUI.
License labels (`modals.model.license.*`): the restriction labels are now translated in all
locales (the sibling `creditRequired` has always been translated).
---
## 7. Workflow for agents and translators
### Adding a new UI string
1. Add the key to `locales/en.json` only.
2. Run `python scripts/sync_translation_keys.py` — it inserts the key into the other 9
locales (as a `[TODO: Translate]` placeholder) preserving formatting.
3. **During feature development, stop here.** While the UI copy is still in flux, leave the
`[TODO: Translate]` placeholders as-is — translating churning strings into 9 locales is
wasted work. Placeholders are a normal intermediate state, not a bug.
4. Once the wording is final and the feature owner explicitly asks for translations,
translate **all** pending `[TODO: Translate]` keys in every locale (not just the latest
feature's), applying §1–§3 (placeholders verbatim, Recipe rule, term maps, register).
Find pending keys with: `grep -c "TODO: Translate" locales/*.json`
5. If the new string contains new terminology, extend §2 tables.
### Fixing a translation bug
1. Locate the key (dotted path) in the relevant locale file.
2. Check the corresponding `en.json` value and the actual caller (grep `static/js` or
`web/comfyui` for the key) to learn which placeholders are passed.
3. Fix trivially; for normalization sweeps (e.g. "recette" → "Recipe"), do it file-wide for
the offending keys only — do not touch unrelated lines.
4. If the bug is in `en.json` itself (R9), fix the source first, then re-sync and update all
locales.
### Verification
```bash
pytest tests/i18n/test_i18n.py # key parity + JSON validity + JS key references
python scripts/sync_translation_keys.py --dry-run # shows which keys would change; add --verbose for per-key detail
npm test # frontend tests incl. i18n helpers
```
`pytest tests/i18n` only checks structure. Quality conventions in this document are not
machine-enforced — a human/agent review pass is required.
### Anti-patterns checklist
- [ ] Placeholders `{x}` / `{{x}}` differ from `en.json`
- [ ] Same source term translated 2+ ways in the same file (see §5)
- [ ] "Checkpoint" became a literal checkpoint; "recipe" became menu/prescription/food-cookbook
- [ ] Brand names translated or transliterated (LoRA, CivitAI, ComfyUI, …)
- [ ] Latin locale using full-width `:()`; fr using `"` as apostrophe
- [ ] Mixed 你/您, du/Sie, tú/usted
- [ ] Full English sentences left behind (see §6)
- [ ] Register/typos/mojibake; source string is stale vs `en.json` (compare semantics, not
just words)
+337
View File
@@ -0,0 +1,337 @@
# Plan: Global Rate-Limit Abidance for Recipe Ingest & Metadata Fetching
**Issue:** [#1085 — Large Recipe Ingest Appears to not abide by vendor rate limits, possibly a few other errors?](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1085)
**Status:** v2 — reviewed; decisions recorded in §10. **Phase 1 implemented**
(2026-08-27, commit `c2a2048c`): coordinator + downloader gate + Fix C
failover semantics + helper double-wait fix + settings. **Phase 2
implemented** (2026-08-27): batch-import rate-limit failures map to
`SKIPPED` + `rate_limited` WebSocket flag + UI slowdown hint (toast + status
text, i18n keys synced); `download_to_memory` / `get_response_headers` /
`download_file` register 429 cooldowns. Changes vs v1: Fix C moved to
Phase 1, helper double-wait resolved in Phase 1, gate/guard ordering
specified.
**Scope:** HTTP API traffic to CivitAI (`civitai.red`) and CivArchive (`civarchive.com`) from metadata fetching (bulk refresh, metadata sync, recipe analysis/enrichment, usage-control lookups). Large binary downloads (model files / preview images via `download_file`) are out of scope for *pacing* (they are already single-connection transfers) but their 429 responses should still be *registered*.
> Context: a first batch of fixes for this issue was already committed as
> `ee233548` ("fix(recipes): enforce batch-import concurrency bound and harden
> ingest errors (#1085)"): the batch-import concurrency controller now shares a
> real semaphore (bounds 15 actually apply), the Comfy parser tolerates
> list/`None` `ckpt_name`, CivArchive treats empty error payloads as failures,
> and offline-cooldown short-circuits log at DEBUG. This plan covers the two
> remaining orchestration-level fixes:
> **Fix 2** — slow down globally when a vendor rate limit is hit (respect
> `Retry-After`, queue instead of hammering); **Fix 3** — stop immediately
> failing over to CivArchive when CivitAI is rate-limited.
---
## 1. Problem Statement
During a large recipe ingest (e.g. importing the example-images directory,
which can be thousands of images), the manager fires one metadata request per
checkpoint + per LoRA per image through the fallback provider chain
(`civitai_api → civarchive_api → sqlite`). Consequences observed in #1085:
1. **CivitAI gets hammered** → 429s. The consumer then *immediately* tries
CivArchive for the same lookup, so **CivArchive gets hammered too** before
it was ever naturally needed (its only real job is recovering metadata for
models deleted from CivitAI).
2. Requests are retried per-call after `Retry-After`, but **each concurrent
call sleeps independently** → thundering herd: thousands of coroutines wake
at the same moment and re-flood the vendor.
3. While CivArchive is in the `ConnectivityGuard` cooldown, every batch item
short-circuits and is marked `FAILED` — the batch import's success/failure
accounting is polluted by a transient vendor state (log spam was fixed in
`ee233548`; the item-failure accounting is not).
4. `ConnectivityGuard` (`py/services/connectivity_guard.py`) only treats
transport-level unreachability as offline; **HTTP 429 is invisible to it**,
so nothing ever intentionally paces request rate.
User expectation from the issue: *"once a vendor rate limit time out is hit,
you should trigger a slow down with intentional reduction in request rate"*.
## 2. Current State (verified against code)
### 2.1 Where 429s are surfaced
- `Downloader.make_request` (`py/services/downloader.py:1120-1132`): HTTP 429 →
returns `RateLimitError(message, retry_after=…)` parsed from `Retry-After`
(missing header defaults to `None`).
- `CivitaiClient._make_request` (`py/services/civitai_client.py:97-100`):
converts `RateLimitError` to a raise immediately; no waiting. Transient
5xx/connection errors are retried 3× with 1s/2s/4s backoff.
- `CivArchiveClient._make_request` (`py/services/civarchive_client.py`):
raises `RateLimitError` with `provider="civarchive_api"` when not set.
- `_RateLimitRetryHelper` (`py/services/model_metadata_provider.py:45-102`):
per-call retry loop — sleeps `retry_after` (capped at 1800 s; `≥120 s` ⇒ no
retry), then re-raises. Because every concurrent call runs its own helper,
they sleep in parallel and re-fire in parallel.
- `FallbackMetadataProvider` (`py/services/model_metadata_provider.py:488-508,
564-584` etc.): on a final `RateLimitError` from one provider it logs
"skipping to next provider" and **continues to the next network provider** —
this is the direct cause of the CivArchive flood.
- `MetadataSyncService.fetch_and_update_model`
(`py/services/metadata_sync_service.py:248-333`): manually iterates
`provider_attempts`; on `RateLimitError` it `continue`s to the next provider
(same failover problem), then reports `"Rate limited"` when nothing
succeeded.
- `Downloader.make_request` has a per-destination scope already available:
`_guard_destination(url)` returns the hostname (`downloader.py:1194-1199`),
used by `ConnectivityGuard`.
### 2.2 What pacing exists today
- `ConnectivityGuard`: per-destination cooldown (30 s base, ×2 per extra
failure batch, 300 s cap) triggered only by transport errors
(`connectivity_guard.py:168-197`).
- `AdaptiveConcurrencyController` (batch import, fixed in `ee233548`): shared
semaphore enforces 15 concurrent items; *duration*-based adjustment only —
it never sees HTTP statuses, so it cannot distinguish "slow because rate
limited" from "slow because big image".
- No token bucket, no minimum inter-request interval, no shared
`Retry-After` gate anywhere (`grep` for throttle/token-bucket/rate-limiter:
0 hits).
## 3. Requirements & Constraints
R1. **Respect `Retry-After`.** After a 429, no further request to that
destination may be sent before the vendor's retry window elapses.
R2. **No thundering herd.** Concurrent waiters must share one wake-up (gate),
not sleep independently.
R3. **No double load.** A CivitAI 429 must not trigger a CivArchive request
for the same lookup. CivArchive should only be consulted when CivitAI
legitimately has no answer (404 / "not found"), or when CivitAI is
unreachable long-term.
R4. **No spurious item failures.** A rate-limited request must not turn a
batch-import item into `FAILED`; it should wait (bounded) and retry, or at
worst be `SKIPPED` with a clear "rate limited" reason (re-runnable import).
R5. **Never hang forever.** All waiting is bounded by a configurable cap; on
expiry the caller receives the `RateLimitError` and can decide.
R6. **Keep legitimate failover.** Deleted-model recovery via CivArchive/sqlite
must keep working (404 paths unchanged).
R7. **Single choke point.** The pacing gate should live where every API call
passes (the `Downloader`), so bulk refresh, metadata sync, recipe
analysis, and usage-control lookups all benefit without per-feature work.
## 4. Approach Comparison
### A. Reactive gate — shared `Retry-After` deadman clock (recommended core)
A process-wide, per-destination coordinator records the *next-allowed-send*
timestamp from each 429 (`now + max(retry_after, backoff)`). Every request
through `Downloader.make_request` consults the gate *before sending* and *when
a 429 arrives*; waiters block on a shared `asyncio.Event` that fires when the
cooldown expires.
- Pros: single choke point (R7); herd-free (R2); honors server guidance (R1);
no guessing at vendor limits; covers all providers automatically; reuses
existing per-destination scoping.
- Cons: still experiences 429s before slowing down (reactive); long
`Retry-After` windows (CivArchive has been observed at ~1500 s) need a sane
wait cap + skip/retry UX.
### B. Preemptive pacing — minimum inter-request interval (recommended companion)
Per-destination token bucket (simplest form: capacity 1 — at least `N` seconds
between consecutive API requests; `N` configurable, default ~0.75 s ≈ 80
r/min ceiling).
- Pros: prevents most 429s before they happen — exactly the "intentional
reduction in request rate" the issue asks for; trivial to implement on top
of A's coordinator.
- Cons: adds latency to bulk operations (thousands of models × `N`); the *exact*
vendor limits are unknown (CivitAI anonymous vs keyed vs `civitai.red`
mirror differ), so the default must be conservative-but-not-crippling and
settings-tunable.
### C. Fallback semantics change — stop network→network failover on 429 (must-do, low risk)
`FallbackMetadataProvider` (and `MetadataSyncService.fetch_and_update_model`'s
manual loop) must treat a final `RateLimitError` as a **terminal, non-failover
result** for network providers. Local-only providers (sqlite archive DB) may
stay as a last resort (no vendor cost).
- Pros: directly removes the CivArchive flood; small, surgical change.
- Cons: none significant; requires care to keep 404-failover intact (R6).
### Rejected / deferred
- **Per-feature retry queues** (batch import pauses & resumes whole batches):
richer UX but much larger change (batch state machine, WebSocket states);
unnecessary once A+B make requests wait at the choke point. Defer unless
review finds the bounded-wait UX insufficient.
- **Full token bucket with burst credit**: overkill; capacity-1 interval is
enough given the shared semaphore already caps concurrency at 5.
- **Retrying in `connectivity_guard`**: wrong layer — the guard is about
transport reachability, not vendor quota.
## 5. Recommended Architecture
New singleton **`RateLimitCoordinator`** (`py/services/rate_limit_coordinator.py`,
mirroring `ConnectivityGuard`'s singleton + per-destination patterns):
```
state per destination (hostname):
next_allowed_send: float (monotonic) # from 429 Retry-After + backoff
consecutive_429: int # for backoff growth
last_send_at: float # for min-interval pacing
waiters: list[Future] | asyncio.Event # shared wake-up per cooldown cycle
```
API:
- `async wait_for_slot(destination, request_started_within_window: bool)`
— called by `Downloader.make_request` *before* sending (blocks until
`min(now >= next_allowed_send)` and inter-request interval elapses) and
re-armable after a 429.
- `register_rate_limit(destination, retry_after: float | None)`
— called on 429: `next_allowed_send = max(now + retry_after_or_backoff, current)`;
`consecutive_429 += 1`; backoff = `retry_after` honored, else exponential
`30 · 2^(n-1)` capped at 1800 s; creates/re-arms the shared wake-up event.
- `register_success(destination)` — resets `consecutive_429` (called from the
existing 200 path in `make_request`).
- `remaining_seconds(destination)`, `in_cooldown(destination)` — for tests and
diagnostics.
Enforcement points:
1. **`Downloader.make_request`** (`downloader.py:1102-1132`): ordering inside
the method is **connectivity-guard fail-fast first** (offline short-circuit
costs nothing to check), **then** `await coordinator.wait_for_slot(destination)`
before `session.request`. On 429: `coordinator.register_rate_limit(...)`,
then *wait for the gate and re-send* (loop, bounded by
`rate_limit_max_wait_seconds`, default 300; `retry_after ≥ cap` ⇒ fail
immediately). After the loop, return the `RateLimitError` to the caller
(unchanged contract) **with `exc.gate_handled = True` set** so downstream
retry helpers know the wait already happened. 200 path calls
`register_success`.
2. **`Downloader.download_to_memory` / `get_response_headers`** (phase 2):
register 429s (so API calls queue); waiting only in `make_request`
initially.
3. **`FallbackMetadataProvider`** (`model_metadata_provider.py`): remove
network→network failover on `RateLimitError` — re-raise; only sqlite stays
as a local last resort (implementation: per-method `except RateLimitError`
handler that marks the chain rate-limited and stops iterating).
4. **`MetadataSyncService.fetch_and_update_model`**
(`metadata_sync_service.py:248-333`): on `RateLimitError` from the default
provider, stop appending further network providers (sqlite may remain);
the existing `any_rate_limited` merge already produces `"Rate limited"`.
5. **Batch import** (`batch_import_service.py`): no structural change needed —
items now wait inside `make_request`; optionally (phase 2) map residual
rate-limit failures (after the wait cap) to `SKIPPED` with
`"rate limited (retry_after=…s); re-run the import later"` instead of
`FAILED`, and surface a `rate_limited` flag in the WebSocket progress
broadcast.
6. **`_RateLimitRetryHelper` retries** (`model_metadata_provider.py`):
**Phase 1** — when the raised `RateLimitError` carries `gate_handled = True`
(set by the downloader after honoring the gate), the helper skips its own
`retry_after` sleep and re-raises immediately, eliminating the double wait.
The wiring stays so a `RateLimitError` still propagates cleanly; full
demotion/removal can follow once the gate proves out.
Settings (`settings.json`, schema extension in `SettingsManager`):
| key | default | meaning |
|---|---|---|
| `rate_limit_gate_enabled` | `true` | master switch for the coordinator |
| `rate_limit_max_wait_seconds` | `300` | how long `make_request` waits on a 429 gate before returning the error |
| `rate_limit_min_interval_seconds` | `0.75` | minimum seconds between API requests per destination (pacing, R6-friendly conservative default) |
## 6. Changes by File
| File | Change |
|---|---|
| `py/services/rate_limit_coordinator.py` (new) | coordinator singleton + per-destination state + tests seam |
| `py/services/downloader.py` | gate pre-check + 429 register/wait/retry loop + `register_success`; log the 429 notice at INFO once per cooldown, then DEBUG |
| `py/services/model_metadata_provider.py` | `FallbackMetadataProvider`: stop network failover on `RateLimitError`; helper skips its sleep when the error is marked `gate_handled` |
| `py/services/metadata_sync_service.py` | `fetch_and_update_model`: same failover semantics; keep sqlite last resort |
| `py/services/batch_import_service.py` | (phase 2) rate-limit failures → `SKIPPED` + `rate_limited` progress flag |
| `py/services/settings_manager.py` | new settings keys + defaults |
| `tests/services/test_rate_limit_coordinator.py` (new) | gate unit tests |
| `tests/services/test_civitai_client.py` / `test_civarchive_client.py` | provider-level 429 behavior |
| `tests/services/test_metadata_service.py` | failover-chain tests |
| `tests/services/test_batch_import_service.py` | SKIPPED-on-rate-limit |
## 7. Impact, Risks, Open Questions
- **Behavior change**: with the gate in `make_request`, any request can block
up to the wait cap — UI actions that call the API (e.g. a model-details
fetch) may take longer during cooldowns. Mitigation: bounded cap + INFO log
+ the existing async request handling already tolerates slow responses.
**Decided (§10): interactive requests take the same bounded wait** — one
behavior, no call-source plumbing; cooldowns are usually short.
- **Gate waits occupy batch slots**: with the 15 batch semaphore, all slots
can park on a gate simultaneously, freezing visible progress for up to one
wait cap per wave. Bounded and acceptable; the phase-2 `SKIPPED` mapping +
WebSocket `rate_limited` flag (both confirmed in scope, §10) make the stall
visible and recoverable.
- **Rate limit reality check**: CivitAI anonymous vs keyed limits, and whether
`civitai.red` differs, is unverified. Default pacing `0.75 s/req` is a
conservative guess (R6). Open question for maintainer: preferred default
and whether an API-keyed ceiling should be higher.
- **Long CivArchive windows**: `Retry-After ~1500 s` observed in code
comments. **Decided (§10): keep the 300 s default cap** — such lookups
fail/skip rather than park a request path for 25 minutes; batch import maps
them to `SKIPPED` (phase 2) so the user can re-run later.
- **Double waiting**: `_RateLimitRetryHelper` + gate could stack waits.
**Resolved in Phase 1**: the downloader marks gate-honored errors with
`gate_handled = True` and the helper skips its own sleep for those.
- **Downloads**: `download_file` 429s return an error to download managers
unchanged (already handled); only *registration* is proposed, so future
API calls queue behind a large `Retry-After` from a download burst.
## 8. Test Plan
1. **Coordinator unit tests** (new file):
- 429 with `retry_after` → `wait_for_slot` blocks ~that long, then passes.
- N concurrent waiters all wake together (herd test, wall-clock ≈ one
window, not N windows).
- Consecutive 429s grow backoff; `register_success` resets.
- Missing `Retry-After` → default backoff path.
- Wait cap: request fails after `rate_limit_max_wait_seconds` with
`RateLimitError`.
2. **Downloader tests** (mock aiohttp session): 429 then 200 → `make_request`
returns success after gate delay; two back-to-back calls to the same
destination are spaced ≥ `min_interval`; different destinations are not
spaced.
3. **Provider tests**: `FallbackMetadataProvider.get_model_version_info` —
Civitai raises `RateLimitError` → CivArchive mock **not called**; 404 still
falls through to CivArchive; sqlite still tried after network 429.
4. **Sync-service test**: `fetch_and_update_model` with a rate-limited default
provider → result error contains `"Rate limited"` and sqlite attempt state
unchanged.
5. **Batch-import test**: analysis provider 429s first, then succeeds →
item ends `SUCCESS` (wait path), and post-cap 429 → `SKIPPED` with
rate-limit reason (phase 2).
6. Full regression: `pytest tests/services tests/routes tests/standalone`
(currently 1582 passing).
## 9. Implementation Phases
- **Phase 1 (this plan, reviewed):** `RateLimitCoordinator` +
`Downloader.make_request` integration (guard fail-fast → gate pre-check
pacing → 429 register/wait/retry loop with cap → `gate_handled` marking) +
settings + **Fix C failover semantics** (`FallbackMetadataProvider`,
`fetch_and_update_model` — moved up from phase 2: smallest diff, kills the
CivArchive flood immediately, independent of coordinator correctness) +
`_RateLimitRetryHelper` double-wait fix + coordinator/downloader/provider/
sync tests.
- **Phase 2:** batch-import `SKIPPED`-on-rate-limit + `rate_limited` WebSocket
progress flag + slowdown hint (confirmed, §10),
`download_to_memory`/HEAD 429 registration, batch tests.
- **Phase 3:** full regression + docs + commit referencing `(#1085)`.
## 10. Review Checklist — Decisions (2026-08-27)
- [x] Default pacing interval `0.75 s` — **accepted** as conservative default;
tunable via `rate_limit_min_interval_seconds`. Revisit if CivitAI
publishes keyed/anonymous ceilings.
- [x] Wait cap `300 s` — **accepted**; long-window CivArchive lookups fail →
batch import marks them `SKIPPED` with a rate-limit reason (phase 2).
- [x] Interactive API calls also wait (bounded) — **yes**, same behavior for
all callers.
- [x] Keep sqlite as last resort behind a network rate limit — **yes**
(local-only, no vendor cost).
- [x] UI hint — **yes**: WebSocket `rate_limited` flag + "rate limited —
slowing down" hint in batch-import progress (phase 2); INFO logging
regardless.
+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.
+395 -213
View File
File diff suppressed because it is too large Load Diff
+265 -83
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": {
@@ -67,15 +88,15 @@
"steps": {
"fetch": {
"title": "Fetch Models Metadata",
"content": "Click the <strong>Fetch</strong> button to download model metadata and preview images from Civitai."
"content": "Click the <strong>Fetch</strong> button to download model metadata and preview images from CivitAI."
},
"download": {
"title": "Download New Models",
"content": "Use the <strong>Download</strong> button to download models directly from Civitai URLs."
"content": "Use the <strong>Download</strong> button to download models directly from CivitAI URLs."
},
"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."
}
}
},
@@ -103,8 +136,8 @@
"actions": {
"addToFavorites": "Add to favorites",
"removeFromFavorites": "Remove from favorites",
"viewOnCivitai": "View on Civitai",
"notAvailableFromCivitai": "Not available from Civitai",
"viewOnCivitai": "View on CivitAI",
"notAvailableFromCivitai": "Not available from CivitAI",
"viewOnHuggingFace": "View on Hugging Face",
"sendToWorkflow": "Send to ComfyUI (Click: Append, Shift+Click: Replace)",
"copyLoRASyntax": "Copy LoRA Syntax",
@@ -137,7 +170,7 @@
"exampleImages": {
"checkError": "Error checking for example images",
"missingHash": "Missing model hash information.",
"noRemoteImagesAvailable": "No remote example images available for this model on Civitai"
"noRemoteImagesAvailable": "No remote example images available for this model on CivitAI"
},
"badges": {
"update": "Update",
@@ -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...",
@@ -290,15 +316,15 @@
}
},
"settings": {
"civitaiApiKey": "Civitai API Key",
"civitaiApiKeyPlaceholder": "Enter your Civitai API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from Civitai",
"civitaiApiKey": "CivitAI API Key",
"civitaiApiKeyPlaceholder": "Enter your CivitAI API key",
"civitaiApiKeyHelp": "Used for authentication when downloading models from CivitAI",
"civitaiApiKeyConfigured": "Configured",
"civitaiApiKeyNotConfigured": "Not configured",
"civitaiApiKeySet": "Set up",
"civitaiHost": {
"label": "Civitai host",
"help": "Choose which Civitai site opens when using View on Civitai links.",
"label": "CivitAI host",
"help": "Choose which CivitAI site opens when using View on CivitAI links.",
"options": {
"com": "civitai.com (SFW)",
"red": "civitai.red (unrestricted)"
@@ -319,8 +345,8 @@
},
"aria2HelpLink": "Learn how to set up the aria2 download backend",
"civitaiHostBanner": {
"title": "Civitai host preference available",
"content": "Civitai now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
"title": "CivitAI host preference available",
"content": "CivitAI now uses civitai.com for SFW content and civitai.red for unrestricted content. You can change which site opens by default in Settings.",
"openSettings": "Open Settings"
},
"openSettingsFileLocation": {
@@ -450,7 +476,9 @@
},
"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.",
"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",
@@ -555,7 +583,7 @@
},
"downloadPathTemplates": {
"title": "Download Path Templates",
"help": "Configure folder structures for different model types when downloading from Civitai.",
"help": "Configure folder structures for different model types when downloading from CivitAI.",
"availablePlaceholders": "Available placeholders:",
"templateOptions": {
"flatStructure": "Flat Structure",
@@ -592,7 +620,7 @@
"exampleImages": {
"downloadLocation": "Download Location",
"downloadLocationPlaceholder": "Enter folder path for example images",
"downloadLocationHelp": "Enter the folder path where example images from Civitai will be saved",
"downloadLocationHelp": "Enter the folder path where example images from CivitAI will be saved",
"autoDownload": "Auto Download Example Images",
"autoDownloadHelp": "Automatically download example images for models that don't have them (requires download location to be set)",
"openMode": "Open Example Images Action",
@@ -647,7 +675,7 @@
},
"metadataArchive": {
"enableArchiveDb": "Enable Metadata Archive Database",
"enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from Civitai.",
"enableArchiveDbHelp": "Use a local database to access metadata for models that have been deleted from CivitAI.",
"status": "Status",
"statusAvailable": "Available",
"statusUnavailable": "Not Available",
@@ -750,7 +778,7 @@
"fullTooltip": "Reload all model details from metadata files—use if the library looks out of date or after manual edits."
},
"fetch": {
"title": "Fetch metadata from Civitai",
"title": "Fetch metadata from CivitAI",
"action": "Fetch"
},
"download": {
@@ -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",
@@ -825,10 +852,10 @@
"enrichHfAgent": "Enrich HF Metadata (AI)"
},
"contextMenu": {
"refreshMetadata": "Refresh Civitai Data",
"refreshMetadata": "Refresh CivitAI Data",
"checkUpdates": "Check Updates",
"linkModel": "Link Model",
"linkCivitai": "Link to Civitai",
"linkCivitai": "Link to CivitAI",
"linkHuggingFace": "Link to HuggingFace",
"copySyntax": "Copy LoRA Syntax",
"copyFilename": "Copy Model Filename",
@@ -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",
@@ -860,12 +886,28 @@
"actions": {
"sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI",
"copyRecipeSyntax": "Copy Recipe Syntax",
"deleteRecipeWithShortcut": "Delete recipe (Del)"
},
"navigation": {
"label": "Recipe navigation",
"previousWithShortcut": "Previous recipe (\u2190)",
"nextWithShortcut": "Next recipe (\u2192)"
"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",
@@ -873,6 +915,89 @@
"sendFailed": "Failed to send workflow to ComfyUI",
"noWorkflow": "No embedded workflow found in this recipe"
},
"status": {
"ready": "Ready to use",
"missingCount": "{count} missing",
"deletedCount": "{count} deleted",
"downloadMissing": "Download {count} missing LoRAs",
"downloadMissingTooltip": "Click to download missing LoRAs"
},
"loraStatus": {
"none": "No LoRAs in this recipe",
"allAvailable": "All LoRAs available - Ready to use",
"missing": "{missing} of {total} LoRAs missing",
"missingAndUnavailable": "{missing} of {total} LoRAs missing, {unavailable} unavailable (deleted from source or unresolvable hash)",
"partial": "{unavailable} of {total} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used",
"noneUsable": "No usable LoRAs - {unavailable} of {total} deleted from source or unresolvable hash"
},
"resources": {
"inLibrary": "In Library",
"notInLibrary": "Not in Library",
"deleted": "Deleted",
"hashInvalid": "Unresolvable Hash",
"inLibraryTooltip": "This model exists in your local library",
"notInLibraryTooltip": "This model is not in your library",
"deletedTooltip": "This LoRA was deleted from the source and is no longer available for download",
"hashInvalidTooltip": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated",
"noLorasAssociated": "No LoRAs associated with this recipe",
"noLorasWhyToggle": "Why no LoRAs?",
"noLorasImportMethod": "Import method",
"noLorasInferredNote": "Possible reason (inferred) — this recipe was imported before import diagnostics were recorded.",
"noLorasChannels": {
"batch_import_url": "Batch import (image URL)",
"batch_import_local": "Batch import (local file)",
"url": "Image URL import",
"local": "Local file import",
"upload": "Image upload",
"widget": "Saved from workflow",
"reimport_url": "Re-import (image URL)",
"reimport_local": "Re-import (local file)"
},
"noLorasReasons": {
"no_loras_used": "The generation metadata is complete and does not reference any LoRAs.",
"api_meta_no_lora_resources": "The source API returned no LoRA resource data for this image. LoRAs shown on the CivitAI page may come from internal data that the public API does not expose.",
"api_meta_missing": "The source API returned no generation metadata for this image.",
"no_embedded_metadata": "The image has no embedded generation metadata, so LoRA information could not be recovered.",
"workflow_metadata_limited": "The image's embedded metadata is a ComfyUI workflow; extracting LoRA information from workflows is limited.",
"video_no_metadata": "Video files do not carry embedded generation metadata.",
"metadata_unsupported": "The image contains metadata in a format that could not be parsed.",
"unknown": "The reason could not be determined from the stored recipe data."
},
"noLorasDetails": {
"apiMetaFields": "API metadata fields",
"modelVersionIds": "Model version IDs reported",
"embeddedMetadata": "Embedded metadata",
"present": "found",
"absent": "none"
},
"download": "Download",
"downloadLoraTooltip": "Download this LoRA",
"preparingDownload": "Preparing download...",
"reconnect": "Reconnect",
"reconnectTooltip": "Reconnect with a local LoRA",
"reconnectInstructions": "Enter LoRA syntax or name to reconnect:",
"reconnectExample": "Example: <lora:name:1> or just the name",
"reconnectPlaceholder": "Enter LoRA name or syntax",
"reconnectSuggestionsLoading": "Searching local library...",
"reconnectSuggestionsEmpty": "No matching LoRAs in your local library",
"reconnectMatchSameHash": "Same hash",
"reconnectMatchSameVersion": "Same model version",
"reconnectMatchSimilarFilename": "Similar filename",
"reconnectMatchSimilarName": "Similar name",
"undoReconnect": "Undo",
"undoReconnectTooltip": "Restore the association this entry had before reconnecting",
"undoReconnectTooltipNamed": "Restore to {name} (the association before reconnecting)",
"viewOnCivitai": "View on CivitAI",
"openLoraDetails": "View {name} in the LoRA library",
"openCheckpointDetails": "View {name} in the model library",
"checkpointDeletedTooltip": "This checkpoint was deleted from the source and can no longer be downloaded - reconnect it with a local model",
"checkpointHashInvalidTooltip": "This checkpoint hash cannot be resolved on CivitAI - the model may have been updated",
"reconnectCheckpoint": "Reconnect",
"reconnectCheckpointTooltip": "Reconnect with a local checkpoint",
"checkpointReconnectInstructions": "Enter checkpoint name to reconnect:",
"checkpointReconnectPlaceholder": "Enter checkpoint name",
"checkpointReconnectSuggestionsEmpty": "No matching checkpoints in your local library"
},
"controls": {
"import": {
"action": "Import",
@@ -910,7 +1035,7 @@
"downloadingLoras": "Downloading LoRAs...",
"savingRecipe": "Saving recipe...",
"startingDownload": "Starting download for LoRA {current}/{total}",
"deletedFromCivitai": "Deleted from Civitai",
"deletedFromCivitai": "Deleted from CivitAI",
"inLibrary": "In Library",
"notInLibrary": "Not in Library",
"earlyAccessRequired": "This LoRA requires early access payment to download.",
@@ -963,6 +1088,7 @@
}
},
"duplicates": {
"finding": "Scanning for duplicate recipes...",
"found": "Found {count} duplicate groups",
"noGroups": "No duplicate groups found with the current matching basis",
"keepLatest": "Keep Latest Versions",
@@ -995,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",
@@ -1032,6 +1151,8 @@
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"rateLimitedSlowdown": "Rate limited — slowing down...",
"rateLimitedHint": "Some items were skipped due to metadata provider rate limits. Re-run the import later to retry them.",
"progress": "Progress",
"total": "Total",
"success": "Success",
@@ -1235,7 +1356,7 @@
"download": {
"title": "Download Model from URL",
"titleWithType": "Download {type} from URL",
"civitaiUrl": "Civitai URL(s):",
"civitaiUrl": "CivitAI URL(s):",
"placeholder": "https://civitai.com/models/...",
"urlHint": "Enter one CivitAI, CivArchive, or Hugging Face URL per line. Supports multiple URLs for batch download.",
"selectHfFiles": "Select file(s) to download from this repository:",
@@ -1270,7 +1391,7 @@
"inLibrary": "In Library"
},
"errors": {
"invalidUrl": "Invalid Civitai URL format",
"invalidUrl": "Invalid CivitAI URL format",
"noVersions": "No versions available for this model",
"mixedSources": "Cannot mix CivitAI and Hugging Face URLs in the same batch.",
"noModelFiles": "No model files found in this repository."
@@ -1384,7 +1505,7 @@
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
"downloadOption": {
"title": "Download from Civitai",
"title": "Download from CivitAI",
"description": "Save remote examples locally for offline use and faster loading"
},
"importOption": {
@@ -1411,7 +1532,7 @@
"confirmAction": "Save & Link"
},
"relinkCivitai": {
"title": "Re-link to Civitai",
"title": "Re-link to CivitAI",
"warning": "Warning:",
"warningText": "This is a potentially destructive operation. Re-linking will:",
"warningList": {
@@ -1420,14 +1541,15 @@
"unintendedConsequences": "May have other unintended consequences"
},
"proceedText": "Only proceed if you're sure this is what you want.",
"urlLabel": "Civitai Model URL:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 or https://civitai.red/models/649516/model-name?modelVersionId=726676",
"urlLabel": "CivitAI Model URL:",
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 or https://civitai.red/models/12345/model-name?modelVersionId=67890",
"helpText": {
"title": "Paste any Civitai model URL from civitai.com or civitai.red. Supported formats:",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"note": "Note: If no modelVersionId is provided, the latest version will be used."
"title": "Paste any CivitAI or CivitArchive model URL. Supported formats:",
"format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "Note: If no modelVersionId is provided, the latest version will be used.",
"format4": "https://civarchive.com/models/12345 (CivArchive)"
},
"confirmAction": "Confirm Re-link"
},
@@ -1437,8 +1559,8 @@
"editFileName": "Edit file name",
"editBaseModel": "Edit base model",
"editVersionName": "Edit version name",
"viewOnCivitai": "View on Civitai",
"viewOnCivitaiText": "View on Civitai",
"viewOnCivitai": "View on CivitAI",
"viewOnCivitaiText": "View on CivitAI",
"viewOnHuggingFace": "View on Hugging Face",
"viewOnHuggingFaceText": "View on Hugging Face",
"viewCreatorProfile": "View Creator Profile",
@@ -1490,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",
@@ -1501,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",
@@ -1540,7 +1666,7 @@
},
"license": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
"noRentCivit": "No CivitAI generation",
"noRent": "No generation services",
"noSell": "No selling models",
"creditRequired": "Creator credit required",
@@ -1561,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",
@@ -1605,28 +1731,28 @@
"newer": "Newer Version",
"newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access",
"earlyAccessTooltip": "This version currently requires CivitAI early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
"onSiteOnlyTooltip": "This version is only available for on-site generation on Civitai"
"onSiteOnlyTooltip": "This version is only available for on-site generation on CivitAI"
},
"actions": {
"download": "Download",
"downloadTooltip": "Download this version",
"downloadChooseFilesTooltip": "Choose which files to download",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"downloadEarlyAccessTooltip": "Download this early access version from CivitAI",
"downloadPaidTooltip": "Download this paid version from CivitAI",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on CivitAI",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
"ignore": "Ignore",
"unignore": "Unignore",
"ignoreTooltip": "Ignore update notifications for this version",
"unignoreTooltip": "Resume update notifications for this version",
"viewVersionOnCivitai": "View version on Civitai",
"viewVersionOnCivitai": "View version on CivitAI",
"earlyAccessTooltip": "Requires early access purchase",
"resumeModelUpdates": "Resume updates for this model",
"ignoreModelUpdates": "Ignore updates for this model",
@@ -1647,7 +1773,7 @@
},
"empty": "No version history available for this model yet.",
"error": "Failed to load versions.",
"missingModelId": "This model is missing a Civitai model id.",
"missingModelId": "This model is missing a CivitAI model id.",
"hfGroupInfo": "This is a HuggingFace model group. Open the library to see all versions in the grid.",
"confirm": {
"delete": "Delete this version from your library?"
@@ -1731,14 +1857,14 @@
"tips": {
"title": "Tips & Tricks",
"civitai": {
"title": "Civitai Integration",
"description": "Connect your Civitai account: Visit Profile Avatar → Settings → API Keys → Add API Key, then paste it in Lora Manager settings.",
"alt": "Civitai API Setup"
"title": "CivitAI Integration",
"description": "Connect your CivitAI account: Visit Profile Avatar → Settings → API Keys → Add API Key, then paste it in Lora Manager settings.",
"alt": "CivitAI API Setup"
},
"download": {
"title": "Easy Download",
"description": "Use Civitai URLs to quickly download and install new models.",
"alt": "Civitai Download"
"description": "Use CivitAI URLs to quickly download and install new models.",
"alt": "CivitAI Download"
},
"recipes": {
"title": "Save Recipes",
@@ -1826,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",
@@ -1846,7 +2014,8 @@
"settings": "Settings & Configuration",
"extensions": "Extensions",
"newBadge": "NEW"
}
},
"newContentBadge": "New"
},
"update": {
"title": "Check for Updates",
@@ -1922,7 +2091,7 @@
"submitGithubIssue": "Submit GitHub Issue",
"joinDiscord": "Join Discord",
"youtubeChannel": "YouTube Channel",
"civitaiProfile": "Civitai Profile",
"civitaiProfile": "CivitAI Profile",
"supportKofi": "Support on Ko-fi",
"supportPatreon": "Support on Patreon"
},
@@ -2005,7 +2174,10 @@
"preparingForDownloadFailed": "Error preparing LoRAs for download",
"enterLoraName": "Please enter a LoRA name or syntax",
"reconnectedSuccessfully": "LoRA reconnected successfully",
"reconnectBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, LoRA: {lora}) — they are architecture-compatible",
"reconnectFailed": "Error reconnecting LoRA: {message}",
"loraRestored": "LoRA restored to its previous association",
"loraRestoreFailed": "Error restoring LoRA: {message}",
"noPromptToSend": "No prompt to send",
"cannotSend": "Cannot send recipe: Missing recipe ID",
"sendFailed": "Failed to send recipe to workflow",
@@ -2013,6 +2185,16 @@
"missingCheckpointPath": "Checkpoint path not available",
"missingCheckpointInfo": "Missing checkpoint information",
"downloadCheckpointFailed": "Failed to download checkpoint: {message}",
"enterCheckpointName": "Please enter a checkpoint name",
"checkpointReconnectedSuccessfully": "Checkpoint reconnected successfully",
"reconnectCheckpointBaseModelMismatch": "Reconnected, but base models differ (recipe: {recipe}, checkpoint: {checkpoint}) — they are architecture-compatible",
"checkpointReconnectFailed": "Error reconnecting checkpoint: {message}",
"checkpointRestored": "Checkpoint restored to its previous association",
"checkpointRestoreFailed": "Error restoring checkpoint: {message}",
"checkpointDownloadUnavailable": "This checkpoint cannot be downloaded without CivitAI identifiers - try reconnecting it with a local checkpoint",
"missingLoraDownloadInfo": "Missing download information for this LoRA",
"hashNotFoundOnCivitai": "This LoRA hash cannot be resolved on CivitAI - the model may have been updated or the hash is invalid",
"downloadLoraFailed": "Failed to download LoRA: {message}",
"cannotDelete": "Cannot delete recipe: Missing recipe ID",
"deleteConfirmationError": "Error showing delete confirmation",
"deletedSuccessfully": "Recipe deleted successfully",
@@ -2036,12 +2218,10 @@
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportRateLimited": "Metadata provider rate limit reached — requests are being slowed and some items may be skipped. You can re-run the import later.",
"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",
@@ -2049,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",
@@ -2093,8 +2274,8 @@
"bulkUpdatesChecking": "Checking selected {type}(s) for updates...",
"bulkUpdatesSuccess": "Updates available for {count} selected {type}(s)",
"bulkUpdatesNone": "No updates found for selected {type}(s)",
"bulkUpdatesMissing": "Selected {type}(s) are not linked to Civitai updates",
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without Civitai links",
"bulkUpdatesMissing": "Selected {type}(s) are not linked to CivitAI updates",
"bulkUpdatesPartialMissing": "Skipped {missing} selected {type}(s) without CivitAI links",
"bulkUpdatesFailed": "Failed to check updates for selected {type}(s): {message}",
"invalidCharactersRemoved": "Invalid characters removed from filename",
"filenameCannotBeEmpty": "File name cannot be empty",
@@ -2220,10 +2401,11 @@
"contextMenu": {
"contentRatingSet": "Content rating set to {level}",
"contentRatingFailed": "Failed to set content rating: {message}",
"relinkSuccess": "Model successfully re-linked to Civitai",
"relinkSuccess": "Model successfully re-linked to CivitAI",
"relinkFailed": "Error: {message}",
"linkHfSuccess": "Model successfully linked to HuggingFace",
"linkHfFailed": "Error: {message}",
"linkCivArchSuccess": "Model successfully re-linked via CivitArchive",
"fetchMetadataFirst": "Please fetch metadata from CivitAI first",
"noCivitaiInfo": "No CivitAI information available",
"missingHash": "Model hash not available"
@@ -2313,7 +2495,7 @@
},
"issues": {
"civitai_api_key": {
"title": "Civitai API Key"
"title": "CivitAI API Key"
},
"cache_health": {
"title": "Model Cache Health"
@@ -2367,9 +2549,9 @@
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM CivitAI Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
"learnMore": "LM CivitAI Extension Tutorial"
},
"cacheHealth": {
"corrupted": {
+403 -221
View File
File diff suppressed because it is too large Load Diff
+420 -238
View File
File diff suppressed because it is too large Load Diff
+432 -250
View File
File diff suppressed because it is too large Load Diff
+361 -179
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": {
@@ -67,15 +88,15 @@
"steps": {
"fetch": {
"title": "モデルメタデータの取得",
"content": "<strong>取得</strong>ボタンをクリックして、Civitaiからモデルのメタデータとプレビュー画像をダウンロードします。"
"content": "<strong>取得</strong>ボタンをクリックして、CivitAIからモデルのメタデータとプレビュー画像をダウンロードします。"
},
"download": {
"title": "新しいモデルのダウンロード",
"content": "<strong>ダウンロード</strong>ボタンを使って、CivitaiのURLから直接モデルをダウンロードできます。"
"content": "<strong>ダウンロード</strong>ボタンを使って、CivitAIのURLから直接モデルをダウンロードできます。"
},
"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>すると、更新の確認や除外モデルの管理などのグローバル操作メニューが表示されます。"
}
}
},
@@ -103,17 +136,17 @@
"actions": {
"addToFavorites": "お気に入りに追加",
"removeFromFavorites": "お気に入りから削除",
"viewOnCivitai": "Civitaiで表示",
"notAvailableFromCivitai": "Civitaiでは利用できません",
"viewOnCivitai": "CivitAIで表示",
"notAvailableFromCivitai": "CivitAIでは利用できません",
"viewOnHuggingFace": "Hugging Face で見る",
"sendToWorkflow": "ComfyUIに送信(クリック:追加、Shift+クリック:置換)",
"copyLoRASyntax": "LoRA構文をコピー",
"checkpointNameCopied": "checkpointの名前をコピーしました",
"checkpointNameCopied": "Checkpointの名前をコピーしました",
"toggleBlur": "ぼかしの切り替え",
"show": "表示",
"openExampleImages": "例画像フォルダを開く",
"replacePreview": "プレビューを置換",
"copyCheckpointName": "checkpoint名をコピー",
"copyCheckpointName": "Checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
@@ -131,13 +164,13 @@
"updateFailed": "お気に入り状態の更新に失敗しました"
},
"sendToWorkflow": {
"checkpointNotImplemented": "checkpointをワークフローに送信 - 実装予定の機能",
"checkpointNotImplemented": "Checkpointをワークフローに送信 - 実装予定の機能",
"missingPath": "このカードのモデルパスを特定できません"
},
"exampleImages": {
"checkError": "例画像の確認中にエラーが発生しました",
"missingHash": "モデルハッシュ情報がありません。",
"noRemoteImagesAvailable": "このモデルのCivitaiでのリモート例画像は利用できません"
"noRemoteImagesAvailable": "このモデルのCivitAIでのリモート例画像は利用できません"
},
"badges": {
"update": "アップデート",
@@ -160,7 +193,7 @@
},
"checkModelUpdates": {
"label": "アップデートを確認",
"loading": "{type} のアップデートを確認中",
"loading": "{type} のアップデートを確認中...",
"success": "{type} のアップデートが {count} 件見つかりました",
"none": "すべての {type} は最新です",
"error": "{type} のアップデート確認に失敗しました: {message}"
@@ -173,18 +206,11 @@
"error": "例画像フォルダのクリーンアップに失敗しました:{message}"
},
"fetchMissingLicenses": {
"label": "Refresh license metadata",
"loading": "Refreshing license metadata for {typePlural}...",
"success": "Updated license metadata for {count} {typePlural}",
"none": "All {typePlural} already have license metadata",
"error": "Failed to refresh license metadata for {typePlural}: {message}"
},
"repairRecipes": {
"label": "レシピデータの修復",
"loading": "レシピデータを修復中...",
"success": "{count} 件のレシピを正常に修復しました。",
"cancelled": "修復がキャンセルされました。{count}個のレシピが修復されました。",
"error": "レシピの修復に失敗しました: {message}"
"label": "ライセンスメタデータを更新",
"loading": "{typePlural}のライセンスメタデータを更新中...",
"success": "{count} 件の{typePlural}のライセンスメタデータを更新しました",
"none": "すべての{typePlural}には既にライセンスメタデータがあります",
"error": "{typePlural}のライセンスメタデータを更新できませんでした: {message}"
},
"rematchRecipes": {
"label": "レシピをローカルモデルに再マッチング",
@@ -290,15 +316,15 @@
}
},
"settings": {
"civitaiApiKey": "Civitai APIキー",
"civitaiApiKeyPlaceholder": "Civitai APIキーを入力してください",
"civitaiApiKeyHelp": "Civitaiからモデルをダウンロードするときの認証に使用されます",
"civitaiApiKey": "CivitAI APIキー",
"civitaiApiKeyPlaceholder": "CivitAI APIキーを入力してください",
"civitaiApiKeyHelp": "CivitAIからモデルをダウンロードするときの認証に使用されます",
"civitaiApiKeyConfigured": "設定済み",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": {
"label": "Civitai ホスト",
"help": "「View on Civitai」リンクを使うときに開く Civitai サイトを選択します。",
"label": "CivitAI ホスト",
"help": "「View on CivitAI」リンクを使うときに開く CivitAI サイトを選択します。",
"options": {
"com": "civitai.comSFW のみ)",
"red": "civitai.red(制限なし)"
@@ -319,8 +345,8 @@
},
"aria2HelpLink": "aria2 ダウンロードバックエンドの設定方法",
"civitaiHostBanner": {
"title": "Civitai ホスト設定を利用できます",
"content": "Civitai は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
"title": "CivitAI ホスト設定を利用できます",
"content": "CivitAI は現在、SFW コンテンツには civitai.com、制限なしコンテンツには civitai.red を使用しています。設定で既定で開くサイトを変更できます。",
"openSettings": "設定を開く"
},
"openSettingsFileLocation": {
@@ -428,7 +454,7 @@
},
"downloadSkipBaseModels": {
"label": "ベースモデルのダウンロードをスキップ",
"help": "すべてのダウンロードフローに適用されます。ここでは対応しているベースモデルのみ選択できます。",
"help": "有効にすると、選択したベースモデルを使用するバージョンはスキップされます。",
"searchPlaceholder": "ベースモデルを絞り込む...",
"empty": "現在の検索に一致するベースモデルはありません。",
"summary": {
@@ -450,7 +476,9 @@
},
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"groupByModelHelp": "有効にすると、各CivitAIモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
"stickyControls": "アクションバーを常に表示",
"stickyControlsHelp": "有効にすると、アクションバー(更新、ダウンロードなど)がスクロール時にパンくずナビゲーションと一緒に画面上部に固定されます。",
"displayDensity": "表示密度",
"displayDensityOptions": {
"default": "デフォルト",
@@ -503,7 +531,7 @@
"defaultLoraRoot": "LoRAルート",
"defaultLoraRootHelp": "ダウンロード、インポート、移動用のデフォルトLoRAルートディレクトリを設定",
"defaultCheckpointRoot": "Checkpointルート",
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトcheckpointルートディレクトリを設定",
"defaultCheckpointRootHelp": "ダウンロード、インポート、移動用のデフォルトCheckpointルートディレクトリを設定",
"defaultUnetRoot": "Diffusion Modelルート",
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "Embeddingルート",
@@ -517,7 +545,7 @@
"extraFolderPaths": {
"title": "追加フォルダーパス",
"description": "LoRA Manager専用の追加モデルルートパス。ComfyUIの標準フォルダー外の場所からモデルを読み込みます。ComfyUIの動作を低下させる可能性のある大規模ライブラリに最適です。",
"restartRequired": "Requires restart to take effect",
"restartRequired": "変更を有効にするには再起動が必要です",
"modelTypes": {
"lora": "LoRAパス",
"checkpoint": "Checkpointパス",
@@ -529,8 +557,8 @@
"saveError": "追加フォルダーパスの更新に失敗しました: {message}",
"validation": {
"duplicatePath": "このパスはすでに設定されています",
"checkpointUnetOverlap": "checkpoints と diffusion models に同じパスは使用できません:{paths}",
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。checkpoints と diffusion models には別々のフォルダを使用してください。"
"checkpointUnetOverlap": "Checkpoints と diffusion models に同じパスは使用できません:{paths}",
"checkpointUnetOverlapInline": "このパスは別のモデルタイプですでに使用されています。Checkpoints と diffusion models には別々のフォルダを使用してください。"
}
},
"priorityTags": {
@@ -540,7 +568,7 @@
"helpLinkLabel": "優先タグのヘルプを開く",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "チェックポイント",
"checkpoint": "Checkpoint",
"embedding": "埋め込み"
},
"saveSuccess": "優先タグを更新しました。",
@@ -555,7 +583,7 @@
},
"downloadPathTemplates": {
"title": "ダウンロードパステンプレート",
"help": "Civitaiからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
"help": "CivitAIからダウンロードする際の異なるモデルタイプのフォルダ構造を設定します。",
"availablePlaceholders": "利用可能なプレースホルダー:",
"templateOptions": {
"flatStructure": "フラット構造",
@@ -592,7 +620,7 @@
"exampleImages": {
"downloadLocation": "ダウンロード場所",
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
"downloadLocationHelp": "Civitaiからの例画像を保存するフォルダパスを入力してください",
"downloadLocationHelp": "CivitAIからの例画像を保存するフォルダパスを入力してください",
"autoDownload": "例画像の自動ダウンロード",
"autoDownloadHelp": "例画像がないモデルの例画像を自動的にダウンロードします(ダウンロード場所の設定が必要)",
"openMode": "サンプル画像を開く動作",
@@ -625,7 +653,7 @@
},
"hideEarlyAccessUpdates": {
"label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新"
"help": "有効にすると、早期アクセス更新のみのモデルには「更新あり」バッジが表示されません。"
},
"hidePaidUpdates": {
"label": "有料更新を非表示",
@@ -647,7 +675,7 @@
},
"metadataArchive": {
"enableArchiveDb": "メタデータアーカイブデータベースを有効化",
"enableArchiveDbHelp": "Civitaiから削除されたモデルのメタデータにアクセスするためにローカルデータベースを使用します。",
"enableArchiveDbHelp": "CivitAIから削除されたモデルのメタデータにアクセスするためにローカルデータベースを使用します。",
"status": "ステータス",
"statusAvailable": "利用可能",
"statusUnavailable": "利用不可",
@@ -708,7 +736,7 @@
"custom": "カスタム(OpenAI 互換)"
},
"apiBase": "APIベースURL",
"apiBaseHelp": "LLM APIのベースURL(例:https://api.openai.com/v1)。空の場合はプロバイダーのデフォルトが使用されます。",
"apiBaseHelp": "LLM APIのベースURL。プリセットを選択するか、カスタムURLを入力してください。ドロップダウンには対応しているすべてのプロバイダーのプリセットが表示されます。",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "APIキー",
"apiKeyHelp": "LLMプロバイダーのAPIキー。ローカルに保存され、選択したLLMプロバイダー以外のサーバーに送信されることはありません。",
@@ -750,7 +778,7 @@
"fullTooltip": "メタデータファイルから全モデル情報を再読み込みします。リストが古いと感じるときや手動編集後に使用してください。"
},
"fetch": {
"title": "Civitaiからメタデータを取得",
"title": "CivitAIからメタデータを取得",
"action": "取得"
},
"download": {
@@ -786,7 +814,6 @@
"setContentRating": "すべてのモデルのコンテンツレーティングを設定",
"copyAll": "すべての構文をコピー",
"refreshAll": "すべてのメタデータを更新",
"repairMetadata": "選択したレシピのメタデータを修復",
"rematchMetadata": "選択したモデルをローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"checkUpdates": "選択項目の更新を確認",
@@ -825,10 +852,10 @@
"enrichHfAgent": "HF メタデータをAIで補完"
},
"contextMenu": {
"refreshMetadata": "Civitaiデータを更新",
"refreshMetadata": "CivitAIデータを更新",
"checkUpdates": "更新確認",
"linkModel": "モデルをリンク",
"linkCivitai": "Civitai にリンク",
"linkCivitai": "CivitAI にリンク",
"linkHuggingFace": "HuggingFace にリンク",
"copySyntax": "LoRA構文をコピー",
"copyFilename": "モデルファイル名をコピー",
@@ -842,7 +869,6 @@
"replacePreview": "プレビューを置換",
"setContentRating": "コンテンツレーティングを設定",
"moveToFolder": "フォルダに移動",
"repairMetadata": "メタデータを修復",
"rematchMetadata": "ローカルモデルに再マッチング",
"reimportMetadata": "ソースから再インポート",
"excludeModel": "モデルを除外",
@@ -860,6 +886,7 @@
"actions": {
"sendCheckpoint": "ComfyUIへ送信",
"sendRecipe": "ComfyUIへ送信",
"copyRecipeSyntax": "レシピ構文をコピー",
"deleteRecipeWithShortcut": "レシピを削除(Del"
},
"navigation": {
@@ -867,12 +894,110 @@
"previousWithShortcut": "前のレシピ(←)",
"nextWithShortcut": "次のレシピ(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "ファイルの場所を開く",
"copyId": "レシピIDをコピー"
},
"openFileLocation": {
"success": "ファイルの場所を正常に開きました",
"failed": "ファイルの場所を開くのに失敗しました",
"copied": "パスをクリップボードにコピーしました: {{path}}",
"clipboardFallback": "パス: {{path}}"
}
},
"workflow": {
"sendWorkflow": "ワークフローをComfyUIへ送信",
"sent": "ワークフローをComfyUIへ送信しました",
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
},
"status": {
"ready": "使用可能",
"missingCount": "{count} 件不足",
"deletedCount": "{count} 件削除済み",
"downloadMissing": "不足している {count} 件のLoRAをダウンロード",
"downloadMissingTooltip": "クリックして不足しているLoRAをダウンロード"
},
"loraStatus": {
"none": "このレシピにはLoRAがありません",
"allAvailable": "すべてのLoRAが利用可能 - 使用可能",
"missing": "{total} 件中 {missing} 件のLoRAが不足",
"missingAndUnavailable": "{total} 件中 {missing} 件のLoRAが不足、{unavailable} 件は利用不可(ソースから削除済みかハッシュを解決できません)",
"partial": "{total} 件中 {unavailable} 件のLoRAが利用不可(ソースから削除済みかハッシュを解決できません)- レシピ使用時はスキップされます",
"noneUsable": "使用可能なLoRAがありません - {total} 件中 {unavailable} 件がソースから削除済みかハッシュを解決できません"
},
"resources": {
"inLibrary": "ライブラリ内",
"notInLibrary": "ライブラリ外",
"deleted": "削除済み",
"hashInvalid": "解決不能なハッシュ",
"inLibraryTooltip": "このモデルはローカルライブラリに存在します",
"notInLibraryTooltip": "このモデルはライブラリにありません",
"deletedTooltip": "この LoRA は配信元から削除されたため、ダウンロードできません",
"hashInvalidTooltip": "このLoRAハッシュはCivitAIで解決できません - モデルが更新された可能性があります",
"noLorasAssociated": "このレシピに関連付けられた LoRA はありません",
"noLorasWhyToggle": "LoRA がない理由",
"noLorasImportMethod": "インポート方法",
"noLorasInferredNote": "考えられる理由(推定)— このレシピはインポート診断が記録される前にインポートされました。",
"noLorasChannels": {
"batch_import_url": "一括インポート(画像 URL",
"batch_import_local": "一括インポート(ローカルファイル)",
"url": "画像 URL からのインポート",
"local": "ローカルファイルのインポート",
"upload": "画像のアップロード",
"widget": "ワークフローから保存",
"reimport_url": "再インポート(画像 URL",
"reimport_local": "再インポート(ローカルファイル)"
},
"noLorasReasons": {
"no_loras_used": "生成メタデータは完全で、LoRA への参照は含まれていません。",
"api_meta_no_lora_resources": "ソース API がこの画像の LoRA リソースデータを返しませんでした。CivitAI ページに表示される LoRA は、公開 API では公開されない内部データに由来する場合があります。",
"api_meta_missing": "ソース API がこの画像の生成メタデータを返しませんでした。",
"no_embedded_metadata": "画像に埋め込まれた生成メタデータがないため、LoRA 情報を復元できませんでした。",
"workflow_metadata_limited": "画像に埋め込まれたメタデータは ComfyUI ワークフローです。ワークフローからの LoRA 情報の抽出には限界があります。",
"video_no_metadata": "動画ファイルには埋め込み生成メタデータがありません。",
"metadata_unsupported": "画像に解析できない形式のメタデータが含まれています。",
"unknown": "保存されたレシピデータから理由を特定できませんでした。"
},
"noLorasDetails": {
"apiMetaFields": "API メタデータフィールド",
"modelVersionIds": "報告されたモデルバージョン ID 数",
"embeddedMetadata": "埋め込みメタデータ",
"present": "あり",
"absent": "なし"
},
"download": "ダウンロード",
"downloadLoraTooltip": "この LoRA をダウンロード",
"preparingDownload": "ダウンロードを準備中...",
"reconnect": "再接続",
"reconnectTooltip": "ローカルの LoRA と再接続",
"reconnectInstructions": "再接続する LoRA の構文または名前を入力してください:",
"reconnectExample": "例:<lora:name:1> または名前のみ",
"reconnectPlaceholder": "LoRA 名または構文を入力",
"reconnectSuggestionsLoading": "ローカルライブラリを検索中...",
"reconnectSuggestionsEmpty": "ローカルライブラリに一致するLoRAがありません",
"reconnectMatchSameHash": "同じハッシュ",
"reconnectMatchSameVersion": "同じモデルバージョン",
"reconnectMatchSimilarFilename": "類似のファイル名",
"reconnectMatchSimilarName": "類似の名前",
"undoReconnect": "元に戻す",
"undoReconnectTooltip": "このエントリーを再接続前の関連付けに戻します",
"undoReconnectTooltipNamed": "{name} に戻す(再接続前の関連付け)",
"viewOnCivitai": "CivitAI で表示",
"openLoraDetails": "LoRA ライブラリで {name} を表示",
"openCheckpointDetails": "モデルライブラリで {name} を表示",
"checkpointDeletedTooltip": "この Checkpoint はソースから削除されたため、ダウンロードできません - ローカルモデルで再接続してください",
"checkpointHashInvalidTooltip": "この Checkpoint のハッシュは CivitAI で解決できません - モデルが更新された可能性があります",
"reconnectCheckpoint": "再接続",
"reconnectCheckpointTooltip": "ローカルの Checkpoint と再接続",
"checkpointReconnectInstructions": "再接続する Checkpoint の名前を入力してください:",
"checkpointReconnectPlaceholder": "Checkpoint 名を入力",
"checkpointReconnectSuggestionsEmpty": "ローカルライブラリに一致するCheckpointがありません"
},
"controls": {
"import": {
"action": "インポート",
@@ -881,7 +1006,7 @@
"dropZoneHint": "画像をここにドラッグ&ドロップ、クリップボードから貼り付け、またはクリックして参照",
"orDivider": "または画像をドラッグ&ドロップ / 貼り付け",
"imageUrlOrPath": "画像URLまたはファイルパス:",
"urlPlaceholder": "https://civitai.com/images/... または C:/path/to/image.png",
"urlPlaceholder": "https://civitai.com/images/... または https://civitai.red/images/... または C:/path/to/image.png",
"fetchImage": "画像を取得",
"recipeName": "レシピ名",
"recipeNamePlaceholder": "レシピ名を入力",
@@ -910,7 +1035,7 @@
"downloadingLoras": "LoRAをダウンロード中...",
"savingRecipe": "レシピを保存中...",
"startingDownload": "LoRA {current}/{total} のダウンロードを開始",
"deletedFromCivitai": "Civitaiから削除済み",
"deletedFromCivitai": "CivitAIから削除済み",
"inLibrary": "ライブラリ内",
"notInLibrary": "ライブラリ外",
"earlyAccessRequired": "このLoRAはダウンロードにアーリーアクセス料金が必要です。",
@@ -963,6 +1088,7 @@
}
},
"duplicates": {
"finding": "重複レシピをスキャンしています...",
"found": "{count} 個の重複グループが見つかりました",
"noGroups": "現在の一致基準では重複グループが見つかりませんでした",
"keepLatest": "最新バージョンを保持",
@@ -995,13 +1121,6 @@
"getInfoFailed": "不足LoRAの情報取得に失敗しました",
"prepareError": "ダウンロード用LoRAの準備中にエラー:{message}"
},
"repair": {
"starting": "レシピのメタデータを修復中...",
"success": "レシピのメタデータが正常に修復されました",
"skipped": "レシピはすでに最新バージョンです。修復は不要です",
"failed": "レシピの修復に失敗しました: {message}",
"missingId": "レシピを修復できません: レシピIDがありません"
},
"reimport": {
"starting": "ソースからレシピを再インポート中...",
"success": "レシピの再インポートが完了しました",
@@ -1011,61 +1130,63 @@
}
},
"batchImport": {
"title": "Batch Import Recipes",
"action": "Batch Import",
"urlList": "URL List",
"directory": "Directory",
"urlDescription": "Enter image URLs or local file paths (one per line). Each will be imported as a recipe.",
"directoryDescription": "Enter a directory path to import all images from that folder.",
"urlsLabel": "Image URLs or Local Paths",
"title": "レシピを一括インポート",
"action": "一括インポート",
"urlList": "URLリスト",
"directory": "フォルダ",
"urlDescription": "画像URLまたはローカルファイルパスを入力してください(1行に1つ)。それぞれがレシピとしてインポートされます。",
"directoryDescription": "フォルダパスを入力すると、そのフォルダ内のすべての画像がインポートされます。",
"urlsLabel": "画像URLまたはローカルパス",
"urlsPlaceholder": "https://civitai.com/images/...\nhttps://civitai.com/images/...\nC:/path/to/image.png\n...",
"urlsHint": "Enter one URL or path per line",
"directoryPath": "Directory Path",
"urlsHint": "1行に1つのURLまたはパスを入力",
"directoryPath": "フォルダパス",
"directoryPlaceholder": "/path/to/images/folder",
"browse": "Browse",
"recursive": "Include subdirectories",
"tagsOptional": "Tags (optional, applied to all recipes)",
"tagsPlaceholder": "Enter tags separated by commas",
"tagsHint": "Tags will be added to all imported recipes",
"skipNoMetadata": "Skip images without metadata",
"skipNoMetadataHelp": "Images without LoRA metadata will be skipped automatically.",
"start": "Start Import",
"startImport": "Start Import",
"importing": "Importing...",
"progress": "Progress",
"total": "Total",
"success": "Success",
"failed": "Failed",
"skipped": "Skipped",
"current": "Current",
"currentItem": "Current",
"preparing": "Preparing...",
"cancel": "Cancel",
"cancelImport": "Cancel",
"cancelled": "Import cancelled",
"completed": "Import completed",
"completedWithErrors": "Completed with errors",
"completedSuccess": "Successfully imported {count} recipe(s)",
"successCount": "Successful",
"failedCount": "Failed",
"skippedCount": "Skipped",
"totalProcessed": "Total processed",
"viewDetails": "View Details",
"newImport": "New Import",
"manualPathEntry": "Please enter the directory path manually. File browser is not available in this browser.",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportManualEntryRequired": "File browser not available. Please enter the directory path manually.",
"backToParent": "Back to parent directory",
"folders": "Folders",
"folderCount": "{count} folders",
"imageFiles": "Image Files",
"images": "images",
"imageCount": "{count} images",
"selectFolder": "Select This Folder",
"browse": "参照",
"recursive": "サブフォルダを含める",
"tagsOptional": "タグ(任意、すべてのレシピに適用)",
"tagsPlaceholder": "タグをカンマ区切りで入力",
"tagsHint": "タグはインポートされたすべてのレシピに追加されます",
"skipNoMetadata": "メタデータのない画像をスキップ",
"skipNoMetadataHelp": "LoRAメタデータのない画像は自動的にスキップされます。",
"start": "インポートを開始",
"startImport": "インポートを開始",
"importing": "インポート中...",
"rateLimitedSlowdown": "レート制限中 — 速度を落としています...",
"rateLimitedHint": "メタデータプロバイダーのレート制限により一部の項目がスキップされました。後でもう一度インポートを実行して再試行してください。",
"progress": "進捗",
"total": "合計",
"success": "成功",
"failed": "失敗",
"skipped": "スキップ",
"current": "現在",
"currentItem": "現在",
"preparing": "準備中...",
"cancel": "キャンセル",
"cancelImport": "キャンセル",
"cancelled": "インポートがキャンセルされました",
"completed": "インポートが完了しました",
"completedWithErrors": "エラーありで完了",
"completedSuccess": "{count} 件のレシピを正常にインポートしました",
"successCount": "成功",
"failedCount": "失敗",
"skippedCount": "スキップ",
"totalProcessed": "処理済みの合計",
"viewDetails": "詳細を見る",
"newImport": "新しいインポート",
"manualPathEntry": "フォルダパスを手動で入力してください。このブラウザではファイルブラウザは利用できません。",
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
"batchImportManualEntryRequired": "ファイルブラウザが利用できません。フォルダパスを手動で入力してください。",
"backToParent": "親フォルダに戻る",
"folders": "フォルダ",
"folderCount": "{count} 個のフォルダ",
"imageFiles": "画像ファイル",
"images": "画像",
"imageCount": "{count} 枚の画像",
"selectFolder": "このフォルダを選択",
"errors": {
"enterUrls": "Please enter at least one URL or path",
"enterDirectory": "Please enter a directory path",
"startFailed": "Failed to start import: {message}"
"enterUrls": "URLまたはパスを少なくとも1つ入力してください",
"enterDirectory": "フォルダパスを入力してください",
"startFailed": "インポートを開始できませんでした: {message}"
}
}
},
@@ -1235,7 +1356,7 @@
"download": {
"title": "URLからモデルをダウンロード",
"titleWithType": "URLから{type}をダウンロード",
"civitaiUrl": "Civitai URL",
"civitaiUrl": "CivitAI URL",
"placeholder": "https://civitai.com/models/...",
"urlHint": "1行に1つのCivitAI、CivArchive、またはHugging Face URLを入力してください。複数のURLを一括ダウンロードできます。",
"selectHfFiles": "このリポジトリからダウンロードするファイルを選択してください:",
@@ -1270,7 +1391,7 @@
"inLibrary": "ライブラリ内"
},
"errors": {
"invalidUrl": "無効なCivitai URL形式",
"invalidUrl": "無効なCivitAI URL形式",
"noVersions": "このモデルの利用可能なバージョンがありません",
"mixedSources": "同じバッチ内でCivitAIとHugging FaceのURLを混在させることはできません。",
"noModelFiles": "このリポジトリにモデルファイルが見つかりませんでした。"
@@ -1349,9 +1470,9 @@
"action": "すべて削除"
},
"checkUpdates": {
"title": "すべての{type}の更新を確認しますか?",
"message": "ライブラリ内のすべての{type}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
"tip": "少しずつ確認したい場合はバルクモードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"title": "すべての{typePlural}の更新を確認しますか?",
"message": "ライブラリ内のすべての{typePlural}で更新を確認します。コレクションが大きい場合は時間がかかることがあります。",
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"action": "すべて確認"
},
"bulkAddTags": {
@@ -1384,7 +1505,7 @@
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
"downloadOption": {
"title": "Civitaiからダウンロード",
"title": "CivitAIからダウンロード",
"description": "リモート例画像をローカルに保存して、オフライン使用と高速読み込みを可能にします"
},
"importOption": {
@@ -1411,7 +1532,7 @@
"confirmAction": "保存&リンク"
},
"relinkCivitai": {
"title": "Civitaiに再リンク",
"title": "CivitAIに再リンク",
"warning": "警告:",
"warningText": "これは破壊的な操作になる可能性があります。再リンクは以下を行います:",
"warningList": {
@@ -1420,14 +1541,15 @@
"unintendedConsequences": "その他の意図しない結果を引き起こす可能性"
},
"proceedText": "これが本当に必要な場合のみ続行してください。",
"urlLabel": "CivitaiモデルURL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"urlLabel": "CivitAIモデルURL",
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 または https://civitai.red/models/12345/model-name?modelVersionId=67890",
"helpText": {
"title": "CivitaiモデルURLを貼り付けてください。対応形式:",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。"
"title": "CivitAIまたはCivitArchiveのモデルURLを貼り付けてください。対応形式:",
"format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "注:modelVersionIdが提供されていない場合、最新バージョンが使用されます。",
"format4": "https://civarchive.com/models/12345 (CivArchive)"
},
"confirmAction": "再リンクを確認"
},
@@ -1437,8 +1559,8 @@
"editFileName": "ファイル名を編集",
"editBaseModel": "ベースモデルを編集",
"editVersionName": "バージョン名を編集",
"viewOnCivitai": "Civitaiで表示",
"viewOnCivitaiText": "Civitaiで表示",
"viewOnCivitai": "CivitAIで表示",
"viewOnCivitaiText": "CivitAIで表示",
"viewOnHuggingFace": "Hugging Face で見る",
"viewOnHuggingFaceText": "Hugging Face で見る",
"viewCreatorProfile": "作成者プロフィールを表示",
@@ -1470,7 +1592,7 @@
"notesHint": "Enterで保存、Shift+Enterで改行",
"addNotesPlaceholder": "メモをここに追加...",
"aboutThisVersion": "このバージョンについて",
"baseModelSearchPlaceholder": "ベースモデルを検索",
"baseModelSearchPlaceholder": "ベースモデルを検索...",
"baseModelSuggested": "おすすめ",
"baseModelNoMatch": "該当するベースモデルがありません"
},
@@ -1490,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "値",
"add": "追加",
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください"
"invalidRange": "無効な範囲形式です。x.x-y.y を使用してください",
"invalidValue": "有効な数値を入力してください",
"saveFailed": "プリセットパラメータの保存に失敗しました",
"added": "プリセットパラメータを追加しました",
"updated": "プリセットパラメータを更新しました"
},
"triggerWords": {
"label": "トリガーワード",
@@ -1501,7 +1627,7 @@
"addPlaceholder": "入力して追加するか、下の提案をクリック",
"editWord": "トリガーワードを編集",
"editPlaceholder": "トリガーワードを編集",
"copyWord": "トリガーワードをコピー",
"copyOrEditWord": "クリックでコピー、ダブルクリックで編集",
"deleteWord": "トリガーワードを削除",
"suggestions": {
"noSuggestions": "提案はありません",
@@ -1539,10 +1665,10 @@
"noNext": "次のモデルがありません"
},
"license": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
"noRent": "No generation services",
"noSell": "No selling models",
"noImageSell": "生成画像の販売禁止",
"noRentCivit": "CivitAIでの生成不可",
"noRent": "生成サービス不可",
"noSell": "モデルの販売禁止",
"creditRequired": "作成者のクレジットが必要",
"noDerivatives": "共有マージ不可",
"noReLicense": "同じ権限が必要",
@@ -1561,8 +1687,8 @@
"showCount": "例を表示({count}",
"hideExamples": "例を非表示",
"addExamples": "例を追加",
"previousExample": "前の例",
"nextExample": "次の例",
"previousExample": "前の例[",
"nextExample": "次の例]",
"noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
@@ -1605,33 +1731,33 @@
"newer": "新しいバージョン",
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"earlyAccessTooltip": "このバージョンは現在 CivitAI の早期アクセスが必要です",
"paid": "有料",
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
"onSiteOnlyTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません"
"onSiteOnlyTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません"
},
"actions": {
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadChooseFilesTooltip": "ダウンロードするファイルを選択",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "Civitai からこの有料バージョンをダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"downloadEarlyAccessTooltip": "CivitAI からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "CivitAI からこの有料バージョンをダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitAIサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
"ignore": "無視",
"unignore": "無視を解除",
"ignoreTooltip": "このバージョンの更新通知を無視",
"unignoreTooltip": "このバージョンの更新通知を再開",
"viewVersionOnCivitai": "Civitai でバージョンを表示",
"viewVersionOnCivitai": "CivitAI でバージョンを表示",
"earlyAccessTooltip": "早期アクセス購入が必要",
"resumeModelUpdates": "このモデルの更新を再開",
"ignoreModelUpdates": "このモデルの更新を無視",
"viewLocalVersions": "ローカルの全バージョンを表示",
"viewLocalTooltip": "近日対応予定"
"viewLocalTooltip": "このモデルのすべてのローカルバージョンをメインページで表示"
},
"filters": {
"label": "ベースフィルター",
@@ -1647,7 +1773,7 @@
},
"empty": "このモデルにはまだバージョン履歴がありません。",
"error": "バージョンの読み込みに失敗しました。",
"missingModelId": "このモデルにはCivitaiのモデルIDがありません。",
"missingModelId": "このモデルにはCivitAIのモデルIDがありません。",
"hfGroupInfo": "これは HuggingFace モデルグループです。ライブラリを開いてグリッドですべてのバージョンを表示してください。",
"confirm": {
"delete": "このバージョンをライブラリから削除しますか?"
@@ -1714,14 +1840,14 @@
},
"checkpoints": {
"title": "Checkpoint Managerを初期化中",
"message": "checkpointキャッシュをスキャンして構築中。数分かかる場合があります..."
"message": "Checkpointキャッシュをスキャンして構築中。数分かかる場合があります..."
},
"embeddings": {
"title": "Embedding Managerを初期化中",
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
},
"recipes": {
"title": "Recipe Managerを初期化中",
"title": "レシピマネージャーを初期化中",
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
},
"statistics": {
@@ -1731,14 +1857,14 @@
"tips": {
"title": "ヒント&コツ",
"civitai": {
"title": "Civitai統合",
"description": "Civitaiアカウントを接続:プロフィールアバター → 設定 → APIキー → APIキーを追加し、LoRA Manager設定に貼り付けてください。",
"alt": "Civitai API設定"
"title": "CivitAI統合",
"description": "CivitAIアカウントを接続:プロフィールアバター → 設定 → APIキー → APIキーを追加し、LoRA Manager設定に貼り付けてください。",
"alt": "CivitAI API設定"
},
"download": {
"title": "簡単ダウンロード",
"description": "Civitai URLを使用して新しいモデルを素早くダウンロードしてインストールできます。",
"alt": "Civitaiダウンロード"
"description": "CivitAI URLを使用して新しいモデルを素早くダウンロードしてインストールできます。",
"alt": "CivitAIダウンロード"
},
"recipes": {
"title": "レシピを保存",
@@ -1826,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": "最新の更新",
@@ -1846,7 +2014,8 @@
"settings": "設定&構成",
"extensions": "拡張機能",
"newBadge": "新着"
}
},
"newContentBadge": "新着"
},
"update": {
"title": "更新確認",
@@ -1922,7 +2091,7 @@
"submitGithubIssue": "GitHub Issueを提出",
"joinDiscord": "Discordに参加",
"youtubeChannel": "YouTubeチャンネル",
"civitaiProfile": "Civitaiプロフィール",
"civitaiProfile": "CivitAIプロフィール",
"supportKofi": "Ko-fiでサポート",
"supportPatreon": "Patreonでサポート"
},
@@ -2005,14 +2174,27 @@
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
"enterLoraName": "LoRA名または構文を入力してください",
"reconnectedSuccessfully": "LoRAが正常に再接続されました",
"reconnectBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、LoRA:{lora})— アーキテクチャ互換です",
"reconnectFailed": "LoRA再接続エラー:{message}",
"loraRestored": "LoRAが以前の関連付けに復元されました",
"loraRestoreFailed": "LoRA復元エラー:{message}",
"noPromptToSend": "送信するプロンプトがありません",
"cannotSend": "レシピを送信できません:レシピIDがありません",
"sendFailed": "レシピのワークフローへの送信に失敗しました",
"sendError": "レシピのワークフロー送信エラー",
"missingCheckpointPath": "チェックポイントのパスがありません",
"missingCheckpointInfo": "チェックポイント情報が不足しています",
"downloadCheckpointFailed": "チェックポイントのダウンロードに失敗しました: {message}",
"missingCheckpointPath": "Checkpointのパスがありません",
"missingCheckpointInfo": "Checkpoint情報が不足しています",
"downloadCheckpointFailed": "Checkpointのダウンロードに失敗しました: {message}",
"enterCheckpointName": "Checkpoint 名を入力してください",
"checkpointReconnectedSuccessfully": "Checkpointが正常に再接続されました",
"reconnectCheckpointBaseModelMismatch": "再接続しましたが、ベースモデルが異なります(レシピ:{recipe}、Checkpoint{checkpoint})— アーキテクチャ互換です",
"checkpointReconnectFailed": "Checkpoint再接続エラー:{message}",
"checkpointRestored": "Checkpoint が以前の関連付けに復元されました",
"checkpointRestoreFailed": "Checkpoint復元エラー:{message}",
"checkpointDownloadUnavailable": "CivitAI の識別子がないため、この Checkpoint をダウンロードできません - ローカルの Checkpoint と再接続してみてください",
"missingLoraDownloadInfo": "この LoRA のダウンロード情報がありません",
"hashNotFoundOnCivitai": "このLoRAハッシュはCivitAIで解決できません - モデルが更新されたか、ハッシュが無効な可能性があります",
"downloadLoraFailed": "LoRA のダウンロードに失敗しました: {message}",
"cannotDelete": "レシピを削除できません:レシピIDがありません",
"deleteConfirmationError": "削除確認の表示中にエラーが発生しました",
"deletedSuccessfully": "レシピが正常に削除されました",
@@ -2027,21 +2209,19 @@
"processingError": "処理エラー:{message}",
"folderBrowserError": "フォルダブラウザの読み込みエラー:{message}",
"recipeSaveFailed": "レシピの保存に失敗しました:{error}",
"recipeSaved": "Recipe saved successfully",
"recipeSaved": "レシピを保存しました",
"importFailed": "インポートに失敗しました:{message}",
"folderTreeFailed": "フォルダツリーの読み込みに失敗しました",
"folderTreeError": "フォルダツリー読み込みエラー",
"batchImportFailed": "Failed to start batch import: {message}",
"batchImportCancelling": "Cancelling batch import...",
"batchImportCancelFailed": "Failed to cancel batch import: {message}",
"batchImportNoUrls": "Please enter at least one URL or file path",
"batchImportNoDirectory": "Please enter a directory path",
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"batchImportFailed": "一括インポートを開始できませんでした: {message}",
"batchImportCancelling": "一括インポートをキャンセルしています...",
"batchImportCancelFailed": "一括インポートをキャンセルできませんでした: {message}",
"batchImportNoUrls": "URLまたはファイルパスを少なくとも1つ入力してください",
"batchImportNoDirectory": "フォルダパスを入力してください",
"batchImportRateLimited": "メタデータプロバイダーのレート制限に達しました — リクエストが遅延され、一部の項目がスキップされる場合があります。後でインポートを再実行できます。",
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
"noRecipesSelected": "レシピが選択されていません",
"repairBulkComplete": "修復完了:{repaired} 件修復、{skipped} 件スキップ(合計 {total} 件)",
"repairBulkSkipped": "選択した {total} 件のレシピは修復不要です",
"repairBulkFailed": "選択したレシピの修復に失敗しました:{message}",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
@@ -2049,6 +2229,7 @@
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
"reimportingViaExtension": "ブラウザ拡張機能経由でレシピを再インポート中 ({current}/{total})...",
"reimportSuccess": "レシピの再インポートが完了しました",
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
@@ -2093,8 +2274,8 @@
"bulkUpdatesChecking": "選択された{type}の更新を確認しています...",
"bulkUpdatesSuccess": "{count} 件の選択された{type}に利用可能な更新があります",
"bulkUpdatesNone": "選択された{type}には更新が見つかりませんでした",
"bulkUpdatesMissing": "選択された{type}はCivitaiの更新にリンクされていません",
"bulkUpdatesPartialMissing": "Civitaiリンクがない{missing} 件の{type}をスキップしました",
"bulkUpdatesMissing": "選択された{type}はCivitAIの更新にリンクされていません",
"bulkUpdatesPartialMissing": "CivitAIリンクがない{missing} 件の{type}をスキップしました",
"bulkUpdatesFailed": "選択された{type}の更新確認に失敗しました: {message}",
"invalidCharactersRemoved": "ファイル名から無効な文字が削除されました",
"filenameCannotBeEmpty": "ファイル名を空にすることはできません",
@@ -2120,10 +2301,10 @@
},
"settings": {
"loraRootsFailed": "LoRAルートの読み込みに失敗しました:{message}",
"checkpointRootsFailed": "checkpointルートの読み込みに失敗しました:{message}",
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング{plural}",
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
@@ -2134,8 +2315,8 @@
"compactModeToggled": "コンパクトモード {state}",
"settingSaveFailed": "設定の保存に失敗しました:{message}",
"displayDensitySet": "表示密度が {density} に設定されました",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"libraryLoadFailed": "ライブラリを読み込めませんでした: {message}",
"libraryActivateFailed": "ライブラリをアクティブ化できませんでした: {message}",
"languageChangeFailed": "言語の変更に失敗しました:{message}",
"cacheCleared": "キャッシュファイルが正常にクリアされました。次回のアクションでキャッシュが再構築されます。",
"cacheClearFailed": "キャッシュのクリアに失敗しました:{error}",
@@ -2220,10 +2401,11 @@
"contextMenu": {
"contentRatingSet": "コンテンツレーティングが {level} に設定されました",
"contentRatingFailed": "コンテンツレーティングの設定に失敗しました:{message}",
"relinkSuccess": "モデルがCivitaiに正常に再リンクされました",
"relinkSuccess": "モデルがCivitAIに正常に再リンクされました",
"relinkFailed": "エラー:{message}",
"linkHfSuccess": "モデルを HuggingFace にリンクしました",
"linkHfFailed": "エラー:{message}",
"linkCivArchSuccess": "モデルがCivitArchive経由で正常に再リンクされました",
"fetchMetadataFirst": "最初にCivitAIからメタデータを取得してください",
"noCivitaiInfo": "CivitAI情報が利用できません",
"missingHash": "モデルハッシュが利用できません"
@@ -2283,7 +2465,7 @@
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}",
"moveFailed": "アイテムを移動できませんでした: {message}",
"copiedToClipboard": "クリップボードにコピーしました",
"downloadStarted": "ダウンロードを開始しました"
},
@@ -2313,7 +2495,7 @@
},
"issues": {
"civitai_api_key": {
"title": "Civitai API キー"
"title": "CivitAI API キー"
},
"cache_health": {
"title": "モデルキャッシュの健全性"
@@ -2366,10 +2548,10 @@
"seconds": "秒"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
"title": "あなたのサポートで LoRA Manager は成長し続けます ❤️",
"content": "LoRA Managerは一人の開発者がフルタイムで維持している情熱的なプロジェクトです。Ko-fiでのご支援は開発費用のカバーや新機能のリリースに役立ち、お礼としてLM CivitAI拡張機能のライセンスキーもご提供します。すべてのご寄付が大きな違いを生みます。",
"supportCta": "Ko-fiでサポート",
"learnMore": "LM CivitAI拡張機能チュートリアル"
},
"cacheHealth": {
"corrupted": {
+366 -184
View File
File diff suppressed because it is too large Load Diff
+383 -201
View File
File diff suppressed because it is too large Load Diff
+305 -123
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": {
@@ -67,15 +88,15 @@
"steps": {
"fetch": {
"title": "获取模型元数据",
"content": "点击 <strong>获取</strong> 按钮,从 Civitai 下载模型元数据和预览图片。"
"content": "点击 <strong>获取</strong> 按钮,从 CivitAI 下载模型元数据和预览图片。"
},
"download": {
"title": "下载新模型",
"content": "使用 <strong>下载</strong> 按钮,可直接通过 Civitai URL 下载模型。"
"content": "使用 <strong>下载</strong> 按钮,可直接通过 CivitAI URL 下载模型。"
},
"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>可使用检查更新、管理已排除的模型等全局操作。"
}
}
},
@@ -103,12 +136,12 @@
"actions": {
"addToFavorites": "添加到收藏",
"removeFromFavorites": "从收藏移除",
"viewOnCivitai": "在 Civitai 查看",
"notAvailableFromCivitai": "Civitai 上不可用",
"viewOnCivitai": "在 CivitAI 查看",
"notAvailableFromCivitai": "CivitAI 上不可用",
"viewOnHuggingFace": "在 Hugging Face 查看",
"sendToWorkflow": "发送到 ComfyUI(点击:追加,Shift+点击:替换)",
"copyLoRASyntax": "复制 LoRA 语法",
"checkpointNameCopied": "检查点名称已复制",
"checkpointNameCopied": "Checkpoint 名称已复制",
"toggleBlur": "切换模糊",
"show": "显示",
"openExampleImages": "打开示例图片文件夹",
@@ -131,13 +164,13 @@
"updateFailed": "收藏状态更新失败"
},
"sendToWorkflow": {
"checkpointNotImplemented": "发送检查点到工作流 - 功能待实现",
"checkpointNotImplemented": "发送Checkpoint到工作流 - 功能待实现",
"missingPath": "无法确定此卡片的模型路径"
},
"exampleImages": {
"checkError": "检查示例图片时出错",
"missingHash": "缺少模型哈希信息。",
"noRemoteImagesAvailable": "此模型在 Civitai 上没有远程示例图片"
"noRemoteImagesAvailable": "此模型在 CivitAI 上没有远程示例图片"
},
"badges": {
"update": "更新",
@@ -179,22 +212,15 @@
"none": "所有 {typePlural} 都已具备许可证元数据",
"error": "刷新 {typePlural} 的许可证元数据失败:{message}"
},
"repairRecipes": {
"label": "修复配方数据",
"loading": "正在修复配方数据...",
"success": "成功修复了 {count} 个配方。",
"cancelled": "修复已取消。已修复 {count} 个配方。",
"error": "配方修复失败:{message}"
},
"rematchRecipes": {
"label": "将食谱重新匹配到本地模型",
"loading": "正在将食谱重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱{failures} 个失败",
"allFailed": "{failures}/{total} 个食谱重新匹配失败",
"noMatch": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个食谱已更新({entries} 个条目)。",
"error": "食谱重新匹配失败:{message}"
"label": "将配方重新匹配到本地模型",
"loading": "正在将配方重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方{failures} 个失败",
"allFailed": "{failures}/{total} 个配方重新匹配失败",
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
"error": "配方重新匹配失败:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
@@ -290,15 +316,15 @@
}
},
"settings": {
"civitaiApiKey": "Civitai API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 Civitai API 密钥",
"civitaiApiKeyHelp": "用于从 Civitai 下载模型时的身份验证",
"civitaiApiKey": "CivitAI API 密钥",
"civitaiApiKeyPlaceholder": "请输入你的 CivitAI API 密钥",
"civitaiApiKeyHelp": "用于从 CivitAI 下载模型时的身份验证",
"civitaiApiKeyConfigured": "已配置",
"civitaiApiKeyNotConfigured": "未配置",
"civitaiApiKeySet": "设置",
"civitaiHost": {
"label": "Civitai 站点",
"help": "选择使用“在 Civitai 中查看”时默认打开的 Civitai 站点。",
"label": "CivitAI 站点",
"help": "选择使用“在 CivitAI 中查看”时默认打开的 CivitAI 站点。",
"options": {
"com": "civitai.com(仅 SFW",
"red": "civitai.red(无限制)"
@@ -319,8 +345,8 @@
},
"aria2HelpLink": "了解如何配置 aria2 下载后端",
"civitaiHostBanner": {
"title": "已提供 Civitai 站点偏好设置",
"content": "Civitai 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
"title": "已提供 CivitAI 站点偏好设置",
"content": "CivitAI 现在使用 civitai.com 提供 SFW 内容,使用 civitai.red 提供无限制内容。你可以在设置中更改默认打开的站点。",
"openSettings": "打开设置"
},
"openSettingsFileLocation": {
@@ -428,7 +454,7 @@
},
"downloadSkipBaseModels": {
"label": "跳过这些基础模型的下载",
"help": "适用于所有下载流程。这里只能选择受支持的基础模型。",
"help": "启用后,使用所选基础模型的版本将被跳过。",
"searchPlaceholder": "筛选基础模型...",
"empty": "没有与当前搜索匹配的基础模型。",
"summary": {
@@ -450,7 +476,9 @@
},
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"groupByModelHelp": "开启后,每个 CivitAI 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
"stickyControls": "保持操作栏可见",
"stickyControlsHelp": "开启后,操作栏(刷新、下载等)会在滚动时与路径导航一起固定在页面顶部。",
"displayDensity": "显示密度",
"displayDensityOptions": {
"default": "默认",
@@ -555,7 +583,7 @@
},
"downloadPathTemplates": {
"title": "下载路径模板",
"help": "配置从 Civitai 下载不同模型类型的文件夹结构。",
"help": "配置从 CivitAI 下载不同模型类型的文件夹结构。",
"availablePlaceholders": "可用占位符:",
"templateOptions": {
"flatStructure": "扁平结构",
@@ -592,7 +620,7 @@
"exampleImages": {
"downloadLocation": "下载位置",
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
"downloadLocationHelp": "输入保存从 Civitai 下载的示例图片的文件夹路径",
"downloadLocationHelp": "输入保存从 CivitAI 下载的示例图片的文件夹路径",
"autoDownload": "自动下载示例图片",
"autoDownloadHelp": "自动为没有示例图片的模型下载示例图片(需设置下载位置)",
"openMode": "打开示例图片操作",
@@ -625,7 +653,7 @@
},
"hideEarlyAccessUpdates": {
"label": "隐藏抢先体验更新",
"help": "抢先体验更新"
"help": "启用后,仅有抢先体验更新的模型将不显示“可更新”徽章。"
},
"hidePaidUpdates": {
"label": "隐藏付费更新",
@@ -647,7 +675,7 @@
},
"metadataArchive": {
"enableArchiveDb": "启用元数据归档数据库",
"enableArchiveDbHelp": "使用本地数据库访问已从 Civitai 删除的模型元数据。",
"enableArchiveDbHelp": "使用本地数据库访问已从 CivitAI 删除的模型元数据。",
"status": "状态",
"statusAvailable": "可用",
"statusUnavailable": "不可用",
@@ -696,7 +724,7 @@
"aiProvider": {
"title": "AI 提供商",
"provider": "提供商",
"providerHelp": "选择的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许指定任何兼容 OpenAI 的端点。",
"providerHelp": "选择的 LLM 提供商。OpenAI 和 Ollama 使用预设的 API 端点。自定义允许指定任何兼容 OpenAI 的端点。",
"providerOptions": {
"openai": "OpenAI",
"ollama": "Ollama(本地)",
@@ -711,7 +739,7 @@
"apiBaseHelp": "LLM API 的基础地址。选择预设或输入自定义地址,下拉框显示所有支持的提供商预设。",
"apiBasePlaceholder": "https://api.openai.com/v1",
"apiKey": "API 密钥",
"apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除选择的 LLM 提供商外不会发送到任何服务器。",
"apiKeyHelp": "LLM 提供商的 API 密钥。本地存储,除选择的 LLM 提供商外不会发送到任何服务器。",
"apiKeyPlaceholder": "sk-...",
"apiKeyNotSet": "未设置",
"apiKeyConfigured": "已配置",
@@ -750,7 +778,7 @@
"fullTooltip": "从元数据文件重新加载所有模型信息;用于列表过时或手动编辑后。"
},
"fetch": {
"title": "从 Civitai 获取元数据",
"title": "从 CivitAI 获取元数据",
"action": "获取"
},
"download": {
@@ -786,7 +814,6 @@
"setContentRating": "为所选中设置内容评级",
"copyAll": "复制所选中语法",
"refreshAll": "刷新所选中元数据",
"repairMetadata": "修复所选中元数据",
"rematchMetadata": "将所选中重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"checkUpdates": "检查所选更新",
@@ -825,10 +852,10 @@
"enrichHfAgent": "AI HF 元数据增强"
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 数据",
"refreshMetadata": "刷新 CivitAI 数据",
"checkUpdates": "检查更新",
"linkModel": "链接模型",
"linkCivitai": "链接到 Civitai",
"linkCivitai": "链接到 CivitAI",
"linkHuggingFace": "链接到 HuggingFace",
"copySyntax": "复制 LoRA 语法",
"copyFilename": "复制模型文件名",
@@ -842,7 +869,6 @@
"replacePreview": "替换预览",
"setContentRating": "设置内容评级",
"moveToFolder": "移动到文件夹",
"repairMetadata": "修复元数据",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "从源重新导入",
"excludeModel": "排除模型",
@@ -860,6 +886,7 @@
"actions": {
"sendCheckpoint": "发送到 ComfyUI",
"sendRecipe": "发送到 ComfyUI",
"copyRecipeSyntax": "复制配方语法",
"deleteRecipeWithShortcut": "删除配方(Del"
},
"navigation": {
@@ -867,12 +894,110 @@
"previousWithShortcut": "上一个配方(←)",
"nextWithShortcut": "下一个配方(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "打开文件位置",
"copyId": "复制配方 ID"
},
"openFileLocation": {
"success": "文件位置已成功打开",
"failed": "打开文件位置失败",
"copied": "路径已复制到剪贴板:{{path}}",
"clipboardFallback": "路径:{{path}}"
}
},
"workflow": {
"sendWorkflow": "发送工作流到 ComfyUI",
"sent": "工作流已发送到 ComfyUI",
"sendFailed": "发送工作流到 ComfyUI 失败",
"noWorkflow": "此配方中未找到内嵌工作流"
},
"status": {
"ready": "可直接使用",
"missingCount": "缺失 {count} 个",
"deletedCount": "已删除 {count} 个",
"downloadMissing": "下载 {count} 个缺失的 LoRA",
"downloadMissingTooltip": "点击下载缺失的 LoRA"
},
"loraStatus": {
"none": "此配方不包含 LoRA",
"allAvailable": "所有 LoRA 均已就绪 - 可直接使用",
"missing": "{total} 个 LoRA 中缺失 {missing} 个",
"missingAndUnavailable": "{total} 个 LoRA 中缺失 {missing} 个,{unavailable} 个不可用(已从源站删除或哈希无法解析)",
"partial": "{total} 个 LoRA 中 {unavailable} 个不可用(已从源站删除或哈希无法解析)- 使用配方时将被跳过",
"noneUsable": "没有可用的 LoRA - {total} 个中 {unavailable} 个已从源站删除或哈希无法解析"
},
"resources": {
"inLibrary": "在库中",
"notInLibrary": "不在库中",
"deleted": "已删除",
"hashInvalid": "无法解析的哈希",
"inLibraryTooltip": "该模型已存在于本地库中",
"notInLibraryTooltip": "该模型不在你的本地库中",
"deletedTooltip": "该 LoRA 已从来源站删除,无法下载",
"hashInvalidTooltip": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新",
"noLorasAssociated": "此配方没有关联任何 LoRA",
"noLorasWhyToggle": "为什么没有 LoRA",
"noLorasImportMethod": "导入方式",
"noLorasInferredNote": "可能的原因(推断)——该配方是在记录导入诊断信息之前导入的。",
"noLorasChannels": {
"batch_import_url": "批量导入(图片 URL",
"batch_import_local": "批量导入(本地文件)",
"url": "图片 URL 导入",
"local": "本地文件导入",
"upload": "图片上传",
"widget": "从工作流保存",
"reimport_url": "重新导入(图片 URL",
"reimport_local": "重新导入(本地文件)"
},
"noLorasReasons": {
"no_loras_used": "生成元数据完整,且未引用任何 LoRA。",
"api_meta_no_lora_resources": "来源 API 未返回此图片的 LoRA 资源数据。CivitAI 页面上显示的 LoRA 可能来自公开 API 未开放的内部数据。",
"api_meta_missing": "来源 API 未返回此图片的生成元数据。",
"no_embedded_metadata": "图片没有内嵌生成元数据,因此无法恢复 LoRA 信息。",
"workflow_metadata_limited": "图片内嵌的元数据是 ComfyUI 工作流;从工作流中提取 LoRA 信息的能力有限。",
"video_no_metadata": "视频文件不携带内嵌生成元数据。",
"metadata_unsupported": "图片包含的元数据格式无法解析。",
"unknown": "无法从存储的配方数据中确定原因。"
},
"noLorasDetails": {
"apiMetaFields": "API 元数据字段",
"modelVersionIds": "报告的模型版本 ID 数",
"embeddedMetadata": "内嵌元数据",
"present": "已找到",
"absent": "无"
},
"download": "下载",
"downloadLoraTooltip": "下载此 LoRA",
"preparingDownload": "正在准备下载...",
"reconnect": "重新关联",
"reconnectTooltip": "与本地 LoRA 重新关联",
"reconnectInstructions": "输入 LoRA 语法或名称以重新关联:",
"reconnectExample": "示例:<lora:name:1> 或只填名称",
"reconnectPlaceholder": "输入 LoRA 名称或语法",
"reconnectSuggestionsLoading": "正在搜索本地库...",
"reconnectSuggestionsEmpty": "本地库中没有匹配的 LoRA",
"reconnectMatchSameHash": "相同哈希",
"reconnectMatchSameVersion": "相同模型版本",
"reconnectMatchSimilarFilename": "相似文件名",
"reconnectMatchSimilarName": "相似名称",
"undoReconnect": "撤销",
"undoReconnectTooltip": "恢复此条目在重新关联前的关联",
"undoReconnectTooltipNamed": "恢复为 {name}(重新关联前的关联)",
"viewOnCivitai": "在 CivitAI 上查看",
"openLoraDetails": "在 LoRA 库中查看 {name}",
"openCheckpointDetails": "在模型库中查看 {name}",
"checkpointDeletedTooltip": "此 Checkpoint 已从来源删除,无法再下载 - 请使用本地模型重新关联",
"checkpointHashInvalidTooltip": "此 Checkpoint 的哈希无法在 CivitAI 上解析 - 模型可能已更新",
"reconnectCheckpoint": "重新关联",
"reconnectCheckpointTooltip": "与本地 Checkpoint 重新关联",
"checkpointReconnectInstructions": "输入 Checkpoint 名称以重新关联:",
"checkpointReconnectPlaceholder": "输入 Checkpoint 名称",
"checkpointReconnectSuggestionsEmpty": "本地库中没有匹配的 Checkpoint"
},
"controls": {
"import": {
"action": "导入",
@@ -890,7 +1015,7 @@
"addTag": "添加",
"noTagsAdded": "未添加标签",
"lorasInRecipe": "此配方中的 LoRA",
"downloadLocationPreview": "下载位置预览:{path}",
"downloadLocationPreview": "下载位置预览:",
"useDefaultPath": "使用默认路径",
"useDefaultPathTooltip": "启用后,文件将自动使用配置的路径模板进行组织",
"selectLoraRoot": "选择 LoRA 根目录",
@@ -904,20 +1029,20 @@
"importAndDownload": "导入并下载",
"downloadMissingLoras": "下载缺失的 LoRA",
"saveRecipe": "保存配方",
"loraCountInfo": "({existing}/{total} in library)",
"loraCountInfo": "(库中 {existing}/{total}",
"processingInput": "处理输入...",
"analyzingMetadata": "分析图像元数据...",
"downloadingLoras": "下载 LoRA...",
"savingRecipe": "保存配方...",
"startingDownload": "开始下载 LoRA {current}/{total}",
"deletedFromCivitai": "从 Civitai 中删除",
"deletedFromCivitai": "从 CivitAI 中删除",
"inLibrary": "在库中",
"notInLibrary": "不在库中",
"earlyAccessRequired": "此 LoRA 需要提前访问权限才能下载。",
"earlyAccessEnds": "提前访问权限将于 {date} 结束。",
"earlyAccess": "提前访问",
"verifyEarlyAccess": "在下载之前,请验证您是否已购买提前访问权限。",
"duplicateRecipesFound": "在的库中找到 {count} 个相同的配方。",
"verifyEarlyAccess": "在下载之前,请确认你已购买提前访问权限。",
"duplicateRecipesFound": "在的库中找到 {count} 个相同的配方。",
"duplicateRecipesDescription": "这些配方包含相同的 LoRA,权重完全相同。",
"showDuplicates": "显示重复项",
"hideDuplicates": "隐藏重复项",
@@ -963,6 +1088,7 @@
}
},
"duplicates": {
"finding": "正在扫描重复配方...",
"found": "发现 {count} 个重复组",
"noGroups": "按当前判重依据未找到重复组",
"keepLatest": "保留最新版本",
@@ -995,13 +1121,6 @@
"getInfoFailed": "获取缺失 LoRA 信息失败",
"prepareError": "准备下载 LoRA 时出错:{message}"
},
"repair": {
"starting": "正在修复配方元数据...",
"success": "配方元数据修复成功",
"skipped": "配方已是最新版本,无需修复",
"failed": "修复配方失败:{message}",
"missingId": "无法修复配方:缺少配方 ID"
},
"reimport": {
"starting": "正在从源重新导入配方...",
"success": "配方已从源重新导入成功",
@@ -1032,6 +1151,8 @@
"start": "开始导入",
"startImport": "开始导入",
"importing": "正在导入配方...",
"rateLimitedSlowdown": "触发速率限制 — 正在减速...",
"rateLimitedHint": "部分条目因元数据提供方的速率限制而被跳过。稍后重新运行导入即可重试这些条目。",
"progress": "进度",
"total": "总计",
"success": "成功",
@@ -1073,7 +1194,7 @@
"title": "Checkpoint 模型",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
"diffusion_model": "扩散模型"
},
"contextMenu": {
"moveToOtherTypeFolder": "移动到 {otherType} 文件夹",
@@ -1097,7 +1218,7 @@
"collapseAllDisabled": "列表视图下不可用",
"dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item.",
"moveUnsupported": "此条目不支持移动。",
"createFolderHint": "释放以创建新文件夹",
"newFolderName": "新文件夹名称",
"folderNameHint": "按 Enter 确认,Escape 取消",
@@ -1235,7 +1356,7 @@
"download": {
"title": "从 URL 下载模型",
"titleWithType": "从 URL 下载 {type}",
"civitaiUrl": "Civitai URL:",
"civitaiUrl": "CivitAI URL:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行输入一个 CivitAI、CivArchive 或 Hugging Face URL。支持批量下载多个 URL。",
"selectHfFiles": "选择从此仓库下载的文件:",
@@ -1270,7 +1391,7 @@
"inLibrary": "已在库中"
},
"errors": {
"invalidUrl": "无效的 Civitai URL 格式",
"invalidUrl": "无效的 CivitAI URL 格式",
"noVersions": "此模型没有可用版本",
"mixedSources": "无法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"noModelFiles": "在此仓库中未找到模型文件。"
@@ -1349,8 +1470,8 @@
"action": "全部删除"
},
"checkUpdates": {
"title": "检查所有 {type} 的更新?",
"message": "这会库中的每个 {type} 检查更新,大型集合可能需要一些时间。",
"title": "检查所有 {typePlural} 的更新?",
"message": "这会检查库中的每个 {typePlural} 的更新,大型集合可能需要一些时间。",
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
"action": "检查全部"
},
@@ -1384,7 +1505,7 @@
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
"downloadOption": {
"title": "从 Civitai 下载",
"title": "从 CivitAI 下载",
"description": "将远程示例保存到本地,便于离线使用和更快加载"
},
"importOption": {
@@ -1411,7 +1532,7 @@
"confirmAction": "保存并链接"
},
"relinkCivitai": {
"title": "重新关联到 Civitai",
"title": "重新关联到 CivitAI",
"warning": "警告:",
"warningText": "这是一个有潜在风险的操作。重新关联将:",
"warningList": {
@@ -1420,14 +1541,15 @@
"unintendedConsequences": "可能有其他不可预期的后果"
},
"proceedText": "仅在你确定需要此操作时继续。",
"urlLabel": "Civitai 模型 URL",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676 或 https://civitai.red/models/649516/model-name?modelVersionId=726676",
"urlLabel": "CivitAI 模型 URL",
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/models/12345/model-name?modelVersionId=67890",
"helpText": {
"title": "粘贴任意来自 civitai.comcivitai.red 的 Civitai 模型 URL。支持格式:",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"note": "注意:如果未提供 modelVersionId,将使用最新版本。"
"title": "粘贴任意 CivitAICivitArchive 模型 URL。支持格式:",
"format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "注意:如果未提供 modelVersionId,将使用最新版本。",
"format4": "https://civarchive.com/models/12345 (CivArchive)"
},
"confirmAction": "确认重新关联"
},
@@ -1437,8 +1559,8 @@
"editFileName": "编辑文件名",
"editBaseModel": "编辑基础模型",
"editVersionName": "编辑版本名称",
"viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看",
"viewOnCivitai": "在 CivitAI 查看",
"viewOnCivitaiText": "在 CivitAI 查看",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看创作者主页",
@@ -1470,7 +1592,7 @@
"notesHint": "回车保存,Shift+回车换行",
"addNotesPlaceholder": "在此添加你的备注...",
"aboutThisVersion": "关于此版本",
"baseModelSearchPlaceholder": "搜索基础模型",
"baseModelSearchPlaceholder": "搜索基础模型...",
"baseModelSuggested": "推荐",
"baseModelNoMatch": "没有匹配的基础模型"
},
@@ -1490,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "数值",
"add": "添加",
"invalidRange": "无效的范围格式。请使用 x.x-y.y"
"invalidRange": "无效的范围格式。请使用 x.x-y.y",
"invalidValue": "请输入有效的数值",
"saveFailed": "保存预设参数失败",
"added": "已添加预设参数",
"updated": "已更新预设参数"
},
"triggerWords": {
"label": "触发词",
@@ -1501,7 +1627,7 @@
"addPlaceholder": "输入或点击下方建议添加",
"editWord": "编辑触发词",
"editPlaceholder": "编辑触发词",
"copyWord": "复制触发词",
"copyOrEditWord": "单击复制,双击编辑",
"deleteWord": "删除触发词",
"suggestions": {
"noSuggestions": "暂无建议",
@@ -1539,10 +1665,10 @@
"noNext": "没有下一个模型"
},
"license": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
"noRent": "No generation services",
"noSell": "No selling models",
"noImageSell": "禁止出售生成的图片",
"noRentCivit": "禁止在 CivitAI 上生成",
"noRent": "禁止生成服务",
"noSell": "禁止出售模型",
"creditRequired": "需要创作者署名",
"noDerivatives": "禁止分享合并作品",
"noReLicense": "需要相同权限",
@@ -1561,8 +1687,8 @@
"showCount": "显示示例({count}",
"hideExamples": "隐藏示例",
"addExamples": "添加示例",
"previousExample": "上一个示例",
"nextExample": "下一个示例",
"previousExample": "上一个示例[",
"nextExample": "下一个示例]",
"noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处",
@@ -1605,49 +1731,49 @@
"newer": "较新的版本",
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"earlyAccessTooltip": "此版本当前需要 CivitAI 抢先体验权限",
"paid": "付费",
"paidTooltip": "此版本需要付费后才能下载",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
"onSiteOnlyTooltip": "此版本仅在 Civitai 站内可用,无法下载"
"onSiteOnlyTooltip": "此版本仅在 CivitAI 站内可用,无法下载"
},
"actions": {
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadChooseFilesTooltip": "选择要下载的文件",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "从 Civitai 下载此付费版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"downloadEarlyAccessTooltip": "从 CivitAI 下载此抢先体验版本",
"downloadPaidTooltip": "从 CivitAI 下载此付费版本",
"downloadNotAllowedTooltip": "此版本仅在 CivitAI 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
"ignore": "忽略",
"unignore": "取消忽略",
"ignoreTooltip": "忽略此版本的更新通知",
"unignoreTooltip": "恢复此版本的更新通知",
"viewVersionOnCivitai": "在 Civitai 上查看版本",
"viewVersionOnCivitai": "在 CivitAI 上查看版本",
"earlyAccessTooltip": "需要购买抢先体验",
"resumeModelUpdates": "继续跟踪该模型的更新",
"ignoreModelUpdates": "忽略该模型的更新",
"viewLocalVersions": "查看所有本地版本",
"viewLocalTooltip": "敬请期待"
"viewLocalTooltip": "在主页面上显示该模型的所有本地版本"
},
"filters": {
"label": "基础筛选",
"state": {
"showAll": "全部版本",
"showSameBase": "相同基模型"
"showSameBase": "相同基模型"
},
"tooltip": {
"showAllVersions": "切换为显示所有版本",
"showSameBaseVersions": "仅显示与当前基模型匹配的版本"
"showSameBaseVersions": "仅显示与当前基模型匹配的版本"
},
"empty": "没有与当前基模型筛选匹配的版本。"
"empty": "没有与当前基模型筛选匹配的版本。"
},
"empty": "该模型还没有版本历史。",
"error": "加载版本失败。",
"missingModelId": "该模型缺少 Civitai 模型 ID。",
"missingModelId": "该模型缺少 CivitAI 模型 ID。",
"hfGroupInfo": "这是一个 HuggingFace 模型组。打开库页面即可在网格中查看所有版本。",
"confirm": {
"delete": "从库中删除此版本?"
@@ -1731,14 +1857,14 @@
"tips": {
"title": "技巧与提示",
"civitai": {
"title": "Civitai 集成",
"description": "连接你的 Civitai 账号:访问头像 → 设置 → API 密钥 → 添加密钥,然后粘贴到 LoRA 管理器设置中。",
"alt": "Civitai API 设置"
"title": "CivitAI 集成",
"description": "连接你的 CivitAI 账号:访问头像 → 设置 → API 密钥 → 添加密钥,然后粘贴到 LoRA 管理器设置中。",
"alt": "CivitAI API 设置"
},
"download": {
"title": "便捷下载",
"description": "使用 Civitai URL 快速下载和安装新模型。",
"alt": "Civitai 下载"
"description": "使用 CivitAI URL 快速下载和安装新模型。",
"alt": "CivitAI 下载"
},
"recipes": {
"title": "保存配方",
@@ -1816,7 +1942,7 @@
"copiedUri": "链接已复制到剪贴板:{{uri}}",
"uriClipboardFallback": "链接:{{uri}}",
"setupRequired": "示例图片存储",
"setupDescription": "要添加自定义示例图片,需要先设置下载位置。",
"setupDescription": "要添加自定义示例图片,需要先设置下载位置。",
"setupUsage": "此路径用于存储下载的示例图片和自定义图片。",
"openSettings": "打开设置"
}
@@ -1826,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": "最新更新",
@@ -1846,7 +2014,8 @@
"settings": "设置与配置",
"extensions": "扩展",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "检查更新",
@@ -1922,7 +2091,7 @@
"submitGithubIssue": "提交 GitHub 问题",
"joinDiscord": "加入 Discord",
"youtubeChannel": "YouTube 频道",
"civitaiProfile": "Civitai 个人资料",
"civitaiProfile": "CivitAI 个人资料",
"supportKofi": "支持 Ko-fi",
"supportPatreon": "支持 Patreon"
},
@@ -2005,14 +2174,27 @@
"preparingForDownloadFailed": "准备下载 LoRA 时出错",
"enterLoraName": "请输入 LoRA 名称或语法",
"reconnectedSuccessfully": "LoRA 重新连接成功",
"reconnectBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe},LoRA{lora})——两者架构兼容",
"reconnectFailed": "LoRA 重新连接出错:{message}",
"loraRestored": "LoRA 已恢复为重新关联前的关联",
"loraRestoreFailed": "LoRA 恢复出错:{message}",
"noPromptToSend": "没有可发送的提示词",
"cannotSend": "无法发送配方:缺少配方 ID",
"sendFailed": "发送配方到工作流失败",
"sendError": "发送配方到工作流出错",
"missingCheckpointPath": "缺少检查点路径",
"missingCheckpointInfo": "缺少检查点信息",
"downloadCheckpointFailed": "下载检查点失败:{message}",
"missingCheckpointPath": "缺少Checkpoint路径",
"missingCheckpointInfo": "缺少Checkpoint信息",
"downloadCheckpointFailed": "下载Checkpoint失败:{message}",
"enterCheckpointName": "请输入 Checkpoint 名称",
"checkpointReconnectedSuccessfully": "Checkpoint 重新连接成功",
"reconnectCheckpointBaseModelMismatch": "已重新关联,但基础模型不同(配方:{recipe}Checkpoint{checkpoint})——两者架构兼容",
"checkpointReconnectFailed": "Checkpoint 重新连接出错:{message}",
"checkpointRestored": "Checkpoint 已恢复为重新关联前的关联",
"checkpointRestoreFailed": "Checkpoint 恢复出错:{message}",
"checkpointDownloadUnavailable": "缺少 CivitAI 标识,无法下载此 Checkpoint - 请尝试使用本地 Checkpoint 重新关联",
"missingLoraDownloadInfo": "缺少此 LoRA 的下载信息",
"hashNotFoundOnCivitai": "此 LoRA 哈希无法在 CivitAI 上解析——模型可能已更新或哈希无效",
"downloadLoraFailed": "下载 LoRA 失败:{message}",
"cannotDelete": "无法删除配方:缺少配方 ID",
"deleteConfirmationError": "显示删除确认出错",
"deletedSuccessfully": "配方删除成功",
@@ -2036,19 +2218,18 @@
"batchImportCancelFailed": "取消批量导入失败:{message}",
"batchImportNoUrls": "请输入至少一个 URL 或文件路径",
"batchImportNoDirectory": "请输入目录路径",
"batchImportRateLimited": "已达到元数据提供方的速率限制 — 请求正在放缓,部分条目可能被跳过。你可以稍后重新运行导入。",
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"repairBulkComplete": "修复完成:{repaired} 个已修复,{skipped} 个已跳过(共 {total} 个)",
"repairBulkSkipped": "所选 {total} 个配方无需修复",
"repairBulkFailed": "修复所选配方失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个食谱,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选食谱重新匹配失败",
"rematchUnmatched": "在 {recipes} 个食谱中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选食谱均无需重新匹配",
"rematchFailed": "重新匹配所选食谱失败:{message}",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
"reimportingViaExtension": "正在通过浏览器扩展重新导入配方 {current}/{total}...",
"reimportSuccess": "配方已从源重新导入成功",
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
@@ -2093,8 +2274,8 @@
"bulkUpdatesChecking": "正在检查所选 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 个所选 {type} 有可用更新",
"bulkUpdatesNone": "所选 {type} 未发现更新",
"bulkUpdatesMissing": "所选 {type} 未关联 Civitai 更新",
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 Civitai 的所选 {type}",
"bulkUpdatesMissing": "所选 {type} 未关联 CivitAI 更新",
"bulkUpdatesPartialMissing": "已跳过 {missing} 个未关联 CivitAI 的所选 {type}",
"bulkUpdatesFailed": "检查所选 {type} 的更新失败:{message}",
"invalidCharactersRemoved": "文件名中的无效字符已移除",
"filenameCannotBeEmpty": "文件名不能为空",
@@ -2123,7 +2304,7 @@
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射{plural}",
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
"mappingsCleared": "基础模型路径映射已清除",
"mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新",
@@ -2134,8 +2315,8 @@
"compactModeToggled": "紧凑模式 {state}",
"settingSaveFailed": "保存设置失败:{message}",
"displayDensitySet": "显示密度已设置为 {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"libraryLoadFailed": "加载模型库失败:{message}",
"libraryActivateFailed": "激活模型库失败:{message}",
"languageChangeFailed": "切换语言失败:{message}",
"cacheCleared": "缓存文件已成功清除。下次操作将重建缓存。",
"cacheClearFailed": "清除缓存失败:{error}",
@@ -2220,10 +2401,11 @@
"contextMenu": {
"contentRatingSet": "内容评级已设置为 {level}",
"contentRatingFailed": "设置内容评级失败:{message}",
"relinkSuccess": "模型已成功重新关联到 Civitai",
"relinkSuccess": "模型已成功重新关联到 CivitAI",
"relinkFailed": "错误:{message}",
"linkHfSuccess": "模型已成功链接到 HuggingFace",
"linkHfFailed": "错误:{message}",
"linkCivArchSuccess": "模型已成功通过 CivitArchive 重新关联",
"fetchMetadataFirst": "请先从 CivitAI 获取元数据",
"noCivitaiInfo": "无 CivitAI 信息",
"missingHash": "模型哈希不可用"
@@ -2283,7 +2465,7 @@
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}",
"moveFailed": "移动条目失败:{message}",
"copiedToClipboard": "已复制到剪贴板",
"downloadStarted": "下载已开始"
},
@@ -2313,7 +2495,7 @@
},
"issues": {
"civitai_api_key": {
"title": "Civitai API 密钥"
"title": "CivitAI API 密钥"
},
"cache_health": {
"title": "模型缓存健康状态"
+319 -137
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": {
@@ -67,15 +88,15 @@
"steps": {
"fetch": {
"title": "取得模型 metadata",
"content": "點擊 <strong>取得</strong> 按鈕,從 Civitai 下載模型 metadata 與預覽圖片。"
"content": "點擊 <strong>取得</strong> 按鈕,從 CivitAI 下載模型 metadata 與預覽圖片。"
},
"download": {
"title": "下載新模型",
"content": "使用 <strong>下載</strong> 按鈕,直接從 Civitai 網址下載模型。"
"content": "使用 <strong>下載</strong> 按鈕,直接從 CivitAI 網址下載模型。"
},
"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>可開啟全域操作選單,例如檢查更新與管理已排除的模型。"
}
}
},
@@ -103,8 +136,8 @@
"actions": {
"addToFavorites": "加入收藏",
"removeFromFavorites": "移除收藏",
"viewOnCivitai": "在 Civitai 查看",
"notAvailableFromCivitai": "Civitai 不提供",
"viewOnCivitai": "在 CivitAI 查看",
"notAvailableFromCivitai": "CivitAI 不提供",
"viewOnHuggingFace": "在 Hugging Face 查看",
"sendToWorkflow": "傳送到 ComfyUI(點擊:附加,Shift+點擊:取代)",
"copyLoRASyntax": "複製 LoRA 語法",
@@ -113,7 +146,7 @@
"show": "顯示",
"openExampleImages": "開啟範例圖片資料夾",
"replacePreview": "更換預覽圖",
"copyCheckpointName": "複製檢查點名稱",
"copyCheckpointName": "複製 Checkpoint 名稱",
"copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
@@ -137,7 +170,7 @@
"exampleImages": {
"checkError": "檢查範例圖片時發生錯誤",
"missingHash": "缺少模型雜湊資訊。",
"noRemoteImagesAvailable": "此模型在 Civitai 上無遠端範例圖片"
"noRemoteImagesAvailable": "此模型在 CivitAI 上無遠端範例圖片"
},
"badges": {
"update": "更新",
@@ -179,22 +212,15 @@
"none": "所有 {typePlural} 已具備授權中繼資料",
"error": "重新整理 {typePlural} 授權中繼資料失敗:{message}"
},
"repairRecipes": {
"label": "修復配方資料",
"loading": "正在修復配方資料...",
"success": "成功修復 {count} 個配方。",
"cancelled": "修復已取消。已修復 {count} 個配方。",
"error": "配方修復失敗:{message}"
},
"rematchRecipes": {
"label": "將食譜重新匹配到本地模型",
"loading": "正在將食譜重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜{failures} 個失敗",
"allFailed": "{failures}/{total} 個食譜重新匹配失敗",
"noMatch": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個食譜已更新({entries} 個條目)。",
"error": "食譜重新匹配失敗:{message}"
"label": "將配方重新匹配到本地模型",
"loading": "正在將配方重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方{failures} 個失敗",
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
"error": "配方重新匹配失敗:{message}"
},
"manageExcludedModels": {
"label": "管理已排除的模型"
@@ -259,7 +285,7 @@
"clearAll": "清除所有篩選",
"any": "任一",
"all": "全部",
"tagLogicAny": "符合任一籤 (或)",
"tagLogicAny": "符合任一籤 (或)",
"tagLogicAll": "符合所有標籤 (與)",
"loraAvailability": "LoRA 可用性",
"availabilityReady": "可直接使用",
@@ -290,15 +316,15 @@
}
},
"settings": {
"civitaiApiKey": "Civitai API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 Civitai API 金鑰",
"civitaiApiKeyHelp": "用於從 Civitai 下載模型時的身份驗證",
"civitaiApiKey": "CivitAI API 金鑰",
"civitaiApiKeyPlaceholder": "請輸入您的 CivitAI API 金鑰",
"civitaiApiKeyHelp": "用於從 CivitAI 下載模型時的身份驗證",
"civitaiApiKeyConfigured": "已設定",
"civitaiApiKeyNotConfigured": "未設定",
"civitaiApiKeySet": "設定",
"civitaiHost": {
"label": "Civitai 站點",
"help": "選擇使用「在 Civitai 中查看」時預設開啟的 Civitai 站點。",
"label": "CivitAI 站點",
"help": "選擇使用「在 CivitAI 中查看」時預設開啟的 CivitAI 站點。",
"options": {
"com": "civitai.com(僅 SFW",
"red": "civitai.red(無限制)"
@@ -319,8 +345,8 @@
},
"aria2HelpLink": "了解如何設定 aria2 下載後端",
"civitaiHostBanner": {
"title": "已提供 Civitai 站點偏好設定",
"content": "Civitai 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。可以在設定中變更預設開啟的站點。",
"title": "已提供 CivitAI 站點偏好設定",
"content": "CivitAI 現在使用 civitai.com 提供 SFW 內容,使用 civitai.red 提供無限制內容。可以在設定中變更預設開啟的站點。",
"openSettings": "開啟設定"
},
"openSettingsFileLocation": {
@@ -407,7 +433,7 @@
"retentionHelp": "在刪除舊快照之前,要保留多少自動快照。",
"management": "備份管理",
"managementHelp": "匯出目前的使用者狀態,或從備份封存中還原。",
"scopeHelp": "備份的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
"scopeHelp": "備份的設定、下載歷史與模型更新狀態。不包含模型檔案或可重建的快取。",
"locationSummary": "目前備份位置",
"openFolderButton": "開啟備份資料夾",
"openFolderSuccess": "已開啟備份資料夾",
@@ -428,7 +454,7 @@
},
"downloadSkipBaseModels": {
"label": "跳過這些基礎模型的下載",
"help": "適用於所有下載流程。這裡只能選擇受支援的基礎模型。",
"help": "啟用後,使用所選基礎模型的版本將被略過。",
"searchPlaceholder": "篩選基礎模型...",
"empty": "沒有符合目前搜尋條件的基礎模型。",
"summary": {
@@ -450,7 +476,9 @@
},
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"groupByModelHelp": "啟用後,每個 CivitAI 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
"stickyControls": "保持操作列可見",
"stickyControlsHelp": "啟用後,操作列(重新整理、下載等)會在捲動時與麵包屑導覽一起固定在頁面頂端。",
"displayDensity": "顯示密度",
"displayDensityOptions": {
"default": "預設",
@@ -517,7 +545,7 @@
"extraFolderPaths": {
"title": "額外資料夾路徑",
"description": "LoRA Manager 專屬的額外模型根目錄。從 ComfyUI 標準資料夾之外的位置載入模型,特別適合管理大型模型庫,避免影響 ComfyUI 效能。",
"restartRequired": "Requires restart to take effect",
"restartRequired": "需要重新啟動才能生效",
"modelTypes": {
"lora": "LoRA 路徑",
"checkpoint": "Checkpoint 路徑",
@@ -555,7 +583,7 @@
},
"downloadPathTemplates": {
"title": "下載路徑範本",
"help": "設定從 Civitai 下載時不同模型類型的資料夾結構。",
"help": "設定從 CivitAI 下載時不同模型類型的資料夾結構。",
"availablePlaceholders": "可用佔位符:",
"templateOptions": {
"flatStructure": "扁平結構",
@@ -592,7 +620,7 @@
"exampleImages": {
"downloadLocation": "下載位置",
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
"downloadLocationHelp": "輸入從 Civitai 下載範例圖片要儲存的資料夾路徑",
"downloadLocationHelp": "輸入從 CivitAI 下載範例圖片要儲存的資料夾路徑",
"autoDownload": "自動下載範例圖片",
"autoDownloadHelp": "自動為沒有範例圖片的模型下載範例圖片(需設定下載位置)",
"openMode": "開啟範例圖片動作",
@@ -625,7 +653,7 @@
},
"hideEarlyAccessUpdates": {
"label": "隱藏搶先體驗更新",
"help": "搶先體驗更新"
"help": "啟用後,只有搶先體驗更新的模型將不顯示「可更新」徽章。"
},
"hidePaidUpdates": {
"label": "隱藏付費更新",
@@ -647,7 +675,7 @@
},
"metadataArchive": {
"enableArchiveDb": "啟用中繼資料封存資料庫",
"enableArchiveDbHelp": "使用本機資料庫以存取已從 Civitai 刪除模型的中繼資料。",
"enableArchiveDbHelp": "使用本機資料庫以存取已從 CivitAI 刪除模型的中繼資料。",
"status": "狀態",
"statusAvailable": "可用",
"statusUnavailable": "不可用",
@@ -750,7 +778,7 @@
"fullTooltip": "從中繼資料檔重新載入所有模型資訊;適用於清單過時或手動編輯後。"
},
"fetch": {
"title": "從 Civitai 取得 metadata",
"title": "從 CivitAI 取得 metadata",
"action": "取得"
},
"download": {
@@ -786,7 +814,6 @@
"setContentRating": "為全部設定內容分級",
"copyAll": "複製全部語法",
"refreshAll": "刷新全部 metadata",
"repairMetadata": "修復所選中元數據",
"rematchMetadata": "將所選中重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"checkUpdates": "檢查所選更新",
@@ -825,10 +852,10 @@
"enrichHfAgent": "AI HF 中繼資料增強"
},
"contextMenu": {
"refreshMetadata": "刷新 Civitai 資料",
"refreshMetadata": "刷新 CivitAI 資料",
"checkUpdates": "檢查更新",
"linkModel": "連結模型",
"linkCivitai": "連結到 Civitai",
"linkCivitai": "連結到 CivitAI",
"linkHuggingFace": "連結到 HuggingFace",
"copySyntax": "複製 LoRA 語法",
"copyFilename": "複製模型檔名",
@@ -842,7 +869,6 @@
"replacePreview": "更換預覽圖",
"setContentRating": "設定內容分級",
"moveToFolder": "移動到資料夾",
"repairMetadata": "修復元數據",
"rematchMetadata": "重新匹配到本地模型",
"reimportMetadata": "從來源重新匯入",
"excludeModel": "排除模型",
@@ -860,6 +886,7 @@
"actions": {
"sendCheckpoint": "傳送到 ComfyUI",
"sendRecipe": "傳送到 ComfyUI",
"copyRecipeSyntax": "複製配方語法",
"deleteRecipeWithShortcut": "刪除配方(Del"
},
"navigation": {
@@ -867,12 +894,110 @@
"previousWithShortcut": "上一個配方(←)",
"nextWithShortcut": "下一個配方(→)"
},
"modal": {
"metadata": {
"id": "ID"
},
"actions": {
"openFileLocation": "開啟檔案位置",
"copyId": "複製配方 ID"
},
"openFileLocation": {
"success": "檔案位置已成功開啟",
"failed": "開啟檔案位置失敗",
"copied": "路徑已複製到剪貼簿:{{path}}",
"clipboardFallback": "路徑:{{path}}"
}
},
"workflow": {
"sendWorkflow": "傳送工作流到 ComfyUI",
"sent": "工作流已傳送到 ComfyUI",
"sendFailed": "傳送工作流到 ComfyUI 失敗",
"noWorkflow": "此配方中未找到內嵌工作流"
},
"status": {
"ready": "可直接使用",
"missingCount": "缺少 {count} 個",
"deletedCount": "已刪除 {count} 個",
"downloadMissing": "下載 {count} 個缺少的 LoRA",
"downloadMissingTooltip": "點擊下載缺少的 LoRA"
},
"loraStatus": {
"none": "此配方不含 LoRA",
"allAvailable": "所有 LoRA 皆已就緒 - 可直接使用",
"missing": "{total} 個 LoRA 中缺少 {missing} 個",
"missingAndUnavailable": "{total} 個 LoRA 中缺少 {missing} 個,{unavailable} 個不可用(已從來源刪除或雜湊無法解析)",
"partial": "{total} 個 LoRA 中 {unavailable} 個不可用(已從來源刪除或雜湊無法解析)- 使用配方時將被略過",
"noneUsable": "沒有可用的 LoRA - {total} 個中 {unavailable} 個已從來源刪除或雜湊無法解析"
},
"resources": {
"inLibrary": "已在庫存",
"notInLibrary": "不在庫存",
"deleted": "已刪除",
"hashInvalid": "無法解析的雜湊",
"inLibraryTooltip": "此模型已存在於本地庫",
"notInLibraryTooltip": "此模型不在您的本地庫中",
"deletedTooltip": "此 LoRA 已從來源站刪除,無法下載",
"hashInvalidTooltip": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新",
"noLorasAssociated": "此配方未關聯任何 LoRA",
"noLorasWhyToggle": "為什麼沒有 LoRA",
"noLorasImportMethod": "匯入方式",
"noLorasInferredNote": "可能的原因(推斷)——此配方是在記錄匯入診斷資訊之前匯入的。",
"noLorasChannels": {
"batch_import_url": "批量匯入(圖片 URL",
"batch_import_local": "批量匯入(本機檔案)",
"url": "圖片 URL 匯入",
"local": "本機檔案匯入",
"upload": "圖片上傳",
"widget": "從工作流儲存",
"reimport_url": "重新匯入(圖片 URL",
"reimport_local": "重新匯入(本機檔案)"
},
"noLorasReasons": {
"no_loras_used": "生成中繼資料完整,且未引用任何 LoRA。",
"api_meta_no_lora_resources": "來源 API 未回傳此圖片的 LoRA 資源資料。CivitAI 頁面上顯示的 LoRA 可能來自公開 API 未開放的內部資料。",
"api_meta_missing": "來源 API 未回傳此圖片的生成中繼資料。",
"no_embedded_metadata": "圖片沒有內嵌生成中繼資料,因此無法復原 LoRA 資訊。",
"workflow_metadata_limited": "圖片內嵌的中繼資料是 ComfyUI 工作流;從工作流中提取 LoRA 資訊的能力有限。",
"video_no_metadata": "影片檔案不攜帶內嵌生成中繼資料。",
"metadata_unsupported": "圖片包含的中繼資料格式無法解析。",
"unknown": "無法從儲存的配方資料中確定原因。"
},
"noLorasDetails": {
"apiMetaFields": "API 中繼資料欄位",
"modelVersionIds": "回報的模型版本 ID 數",
"embeddedMetadata": "內嵌中繼資料",
"present": "已找到",
"absent": "無"
},
"download": "下載",
"downloadLoraTooltip": "下載此 LoRA",
"preparingDownload": "正在準備下載...",
"reconnect": "重新關聯",
"reconnectTooltip": "與本地 LoRA 重新關聯",
"reconnectInstructions": "輸入 LoRA 語法或名稱以重新關聯:",
"reconnectExample": "範例:<lora:name:1> 或只填名稱",
"reconnectPlaceholder": "輸入 LoRA 名稱或語法",
"reconnectSuggestionsLoading": "正在搜尋本地庫...",
"reconnectSuggestionsEmpty": "本地庫中沒有符合的 LoRA",
"reconnectMatchSameHash": "相同雜湊",
"reconnectMatchSameVersion": "相同模型版本",
"reconnectMatchSimilarFilename": "相似檔案名稱",
"reconnectMatchSimilarName": "相似名稱",
"undoReconnect": "撤銷",
"undoReconnectTooltip": "恢復此條目在重新關聯前的關聯",
"undoReconnectTooltipNamed": "恢復為 {name}(重新關聯前的關聯)",
"viewOnCivitai": "在 CivitAI 上檢視",
"openLoraDetails": "在 LoRA 庫中檢視 {name}",
"openCheckpointDetails": "在模型庫中檢視 {name}",
"checkpointDeletedTooltip": "此 Checkpoint 已從來源刪除,無法再下載 - 請使用本地模型重新關聯",
"checkpointHashInvalidTooltip": "此 Checkpoint 的雜湊無法在 CivitAI 上解析 - 模型可能已更新",
"reconnectCheckpoint": "重新關聯",
"reconnectCheckpointTooltip": "與本地 Checkpoint 重新關聯",
"checkpointReconnectInstructions": "輸入 Checkpoint 名稱以重新關聯:",
"checkpointReconnectPlaceholder": "輸入 Checkpoint 名稱",
"checkpointReconnectSuggestionsEmpty": "本地庫中沒有符合的 Checkpoint"
},
"controls": {
"import": {
"action": "匯入",
@@ -881,7 +1006,7 @@
"dropZoneHint": "將圖片拖曳至此處、從剪貼簿貼上,或點擊瀏覽",
"orDivider": "或拖曳 / 貼上圖片",
"imageUrlOrPath": "圖片網址或檔案路徑:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "取得圖片",
"recipeName": "配方名稱",
"recipeNamePlaceholder": "輸入配方名稱",
@@ -910,7 +1035,7 @@
"downloadingLoras": "下載 LoRA 中...",
"savingRecipe": "儲存配方中...",
"startingDownload": "開始下載 LoRA {current}/{total}",
"deletedFromCivitai": "已從 Civitai 刪除",
"deletedFromCivitai": "已從 CivitAI 刪除",
"inLibrary": "已在庫存",
"notInLibrary": "不在庫存",
"earlyAccessRequired": "此 LoRA 需購買早期存取才能下載。",
@@ -963,6 +1088,7 @@
}
},
"duplicates": {
"finding": "正在掃描重複配方...",
"found": "發現 {count} 組重複項",
"noGroups": "按目前判重依據未找到重複組",
"keepLatest": "保留最新版本",
@@ -995,13 +1121,6 @@
"getInfoFailed": "取得缺少 LoRA 資訊失敗",
"prepareError": "準備下載 LoRA 時發生錯誤:{message}"
},
"repair": {
"starting": "正在修復配方元數據...",
"success": "配方元數據修復成功",
"skipped": "配方已是最新版本,無需修復",
"failed": "修復配方失敗:{message}",
"missingId": "無法修復配方:缺少配方 ID"
},
"reimport": {
"starting": "正在從來源重新匯入配方...",
"success": "配方已從來源重新匯入成功",
@@ -1032,6 +1151,8 @@
"start": "開始匯入",
"startImport": "開始匯入",
"importing": "匯入中...",
"rateLimitedSlowdown": "觸發速率限制 — 正在減速...",
"rateLimitedHint": "部分項目因元數據提供方的速率限制而被略過。稍後重新執行匯入即可重試這些項目。",
"progress": "進度",
"total": "總計",
"success": "成功",
@@ -1073,7 +1194,7 @@
"title": "Checkpoint 模型",
"modelTypes": {
"checkpoint": "Checkpoint",
"diffusion_model": "Diffusion Model"
"diffusion_model": "擴散模型"
},
"contextMenu": {
"moveToOtherTypeFolder": "移動到 {otherType} 資料夾",
@@ -1097,7 +1218,7 @@
"collapseAllDisabled": "列表檢視下不可用",
"dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item.",
"moveUnsupported": "此項目不支援移動。",
"createFolderHint": "放開以建立新資料夾",
"newFolderName": "新資料夾名稱",
"folderNameHint": "按 Enter 確認,Escape 取消",
@@ -1160,36 +1281,36 @@
"unusedLoras": {
"high": {
"title": "大量未使用的 LoRA",
"description": "的 LoRA 中有 {percent}%{count}/{total})從未被使用過。",
"description": "的 LoRA 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的模型以釋放儲存空間。"
}
},
"unusedCheckpoints": {
"detected": {
"title": "檢測到未使用的 Checkpoint",
"description": "的 Checkpoint 中有 {percent}%{count}/{total})從未被使用過。",
"description": "的 Checkpoint 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "審查並考慮刪除不再需要的 Checkpoint。"
}
},
"unusedEmbeddings": {
"high": {
"title": "大量未使用的 Embedding",
"description": "的 Embedding 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的 Embedding 以優化的收藏。"
"description": "的 Embedding 中有 {percent}%{count}/{total})從未被使用過。",
"suggestion": "考慮整理或封存未使用的 Embedding 以優化的收藏。"
}
},
"collection": {
"large": {
"title": "檢測到大型收藏",
"description": "的模型收藏正在使用 {size} 的儲存空間。",
"description": "的模型收藏正在使用 {size} 的儲存空間。",
"suggestion": "考慮使用外部儲存或雲端解決方案以獲得更好的組織。"
}
},
"activity": {
"active": {
"title": "活躍用戶",
"description": "已經完成了 {count} 次生成!",
"suggestion": "繼續探索並用的模型創作精彩內容。"
"description": "已經完成了 {count} 次生成!",
"suggestion": "繼續探索並用的模型創作精彩內容。"
}
}
},
@@ -1235,7 +1356,7 @@
"download": {
"title": "從網址下載模型",
"titleWithType": "從網址下載 {type}",
"civitaiUrl": "Civitai 網址:",
"civitaiUrl": "CivitAI 網址:",
"placeholder": "https://civitai.com/models/...",
"urlHint": "每行輸入一個 CivitAI、CivArchive 或 Hugging Face URL。支援批量下載多個 URL。",
"selectHfFiles": "選擇從此倉庫下載的檔案:",
@@ -1259,7 +1380,7 @@
"earlyAccessTooltip": "需要早期存取",
"inLibrary": "已在庫存",
"downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在的庫中。",
"downloadedTooltip": "先前已下載,但目前不在的庫中。",
"alreadyInLibrary": "已在庫存",
"partiallyDownloaded": "部分已下載",
"autoOrganizedPath": "[依路徑範本自動整理]",
@@ -1270,7 +1391,7 @@
"inLibrary": "已在庫中"
},
"errors": {
"invalidUrl": "Civitai 網址格式無效",
"invalidUrl": "CivitAI 網址格式無效",
"noVersions": "此模型無可用版本",
"mixedSources": "無法在同一批次中混合使用 CivitAI 和 Hugging Face URL。",
"noModelFiles": "在此倉庫中未找到模型檔案。"
@@ -1349,8 +1470,8 @@
"action": "全部刪除"
},
"checkUpdates": {
"title": "要檢查所有 {type} 的更新嗎?",
"message": "這會資料庫中的每個 {type} 檢查更新,大型收藏可能會花上一些時間。",
"title": "要檢查所有 {typePlural} 的更新嗎?",
"message": "這會檢查資料庫中的每個 {typePlural} 的更新,大型收藏可能會花上一些時間。",
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
"action": "全部檢查"
},
@@ -1374,7 +1495,7 @@
},
"bulkDownloadMissingLoras": {
"title": "下載缺失的 LoRAs",
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取食譜中的 {totalCount} 個總數)。",
"message": "發現 {uniqueCount} 個獨特的缺失 LoRAs(從選取配方中的 {totalCount} 個總數)。",
"previewTitle": "要下載的 LoRAs",
"moreItems": "...還有 {count} 個",
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
@@ -1384,7 +1505,7 @@
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
"downloadOption": {
"title": "從 Civitai 下載",
"title": "從 CivitAI 下載",
"description": "將遠端範例儲存到本機以便離線使用及加快載入"
},
"importOption": {
@@ -1411,7 +1532,7 @@
"confirmAction": "儲存並連結"
},
"relinkCivitai": {
"title": "重新連結至 Civitai",
"title": "重新連結至 CivitAI",
"warning": "警告:",
"warningText": "這是可能造成破壞性的操作。重新連結將會:",
"warningList": {
@@ -1420,14 +1541,15 @@
"unintendedConsequences": "可能產生其他非預期後果"
},
"proceedText": "僅在確定需要執行時才繼續。",
"urlLabel": "Civitai 模型網址:",
"urlPlaceholder": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"urlLabel": "CivitAI 模型網址:",
"urlPlaceholder": "https://civitai.com/models/12345/model-name?modelVersionId=67890 或 https://civitai.red/models/12345/model-name?modelVersionId=67890",
"helpText": {
"title": "貼上任意 Civitai 模型網址。支援格式:",
"format1": "https://civitai.com/models/649516",
"format2": "https://civitai.com/models/649516?modelVersionId=726676",
"format3": "https://civitai.com/models/649516/model-name?modelVersionId=726676",
"note": "注意:若未提供 modelVersionId,將使用最新版本。"
"title": "貼上任意 CivitAI 或 CivitArchive 模型網址。支援格式:",
"format1": "https://civitai.com/models/12345",
"format2": "https://civitai.com/models/12345?modelVersionId=67890",
"format3": "https://civitai.com/models/12345/model-name?modelVersionId=67890",
"note": "注意:若未提供 modelVersionId,將使用最新版本。",
"format4": "https://civarchive.com/models/12345 (CivArchive)"
},
"confirmAction": "確認重新連結"
},
@@ -1437,8 +1559,8 @@
"editFileName": "編輯檔案名稱",
"editBaseModel": "編輯基礎模型",
"editVersionName": "編輯版本名稱",
"viewOnCivitai": "在 Civitai 查看",
"viewOnCivitaiText": "在 Civitai 查看",
"viewOnCivitai": "在 CivitAI 查看",
"viewOnCivitaiText": "在 CivitAI 查看",
"viewOnHuggingFace": "在 Hugging Face 查看",
"viewOnHuggingFaceText": "在 Hugging Face 查看",
"viewCreatorProfile": "查看創作者個人檔案",
@@ -1470,7 +1592,7 @@
"notesHint": "按 Enter 儲存,Shift+Enter 換行",
"addNotesPlaceholder": "在此新增備註...",
"aboutThisVersion": "關於此版本",
"baseModelSearchPlaceholder": "搜尋基礎模型",
"baseModelSearchPlaceholder": "搜尋基礎模型...",
"baseModelSuggested": "推薦",
"baseModelNoMatch": "沒有符合的基礎模型"
},
@@ -1490,7 +1612,11 @@
"clipSkip": "Clip Skip",
"valuePlaceholder": "數值",
"add": "新增",
"invalidRange": "無效的範圍格式。請使用 x.x-y.y"
"invalidRange": "無效的範圍格式。請使用 x.x-y.y",
"invalidValue": "請輸入有效的數值",
"saveFailed": "儲存預設參數失敗",
"added": "已新增預設參數",
"updated": "已更新預設參數"
},
"triggerWords": {
"label": "觸發詞",
@@ -1501,7 +1627,7 @@
"addPlaceholder": "輸入或點擊下方建議",
"editWord": "編輯觸發詞",
"editPlaceholder": "編輯觸發詞",
"copyWord": "複製觸發詞",
"copyOrEditWord": "點擊複製,雙擊編輯",
"deleteWord": "刪除觸發詞",
"suggestions": {
"noSuggestions": "無可用建議",
@@ -1539,10 +1665,10 @@
"noNext": "沒有下一個模型"
},
"license": {
"noImageSell": "No selling generated content",
"noRentCivit": "No Civitai generation",
"noRent": "No generation services",
"noSell": "No selling models",
"noImageSell": "禁止出售生成的圖片",
"noRentCivit": "禁止在 CivitAI 上生成",
"noRent": "禁止生成服務",
"noSell": "禁止出售模型",
"creditRequired": "需要創作者標示",
"noDerivatives": "禁止分享合併作品",
"noReLicense": "需要相同授權",
@@ -1561,8 +1687,8 @@
"showCount": "顯示範例({count}",
"hideExamples": "隱藏範例",
"addExamples": "新增範例",
"previousExample": "上一個範例",
"nextExample": "下一個範例",
"previousExample": "上一個範例[",
"nextExample": "下一個範例]",
"noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處",
@@ -1572,8 +1698,8 @@
"importing": "正在匯入檔案...",
"noSupportedFiles": "未選擇支援的檔案。請選擇圖片或影片檔案。",
"allFiltered": "所有範例圖片都因 NSFW 內容設定而被過濾",
"sfwOnlyEnabled": "目前的設定為僅顯示安全(SFW)內容",
"changeInSettings": "可以在設定中變更此選項",
"sfwOnlyEnabled": "目前的設定為僅顯示安全(SFW)內容",
"changeInSettings": "可以在設定中變更此選項",
"nsfwMature": "成熟內容",
"nsfwR": "R 級內容",
"nsfwX": "X 級內容",
@@ -1597,41 +1723,41 @@
},
"badges": {
"current": "已開啟版本",
"currentTooltip": "這是用來開啟此彈窗的版本",
"currentTooltip": "這是用來開啟此彈窗的版本",
"inLibrary": "已在庫中",
"inLibraryTooltip": "此版本已存在於的本地庫中",
"inLibraryTooltip": "此版本已存在於的本地庫中",
"downloaded": "已下載",
"downloadedTooltip": "此版本之前下載過,但目前不在的本地庫中",
"downloadedTooltip": "此版本之前下載過,但目前不在的本地庫中",
"newer": "較新版本",
"newerTooltip": "此版本比本地的最新版本更新",
"newerTooltip": "此版本比本地的最新版本更新",
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"earlyAccessTooltip": "此版本目前需要 CivitAI 搶先體驗權限",
"paid": "付費",
"paidTooltip": "此版本需要付費才能下載",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
"onSiteOnlyTooltip": "此版本僅在 Civitai 站內可用,無法下載"
"onSiteOnlyTooltip": "此版本僅在 CivitAI 站內可用,無法下載"
},
"actions": {
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadChooseFilesTooltip": "選擇要下載的檔案",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "從 Civitai 下載此付費版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"downloadEarlyAccessTooltip": "從 CivitAI 下載此搶先體驗版本",
"downloadPaidTooltip": "從 CivitAI 下載此付費版本",
"downloadNotAllowedTooltip": "此版本僅在 CivitAI 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
"ignore": "忽略",
"unignore": "取消忽略",
"ignoreTooltip": "忽略此版本的更新通知",
"unignoreTooltip": "恢復此版本的更新通知",
"viewVersionOnCivitai": "在 Civitai 上查看版本",
"viewVersionOnCivitai": "在 CivitAI 上查看版本",
"earlyAccessTooltip": "需要購買搶先體驗",
"resumeModelUpdates": "恢復追蹤此模型的更新",
"ignoreModelUpdates": "忽略此模型的更新",
"viewLocalVersions": "檢視所有本地版本",
"viewLocalTooltip": "敬請期待"
"viewLocalTooltip": "在主頁面上顯示此模型的所有本機版本"
},
"filters": {
"label": "基礎篩選",
@@ -1647,7 +1773,7 @@
},
"empty": "此模型尚無版本歷史。",
"error": "載入版本失敗。",
"missingModelId": "此模型缺少 Civitai 模型 ID。",
"missingModelId": "此模型缺少 CivitAI 模型 ID。",
"hfGroupInfo": "這是一個 HuggingFace 模型組。打開庫頁面即可在網格中查看所有版本。",
"confirm": {
"delete": "要從庫中刪除此版本嗎?"
@@ -1731,14 +1857,14 @@
"tips": {
"title": "小技巧",
"civitai": {
"title": "Civitai 整合",
"description": "連結您的 Civitai 帳號:前往個人頭像 → 設定 → API 金鑰 → 新增 API 金鑰,然後貼到 LoRA 管理器設定中。",
"alt": "Civitai API 設定"
"title": "CivitAI 整合",
"description": "連結您的 CivitAI 帳號:前往個人頭像 → 設定 → API 金鑰 → 新增 API 金鑰,然後貼到 LoRA 管理器設定中。",
"alt": "CivitAI API 設定"
},
"download": {
"title": "快速下載",
"description": "使用 Civitai 網址即可快速下載並安裝新模型。",
"alt": "Civitai 下載"
"description": "使用 CivitAI 網址即可快速下載並安裝新模型。",
"alt": "CivitAI 下載"
},
"recipes": {
"title": "儲存配方",
@@ -1826,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": "最新更新",
@@ -1846,7 +2014,8 @@
"settings": "設定與配置",
"extensions": "擴充功能",
"newBadge": "新"
}
},
"newContentBadge": "新"
},
"update": {
"title": "檢查更新",
@@ -1922,7 +2091,7 @@
"submitGithubIssue": "提交 GitHub 問題",
"joinDiscord": "加入 Discord",
"youtubeChannel": "YouTube 頻道",
"civitaiProfile": "Civitai 個人檔案",
"civitaiProfile": "CivitAI 個人檔案",
"supportKofi": "在 Ko-fi 支持",
"supportPatreon": "在 Patreon 支持"
},
@@ -2005,14 +2174,27 @@
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
"enterLoraName": "請輸入 LoRA 名稱或語法",
"reconnectedSuccessfully": "LoRA 重新連結成功",
"reconnectBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe},LoRA{lora})——兩者架構相容",
"reconnectFailed": "LoRA 重新連結錯誤:{message}",
"loraRestored": "LoRA 已恢復為重新關聯前的關聯",
"loraRestoreFailed": "LoRA 恢復錯誤:{message}",
"noPromptToSend": "沒有可發送的提示詞",
"cannotSend": "無法傳送配方:缺少配方 ID",
"sendFailed": "傳送配方到工作流失敗",
"sendError": "傳送配方到工作流錯誤",
"missingCheckpointPath": "缺少檢查點路徑",
"missingCheckpointInfo": "缺少檢查點資訊",
"downloadCheckpointFailed": "下載檢查點失敗:{message}",
"missingCheckpointPath": "缺少Checkpoint路徑",
"missingCheckpointInfo": "缺少Checkpoint資訊",
"downloadCheckpointFailed": "下載Checkpoint失敗:{message}",
"enterCheckpointName": "請輸入 Checkpoint 名稱",
"checkpointReconnectedSuccessfully": "Checkpoint 重新連結成功",
"reconnectCheckpointBaseModelMismatch": "已重新關聯,但基礎模型不同(配方:{recipe}Checkpoint{checkpoint})——兩者架構相容",
"checkpointReconnectFailed": "Checkpoint 重新連結錯誤:{message}",
"checkpointRestored": "Checkpoint 已恢復為重新關聯前的關聯",
"checkpointRestoreFailed": "Checkpoint 恢復錯誤:{message}",
"checkpointDownloadUnavailable": "缺少 CivitAI 標識,無法下載此 Checkpoint - 請嘗試使用本地 Checkpoint 重新關聯",
"missingLoraDownloadInfo": "缺少此 LoRA 的下載資訊",
"hashNotFoundOnCivitai": "此 LoRA 雜湊無法在 CivitAI 上解析——模型可能已更新或雜湊無效",
"downloadLoraFailed": "下載 LoRA 失敗:{message}",
"cannotDelete": "無法刪除配方:缺少配方 ID",
"deleteConfirmationError": "顯示刪除確認時發生錯誤",
"deletedSuccessfully": "配方已成功刪除",
@@ -2036,23 +2218,22 @@
"batchImportCancelFailed": "取消批量匯入失敗:{message}",
"batchImportNoUrls": "請輸入至少一個 URL 或檔案路徑",
"batchImportNoDirectory": "請輸入目錄路徑",
"batchImportRateLimited": "已達到元數據提供方的速率限制 — 請求正在放緩,部分項目可能被略過。您可以稍後重新執行匯入。",
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何食譜",
"repairBulkComplete": "修復完成:{repaired} 個已修復,{skipped} 個已跳過(共 {total} 個)",
"repairBulkSkipped": "所選 {total} 個配方無需修復",
"repairBulkFailed": "修復所選配方失敗:{message}",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個食譜,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選食譜重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個食譜中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選食譜均無需重新匹配",
"rematchFailed": "重新匹配所選食譜失敗:{message}",
"noRecipesSelected": "未選取任何配方",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
"reimportingViaExtension": "正在透過瀏覽器擴充功能重新匯入配方 {current}/{total}...",
"reimportSuccess": "配方已從來源重新匯入成功",
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noMissingLorasInSelection": "在選取的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。",
"workflowSent": "工作流已傳送到 ComfyUI",
"workflowSendFailed": "傳送工作流到 ComfyUI 失敗: {error}",
@@ -2093,8 +2274,8 @@
"bulkUpdatesChecking": "正在檢查所選 {type} 的更新...",
"bulkUpdatesSuccess": "{count} 個所選 {type} 有可用更新",
"bulkUpdatesNone": "所選 {type} 未找到更新",
"bulkUpdatesMissing": "所選 {type} 未連結 Civitai 更新",
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 Civitai 的所選 {type}",
"bulkUpdatesMissing": "所選 {type} 未連結 CivitAI 更新",
"bulkUpdatesPartialMissing": "已略過 {missing} 個未連結 CivitAI 的所選 {type}",
"bulkUpdatesFailed": "檢查所選 {type} 更新失敗:{message}",
"invalidCharactersRemoved": "已移除檔名中的無效字元",
"filenameCannotBeEmpty": "檔案名稱不可為空",
@@ -2134,8 +2315,8 @@
"compactModeToggled": "緊湊模式已{state}",
"settingSaveFailed": "儲存設定失敗:{message}",
"displayDensitySet": "顯示密度已設為 {density}",
"libraryLoadFailed": "Failed to load libraries: {message}",
"libraryActivateFailed": "Failed to activate library: {message}",
"libraryLoadFailed": "載入模型庫失敗:{message}",
"libraryActivateFailed": "啟動模型庫失敗:{message}",
"languageChangeFailed": "切換語言失敗:{message}",
"cacheCleared": "快取檔案已成功清除。快取將於下次操作時重建。",
"cacheClearFailed": "清除快取失敗:{error}",
@@ -2213,17 +2394,18 @@
},
"controls": {
"reloadFailed": "重新載入 {pageType} 失敗:{message}",
"refreshFailed": "刷新 {pageType} 失敗:{message}",
"refreshFailed": "{action} {pageType} 失敗:{message}",
"fetchMetadataFailed": "取得 metadata 失敗:{message}",
"clearFilterFailed": "清除自訂篩選失敗:{message}"
},
"contextMenu": {
"contentRatingSet": "內容分級已設為 {level}",
"contentRatingFailed": "設定內容分級失敗:{message}",
"relinkSuccess": "模型已成功重新連結至 Civitai",
"relinkSuccess": "模型已成功重新連結至 CivitAI",
"relinkFailed": "錯誤:{message}",
"linkHfSuccess": "模型已成功連結到 HuggingFace",
"linkHfFailed": "錯誤:{message}",
"linkCivArchSuccess": "模型已成功透過 CivitArchive 重新連結",
"fetchMetadataFirst": "請先從 CivitAI 取得 metadata",
"noCivitaiInfo": "無 CivitAI 資訊",
"missingHash": "模型雜湊不可用"
@@ -2283,7 +2465,7 @@
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}",
"moveFailed": "移動項目失敗:{message}",
"copiedToClipboard": "已複製到剪貼簿",
"downloadStarted": "下載已開始"
},
@@ -2313,7 +2495,7 @@
},
"issues": {
"civitai_api_key": {
"title": "Civitai API 金鑰"
"title": "CivitAI API 金鑰"
},
"cache_health": {
"title": "模型快取健康狀態"
@@ -2366,10 +2548,10 @@
"seconds": "秒後重新整理"
},
"communitySupport": {
"title": "Keep LoRA Manager Thriving with Your Support ❤️",
"content": "LoRA Manager is a passion project maintained full-time by a solo developer. Your support on Ko-fi helps cover development costs, keeps new updates coming, and unlocks a license key for the LM Civitai Extension as a thank-you gift. Every contribution truly makes a difference.",
"supportCta": "Support on Ko-fi",
"learnMore": "LM Civitai Extension Tutorial"
"title": "用您的支持讓 LoRA Manager 持續茁壯 ❤️",
"content": "LoRA Manager 是由一位獨立開發者全職維護的熱情項目。您在 Ko-fi 上的支持有助於支付開發成本、持續推出新更新,並將贈送 LM CivitAI 擴充功能的授權金鑰作為感謝之禮。每一份貢獻都意義重大。",
"supportCta": " Ko-fi 上支持",
"learnMore": "LM CivitAI 擴充功能教學"
},
"cacheHealth": {
"corrupted": {
-214
View File
@@ -1,214 +0,0 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class RandomCheckpointLoaderLM:
"""Checkpoint Loader that can randomly pick a checkpoint from the pool
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths. When select_at_random is enabled, ignores ckpt_name
and picks a random checkpoint (optionally filtered by base_model) on
every run.
"""
NAME = "Random Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore ckpt_name and pick a random checkpoint from the "
"pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "VAE", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
"The name of the checkpoint that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_checkpoint"
@classmethod
def IS_CHANGED(cls, ckpt_name, select_at_random=False, base_model="Any"):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return ckpt_name
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
@classmethod
def _get_checkpoint_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include checkpoints matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing checkpoints at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
def load_checkpoint(
self,
ckpt_name: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, Any, Any, str]:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
select_at_random: If True, ignore ckpt_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, CLIP, VAE, model_name)
"""
if select_at_random:
pool = self._get_checkpoint_names(base_model)
if not pool:
raise FileNotFoundError(
f"No checkpoints found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
ckpt_name = random.choice(pool)
logger.info(
f"[RandomCheckpointLoaderLM] Randomly selected checkpoint: {ckpt_name}"
)
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3] + (ckpt_name,)
-326
View File
@@ -1,326 +0,0 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
def _reload_gguf_unet(
unet_path: str, weight_dtype: str, disable_dynamic: bool = False
) -> object:
"""Reload a GGUF diffusion model from disk (cached_patcher_init factory).
Mirrors the GGUF branch of RandomUNETLoaderLM.load_unet so ModelPatcher
deepclone/dynamic machinery can rebuild GGUF models with the correct
GGMLOps. ``disable_dynamic`` is accepted for signature compatibility
with core ComfyUI loaders.
"""
loader = RandomUNETLoaderLM()
model, _unet_name = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
return model
class RandomUNETLoaderLM:
"""UNET Loader that can randomly pick a diffusion model from the pool
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA
Manager's extra folder paths. Supports both regular diffusion models and
GGUF format models. When select_at_random is enabled, ignores unet_name
and picks a random diffusion model (optionally filtered by base_model)
on every run.
"""
NAME = "Random Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore unet_name and pick a random diffusion model from "
"the pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "STRING")
RETURN_NAMES = ("MODEL", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The name of the diffusion model that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_unet"
@classmethod
def IS_CHANGED(
cls, unet_name, weight_dtype, select_at_random=False, base_model="Any"
):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return unet_name
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
@classmethod
def _get_unet_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include models matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing diffusion models at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
def load_unet(
self,
unet_name: str,
weight_dtype: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, ...]:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
select_at_random: If True, ignore unet_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, model_name)
"""
import torch
if select_at_random:
pool = self._get_unet_names(base_model)
if not pool:
raise FileNotFoundError(
f"No diffusion models found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
unet_name = random.choice(pool)
logger.info(
f"[RandomUNETLoaderLM] Randomly selected diffusion model: {unet_name}"
)
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model, unet_name)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple[Any, ...]:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL, model_name)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
# Register a reload factory so the MODEL carries its source path
# (cached_patcher_init) like core ComfyUI loaders do — required
# for model-name extraction downstream and for ModelPatcher
# deepclone/dynamic machinery.
model.cached_patcher_init = (_reload_gguf_unet, (unet_path, weight_dtype))
return (model, unet_name)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)
+27 -8
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__)
@@ -146,15 +147,13 @@ class AutomaticMetadataParser(RecipeMetadataParser):
# Initialize hashes dict if it doesn't exist
if "hashes" not in metadata:
metadata["hashes"] = {}
# Add as lora type in the same format as
# regular hashes. Only override an
# existing entry if its value is empty
# (Lora hashes is the more reliable
# source when Hashes JSON has blanks).
# Lora hashes carries the 12-char AutoV3
# hash (resolvable on CivitAI and the local
# autov3 index); the Hashes JSON value is
# only the 10-char AutoV2 prefix, so on
# conflict the Lora hashes value wins.
key = f"lora:{lora_name}"
existing = metadata["hashes"].get(key, "")
if not existing:
metadata["hashes"][key] = lora_hash
metadata["hashes"][key] = lora_hash
# Remove lora hashes from params section
params_section = params_section.replace(lora_hashes_match.group(0), '')
@@ -526,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:
+21
View File
@@ -115,6 +115,27 @@ class CivitaiApiMetadataParser(RecipeMetadataParser):
):
metadata = inner_meta
# Civitai's image API meta parser mangles the A1111 "Lora hashes"
# text field into a quote-wrapped dict entry:
# '"Daphne Blake Cosplay_v1": "e67ebd5e315f"'
# The 12-char AutoV3 it carries is more reliable than the stale
# 10-char AutoV2 value in the "hashes" dict, so recover it and
# let it override the conflicting entry.
if isinstance(metadata, dict):
for key, hash_value in list(metadata.items()):
if (
isinstance(key, str)
and key.startswith('"')
and isinstance(hash_value, str)
and hash_value.endswith('"')
):
clean_name = key.strip('"').strip()
clean_hash = hash_value.strip('"').strip()
if clean_name and clean_hash:
hashes_dict = metadata.get("hashes")
if isinstance(hashes_dict, dict):
hashes_dict[f"lora:{clean_name}"] = clean_hash
# Initialize result structure
result: Dict[str, Any] = {
"base_model": None,
+28 -18
View File
@@ -40,24 +40,34 @@ class ComfyMetadataParser(RecipeMetadataParser):
checkpoint_node = next(iter(checkpoint_nodes.values()))
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
if checkpoint_match:
checkpoint_id = checkpoint_match.group(1)
checkpoint_version_id = checkpoint_match.group(2)
checkpoint = {
'id': checkpoint_version_id,
'modelId': checkpoint_id,
'name': f"Checkpoint {checkpoint_id}",
'version': '',
'type': 'checkpoint'
}
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
# Some ComfyUI workflows serialize ckpt_name as a
# single-element list (e.g. ["model.safetensors"]) or leave
# the value unset (None). Neither is a string, so skip the
# CivitAI-URN lookup instead of crashing re.search with a
# TypeError that fails the whole image import.
if isinstance(checkpoint_name, list):
checkpoint_name = (
checkpoint_name[0] if checkpoint_name else None
)
if isinstance(checkpoint_name, str):
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
if checkpoint_match:
checkpoint_id = checkpoint_match.group(1)
checkpoint_version_id = checkpoint_match.group(2)
checkpoint = {
'id': checkpoint_version_id,
'modelId': checkpoint_id,
'name': f"Checkpoint {checkpoint_id}",
'version': '',
'type': 'checkpoint'
}
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
recipe_base_model = checkpoint.get('baseModel') if checkpoint else None
loras = []
+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()
+5 -4
View File
@@ -47,15 +47,16 @@ class CheckpointRoutes(BaseModelRoutes):
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
# Name/base_model pool for the Random Checkpoint/Unet Loader nodes
# Name/base_model pool for the Checkpoint/Unet Loader nodes' base_model filtering
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
async def get_loader_pool(self, request: web.Request) -> web.Response:
"""Return ComfyUI-formatted model names with their base_model.
Backing data for the Random Checkpoint/Unet Loader nodes: the front-end
filters the ckpt_name/unet_name combo options by base_model using this
pool, so control_after_generate randomizes within the narrowed set.
Backing data for the Checkpoint/Unet Loader nodes'
control_after_generate feature: the front-end filters the
ckpt_name/unet_name combo options by base_model using this pool, so
randomize mode picks within the narrowed set.
"""
try:
sub_type = request.query.get("sub_type", "checkpoint")
+121 -8
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,
@@ -634,6 +638,16 @@ class ModelManagementHandler:
file_path = data.get("file_path")
model_id = data.get("model_id")
model_version_id = data.get("model_version_id")
source = data.get("source")
if source not in (None, "", "civarchive"):
return web.json_response(
{
"success": False,
"error": f"Unsupported relink source: {source}",
},
status=400,
)
if not file_path or model_id is None:
return web.json_response(
@@ -649,20 +663,33 @@ class ModelManagementHandler:
metadata_path
)
relink_kwargs = {
"file_path": file_path,
"metadata": local_metadata,
"model_id": int(model_id),
"model_version_id": int(model_version_id) if model_version_id else None,
}
if source == "civarchive":
relink_kwargs["provider_name"] = "civarchive_api"
updated_metadata = await self._metadata_sync.relink_metadata(
file_path=file_path,
metadata=local_metadata,
model_id=int(model_id),
model_version_id=int(model_version_id) if model_version_id else None,
**relink_kwargs
)
await self._service.scanner.update_single_model_cache(
file_path, file_path, updated_metadata
)
message = f"Model successfully re-linked to Civitai model {model_id}" + (
f" version {model_version_id}" if model_version_id else ""
)
if source == "civarchive":
message = (
f"Model successfully re-linked to CivArchive model {model_id}"
+ (f" version {model_version_id}" if model_version_id else "")
)
else:
message = (
f"Model successfully re-linked to Civitai model {model_id}"
+ (f" version {model_version_id}" if model_version_id else "")
)
return web.json_response(
{
"success": True,
@@ -670,6 +697,8 @@ class ModelManagementHandler:
"hash": updated_metadata.get("sha256", ""),
}
)
except ValueError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except Exception as exc:
if is_expected_offline_error(str(exc)):
return web.json_response(
@@ -1570,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)
@@ -1609,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."""
@@ -3314,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,
File diff suppressed because it is too large Load Diff
+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"
),
+30 -5
View File
@@ -49,6 +49,31 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("POST", "/api/lm/recipe/lora/restore", "restore_lora"),
RouteDefinition(
"GET",
"/api/lm/recipe/{recipe_id}/lora/{lora_index}/reconnect-suggestions",
"get_reconnect_suggestions",
),
RouteDefinition(
"POST", "/api/lm/recipe/lora/mark-hash-invalid", "mark_lora_hash_invalid"
),
RouteDefinition(
"POST", "/api/lm/recipe/checkpoint/reconnect", "reconnect_checkpoint"
),
RouteDefinition(
"POST", "/api/lm/recipe/checkpoint/restore", "restore_checkpoint"
),
RouteDefinition(
"GET",
"/api/lm/recipe/{recipe_id}/checkpoint/reconnect-suggestions",
"get_checkpoint_reconnect_suggestions",
),
RouteDefinition(
"POST",
"/api/lm/recipe/checkpoint/mark-hash-invalid",
"mark_checkpoint_hash_invalid",
),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
RouteDefinition(
@@ -59,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"),
@@ -90,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
+46 -3
View File
@@ -82,6 +82,17 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
)
def _is_no_uri_available_error(message: str) -> bool:
"""Return True for aria2's "No URI available" transfer failure.
aria2 reports this when every URI for the transfer has become unusable.
For CivitAI downloads this typically means the temporary signed URL
expired mid-download; the transfer can be recovered by resolving a fresh
signed URL and re-scheduling with ``continue=true``.
"""
return "no uri available" in message.lower()
class Aria2Error(RuntimeError):
"""Raised when aria2 integration fails."""
@@ -145,8 +156,11 @@ class Aria2Downloader:
disappears (e.g. another download restarted the daemon and
``close()`` cleared ``_transfers``) or the RPC becomes unreachable,
the transfer is re-scheduled with ``continue=true`` so the download
resumes from the on-disk ``.aria2`` control file. Recovery is bounded
by ``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
resumes from the on-disk ``.aria2`` control file. The same
re-scheduling happens when aria2 fails with "No URI available"
(typically an expired CivitAI signed URL): a fresh URL is resolved
and the partial download continues. Recovery is bounded by
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
"""
await self._ensure_process()
@@ -201,7 +215,36 @@ class Aria2Downloader:
completed_path = self._resolve_completed_path(status, save_path)
return True, completed_path
if state == "error":
return False, status.get("errorMessage") or "aria2 download failed"
error_message = status.get("errorMessage") or "aria2 download failed"
if (
_is_no_uri_available_error(error_message)
and recovery_attempts < MAX_TRANSFER_RECOVERY_ATTEMPTS
):
# The signed URL (e.g. CivitAI's) expired before the
# transfer finished. Re-registering resolves a fresh
# URL and resumes from the on-disk partial payload and
# .aria2 control file via ``continue=true``.
recovery_attempts += 1
logger.warning(
"aria2 transfer %s failed with %r; refreshing the "
"URL and resuming the partial download "
"(attempt %d/%d)",
download_id,
error_message,
recovery_attempts,
MAX_TRANSFER_RECOVERY_ATTEMPTS,
)
await asyncio.sleep(1.0)
await self._ensure_process()
async with self._register_lock:
transfer = await self._register_transfer(
url,
save_path,
download_id=download_id,
headers=headers,
)
continue
return False, error_message
if state == "removed":
return False, "Download was cancelled"
+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
+129 -4
View File
@@ -20,6 +20,11 @@ from .recipes import (
RecipeDownloadError,
RecipeNotFoundError,
)
from .recipes.import_info import (
CHANNEL_BATCH_IMPORT_LOCAL,
CHANNEL_BATCH_IMPORT_URL,
build_import_info,
)
class ImportItemType(Enum):
@@ -71,6 +76,9 @@ class BatchImportProgress:
tags: List[str] = field(default_factory=list)
skip_no_metadata: bool = False
skip_duplicates: bool = False
# Set once any item is skipped due to vendor rate limiting (#1085); lets
# the UI surface a "slowing down / try again later" hint.
rate_limited: bool = False
def to_dict(self) -> Dict[str, Any]:
return {
@@ -82,6 +90,7 @@ class BatchImportProgress:
"skipped": self.skipped,
"current_item": self.current_item,
"status": self.status,
"rate_limited": self.rate_limited,
"started_at": self.started_at,
"finished_at": self.finished_at,
"progress_percent": round((self.completed / self.total) * 100, 1)
@@ -118,6 +127,10 @@ class AdaptiveConcurrencyController:
self._task_durations: List[float] = []
self._recent_errors = 0
self._recent_successes = 0
# Batch-wide shared semaphore; created lazily on first use so the
# controller can also be constructed outside a running event loop.
self._semaphore: Optional[asyncio.Semaphore] = None
self._semaphore_capacity = initial_concurrency
def record_result(self, duration: float, success: bool) -> None:
self._task_durations.append(duration)
@@ -146,7 +159,37 @@ class AdaptiveConcurrencyController:
self._recent_successes = 0
def get_semaphore(self) -> asyncio.Semaphore:
return asyncio.Semaphore(self.current_concurrency)
"""Return the batch-wide shared semaphore.
The same semaphore instance is returned for every item of a batch so
the configured concurrency bounds are actually enforced. Previously a
fresh semaphore was created per call, letting every item run
concurrently and hammering remote metadata providers without any
limit.
"""
if self._semaphore is None:
self._semaphore = asyncio.Semaphore(self.current_concurrency)
self._semaphore_capacity = self.current_concurrency
return self._semaphore
async def apply_concurrency(self) -> None:
"""Synchronize the shared semaphore capacity with ``current_concurrency``.
Call after ``record_result`` (once per completed item). Growing the
capacity is immediate (release). Shrinking requires acquiring a permit
and holding it, which is best-effort while other tasks are still
running the capacity converges on subsequent calls.
"""
semaphore = self.get_semaphore()
while self._semaphore_capacity < self.current_concurrency:
semaphore.release()
self._semaphore_capacity += 1
while self._semaphore_capacity > self.current_concurrency:
try:
await asyncio.wait_for(semaphore.acquire(), timeout=0.01)
except (asyncio.TimeoutError, asyncio.CancelledError):
break
self._semaphore_capacity -= 1
class BatchImportService:
@@ -184,6 +227,7 @@ class BatchImportService:
def cancel_import(self, operation_id: str) -> bool:
if operation_id in self._active_operations:
self._cancellation_flags[operation_id] = True
self._logger.info("Cancel requested for batch import operation %s", operation_id)
return True
return False
@@ -273,6 +317,14 @@ class BatchImportService:
self._active_operations[operation_id] = progress
self._cancellation_flags[operation_id] = False
self._logger.info(
"Starting batch import operation %s: %d item(s) (%d URL(s), %d local path(s))",
operation_id,
len(import_items),
sum(1 for it in import_items if it.item_type == ImportItemType.URL),
sum(1 for it in import_items if it.item_type == ImportItemType.LOCAL_PATH),
)
asyncio.create_task(
self._run_batch_import(
operation_id=operation_id,
@@ -295,6 +347,12 @@ class BatchImportService:
skip_duplicates: bool = False,
) -> str:
image_paths = await self._discover_images(directory, recursive)
self._logger.info(
"Batch import directory scan: %d image(s) discovered in %s (recursive=%s)",
len(image_paths),
directory,
recursive,
)
items = [{"source": path, "type": "local_path"} for path in image_paths]
@@ -334,6 +392,13 @@ class BatchImportService:
ext = os.path.splitext(filename)[1].lower()
return ext in self.SUPPORTED_EXTENSIONS
@staticmethod
def _is_rate_limit_error(error: Optional[str]) -> bool:
"""Return True when an error payload represents vendor rate limiting."""
if not error:
return False
return "rate limit" in error.lower()
async def _run_batch_import(
self,
*,
@@ -379,6 +444,9 @@ class BatchImportService:
self._concurrency_controller.record_result(
duration, result.get("success", False)
)
# Keep the shared batch semaphore in sync with the adaptively
# adjusted concurrency so the bounds actually take effect.
await self._concurrency_controller.apply_concurrency()
if result.get("success"):
item.status = ImportStatus.SUCCESS
@@ -389,6 +457,17 @@ class BatchImportService:
item.status = ImportStatus.SKIPPED
item.error_message = result.get("error")
progress.skipped += 1
elif self._is_rate_limit_error(result.get("error")):
# Vendor rate limit is a transient, external condition —
# do not pollute the failure count with it (#1085). The
# import can simply be re-run later.
item.status = ImportStatus.SKIPPED
item.error_message = (
f"Rate limited by metadata provider; "
f"re-run the import later ({result.get('error')})"
)
progress.skipped += 1
progress.rate_limited = True
else:
item.status = ImportStatus.FAILED
item.error_message = result.get("error")
@@ -396,13 +475,36 @@ class BatchImportService:
except Exception as e:
self._logger.error(f"Error importing {item.source}: {e}")
item.status = ImportStatus.FAILED
item.error_message = str(e)
item.duration = time.time() - start_time
progress.failed += 1
if self._is_rate_limit_error(str(e)):
item.status = ImportStatus.SKIPPED
item.error_message = (
f"Rate limited by metadata provider; "
f"re-run the import later ({e})"
)
progress.skipped += 1
progress.rate_limited = True
else:
item.status = ImportStatus.FAILED
item.error_message = str(e)
progress.failed += 1
self._concurrency_controller.record_result(item.duration, False)
await self._concurrency_controller.apply_concurrency()
progress.completed += 1
self._logger.info(
"Batch import %s: item %d/%d status=%s source=%s%s",
operation_id,
progress.completed,
progress.total,
item.status.value,
(
os.path.basename(item.source)
if item.item_type == ImportItemType.LOCAL_PATH
else item.source[:50]
),
(f" error={item.error_message}" if item.error_message else ""),
)
await self._broadcast_progress(progress)
tasks = [process_item(item) for item in progress.items]
@@ -415,6 +517,15 @@ class BatchImportService:
progress.finished_at = time.time()
progress.current_item = ""
self._logger.info(
"Batch import %s finished: status=%s total=%d success=%d failed=%d skipped=%d",
operation_id,
progress.status,
progress.total,
progress.success,
progress.failed,
progress.skipped,
)
await self._broadcast_progress(progress)
await asyncio.sleep(5)
@@ -518,6 +629,17 @@ class BatchImportService:
"loras": loras,
"gen_params": payload.get("gen_params", {}),
"source_path": item.source,
# Record why this import ended up with no LoRAs so the
# recipe modal can explain it (collapsed by default).
"import_info": build_import_info(
(
CHANNEL_BATCH_IMPORT_URL
if item.item_type == ImportItemType.URL
else CHANNEL_BATCH_IMPORT_LOCAL
),
payload.get("diagnostics"),
loras,
),
}
if payload.get("checkpoint"):
@@ -595,3 +717,6 @@ class BatchImportService:
def _cleanup_operation(self, operation_id: str) -> None:
if operation_id in self._cancellation_flags:
del self._cancellation_flags[operation_id]
if operation_id in self._active_operations:
del self._active_operations[operation_id]
self._logger.info("Batch import operation %s cleaned up", operation_id)
+43 -7
View File
@@ -7,6 +7,7 @@ import logging
import asyncio
from copy import deepcopy
from typing import Any, Optional, Dict, Tuple, List, cast
from .connectivity_guard import is_expected_offline_error
from .model_metadata_provider import CivArchiveModelMetadataProvider, ModelMetadataProviderManager
from .downloader import get_downloader
from .errors import RateLimitError
@@ -46,7 +47,11 @@ class CivArchiveClient:
"""Call CivArchive API and return JSON payload"""
success, payload = await self._make_request(path, params=params)
if not success:
error = payload if isinstance(payload, str) else "Request failed"
# Normalize empty-string failure payloads (e.g. a throttled
# connection dropped without a message) so callers never see a
# falsy error alongside a None payload — that combination used to
# crash downstream None.get() calls.
error = payload if isinstance(payload, str) and payload else "Request failed"
return None, error
if not isinstance(payload, dict):
return None, "Invalid response structure"
@@ -298,6 +303,8 @@ class CivArchiveClient:
async def _resolve_version_from_files(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Fallback to fetch version data when only file metadata is available"""
if not isinstance(payload, dict):
return None
data = self._normalize_payload(payload)
files = data.get("files") or payload.get("files") or []
if not isinstance(files, list):
@@ -332,10 +339,13 @@ class CivArchiveClient:
"""Find model by SHA256 hash value using CivArchive API"""
try:
payload, error = await self._request_json(f"/sha256/{model_hash.lower()}")
if error:
if "not found" in error.lower():
# Treat a missing payload as an error even when the error string is
# falsy; passing None into the split/transform helpers below used to
# crash with "'NoneType' object has no attribute 'get'".
if error is not None or payload is None:
if error and "not found" in error.lower():
return None, "Model not found"
return None, error
return None, error or "Request failed"
context, version_data, fallback_files = self._split_context(cast(Dict[str, Any], payload))
transformed = self._transform_version(context, version_data, fallback_files)
@@ -352,7 +362,14 @@ class CivArchiveClient:
except RateLimitError:
raise
except Exception as e:
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
if is_expected_offline_error(str(e)):
logger.debug(
"Skipping CivArchive model by hash %s while offline: %s",
model_hash[:10],
e,
)
else:
logger.error(f"Error fetching CivArchive model by hash {model_hash[:10]}: {e}")
return None, str(e)
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
@@ -362,7 +379,14 @@ class CivArchiveClient:
if error or payload is None:
if error and "not found" in error.lower():
return None
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
if is_expected_offline_error(error):
logger.debug(
"Skipping CivArchive model versions fetch for %s while offline: %s",
model_id,
error,
)
else:
logger.error(f"Error fetching CivArchive model versions for {model_id}: {error}")
return None
data = self._normalize_payload(payload)
@@ -426,7 +450,19 @@ class CivArchiveClient:
if error or payload is None:
if error and "not found" in error.lower():
return None
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
# The connectivity guard short-circuits requests during its
# offline cooldown; that is an expected, transient state, so
# log it as DEBUG instead of spamming one ERROR per request
# (batch imports can hit this thousands of times).
if is_expected_offline_error(error):
logger.debug(
"Skipping CivArchive model version fetch %s/%s while offline: %s",
model_id,
version_id,
error,
)
else:
logger.error(f"Error fetching CivArchive model version via API {model_id}/{version_id}: {error}")
return None
context, version_data, fallback_files = self._split_context(payload)
+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",
+178 -14
View File
@@ -2,6 +2,7 @@
# Lazy (function-local) imports still count as static edges in basedpyright's
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import contextlib
import copy
import json
import logging
@@ -12,6 +13,7 @@ import shutil
import zipfile
from concurrent.futures import ThreadPoolExecutor
from collections import OrderedDict
from dataclasses import dataclass, field
import uuid
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
from urllib.parse import urlparse
@@ -33,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
@@ -53,6 +56,12 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
NON_DOWNLOADABLE_PRIMARY_TYPES = ("Config", "Archive", "Workflow", "Training Data")
@dataclass
class _PathSlot:
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
refs: int = 0
class DownloadManager:
_instance = None
_lock = asyncio.Lock()
@@ -82,6 +91,11 @@ class DownloadManager:
self._aria2_state_store = Aria2TransferStateStore()
self._restored_persisted_downloads = False
self._restore_lock = asyncio.Lock()
# Refcounted per-target-path locks: two downloads resolving to the
# same save_path (e.g. model versions sharing one filename) must not
# overlap, or one task's failure cleanup can delete the other's file.
self._path_slot_guard: asyncio.Lock = asyncio.Lock()
self._path_slots: dict[str, _PathSlot] = {}
@staticmethod
def _get_model_download_backend() -> str:
@@ -704,6 +718,47 @@ class DownloadManager:
await asyncio.sleep(delay)
return False
@staticmethod
def _reconcile_failed_aria2_partial(save_path: str) -> None:
"""Reconcile on-disk partial state after a failed aria2 transfer.
The payload and its ``.aria2`` control file form a resumable pair and
are preserved together so a retry (with a refreshed URL when needed)
can resume via aria2's ``continue=true``. A control file without its
payload cannot resume anything, so the orphan is reported and removed.
"""
control_path = f"{save_path}.aria2"
payload_exists = os.path.exists(save_path)
control_exists = os.path.exists(control_path)
if payload_exists and not control_exists:
# If the .aria2 control file is missing, aria2 considers the
# download complete. A transient RPC failure may have made us
# think the download failed even though the file is fully on disk.
# Keep the file so a retry can find it already complete.
logger.warning(
"aria2 download reported failure but .aria2 file is absent "
"for %s — the file is likely complete. Preserving it for retry.",
save_path,
)
elif payload_exists and control_exists:
logger.info(
"Preserving aria2 partial download for resume: %s", save_path
)
elif control_exists:
logger.warning(
"Orphaned aria2 control file without payload: %s — removing it",
control_path,
)
try:
os.remove(control_path)
except OSError as exc:
logger.warning(
"Failed to remove orphaned aria2 control file %s: %s",
control_path,
exc,
)
async def _cleanup_cancelled_download_files(
self,
download_id: str,
@@ -875,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,
*,
@@ -1213,6 +1304,24 @@ class DownloadManager:
)
continue
if not os.path.exists(save_path) and os.path.exists(control_path):
# A control file without its payload cannot resume
# anything; report it and clean up the orphan.
logger.warning(
"Orphaned aria2 control file without payload for %s: "
"%s — removing it",
download_id,
control_path,
)
try:
os.remove(control_path)
except OSError as exc:
logger.warning(
"Failed to remove orphaned aria2 control file %s: %s",
control_path,
exc,
)
await self._aria2_state_store.remove(download_id)
self._restored_persisted_downloads = True
@@ -1786,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:
@@ -2132,6 +2259,28 @@ class DownloadManager:
return formatted_path
@contextlib.asynccontextmanager
async def _exclusive_target_slot(self, target_key: str):
async with self._path_slot_guard:
slot = self._path_slots.get(target_key)
if slot is None:
slot = _PathSlot()
self._path_slots[target_key] = slot
slot.refs += 1
try:
async with slot.lock:
yield
finally:
async with self._path_slot_guard:
slot.refs -= 1
if slot.refs <= 0:
_ = self._path_slots.pop(target_key, None)
def _target_slot_key(self, save_dir: str, metadata) -> str:
return os.path.abspath(
os.path.join(save_dir, os.path.basename(metadata.file_path))
)
async def _execute_download(
self,
download_urls: List[str],
@@ -2143,6 +2292,33 @@ class DownloadManager:
model_type: str = "lora",
download_id: str | None = None,
transfer_backend: Optional[str] = None,
) -> Dict[str, Any]:
"""Execute the download serialized against other downloads targeting the same path."""
target_key = self._target_slot_key(save_dir, metadata)
async with self._exclusive_target_slot(target_key):
return await self._execute_download_pipeline(
download_urls=download_urls,
save_dir=save_dir,
metadata=metadata,
version_info=version_info,
relative_path=relative_path,
progress_callback=progress_callback,
model_type=model_type,
download_id=download_id,
transfer_backend=transfer_backend,
)
async def _execute_download_pipeline(
self,
download_urls: List[str],
save_dir: str,
metadata,
version_info: Dict[str, Any],
relative_path: str,
progress_callback=None,
model_type: str = "lora",
download_id: str | None = None,
transfer_backend: Optional[str] = None,
) -> Dict[str, Any]:
"""Execute the actual download process including preview images and model files"""
metadata_entries: List[Any] = []
@@ -2361,20 +2537,8 @@ class DownloadManager:
break
last_error = result
# For aria2: if the .aria2 control file is missing, aria2 considers
# the download complete. A transient RPC failure may have made us
# think the download failed even though the file is fully on disk.
# Keep the file so a retry can find it already complete.
if (
transfer_backend == "aria2"
and os.path.exists(save_path)
and not os.path.exists(f"{save_path}.aria2")
):
logger.warning(
"aria2 download reported failure but .aria2 file is absent "
"for %s — the file is likely complete. Preserving it for retry.",
save_path,
)
if transfer_backend == "aria2":
self._reconcile_failed_aria2_partial(save_path)
elif os.path.exists(save_path):
try:
os.remove(save_path)
+125 -57
View File
@@ -32,6 +32,7 @@ from .connectivity_guard import (
ConnectivityGuard,
)
from .errors import RateLimitError
from .rate_limit_coordinator import RateLimitCoordinator
logger = logging.getLogger(__name__)
@@ -595,6 +596,21 @@ class Downloader:
False,
"File not found - the download link may be invalid or expired.",
)
elif response.status == 429:
# Register the vendor's cooldown so API calls through
# make_request queue behind it (#1085). The download
# itself fails as before; retry policy stays with the
# caller (download manager).
retry_after = self._extract_retry_after(response.headers)
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(
self._guard_destination(url), retry_after
)
logger.warning(
f"Rate limited (429) for {url}, retry_after={retry_after}"
)
return False, f"Download rate limited (429), retry after {retry_after}s"
else:
logger.error(
f"Download failed for {url} with status {response.status}"
@@ -972,6 +988,11 @@ class Downloader:
elif response.status == 429:
raw_retry_after = response.headers.get("Retry-After")
retry_after = _parse_retry_after(raw_retry_after or "")
# Register the vendor's cooldown so API calls through
# make_request queue behind it (#1085).
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(destination, retry_after)
if raw_retry_after:
logger.warning(
"Rate limited (429) for %s, Retry-After: %ss", url, retry_after
@@ -1041,6 +1062,14 @@ class Downloader:
if response.status == 200:
guard.register_success(destination)
return True, dict(response.headers)
elif response.status == 429:
# Register the vendor's cooldown so API calls through
# make_request queue behind it (#1085).
retry_after = self._extract_retry_after(response.headers)
coordinator = await RateLimitCoordinator.get_instance()
if coordinator.enabled:
coordinator.register_rate_limit(destination, retry_after)
return False, f"Head request rate limited (429), retry after {retry_after}s"
else:
return False, f"Head request failed with status {response.status}"
@@ -1074,74 +1103,113 @@ class Downloader:
Returns:
Tuple[bool, Union[Dict, str]]: (success, response data or error message)
When the rate-limit gate is enabled (``rate_limit_gate_enabled``),
requests are paced per destination and 429 responses are honored by
waiting out the ``Retry-After`` window (bounded by
``rate_limit_max_wait_seconds``) before re-sending. A ``RateLimitError``
returned after gate involvement is marked with ``gate_handled = True``
so downstream retry helpers do not wait a second time.
"""
guard = await ConnectivityGuard.get_instance()
destination = self._guard_destination(url)
# Fail fast on transport-level outages before pacing: there is no
# point waiting out a vendor cooldown while the network is down.
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
try:
session = await self.session
# Debug log for proxy mode at request time
if self.proxy_url:
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
else:
logger.debug(
"[make_request] Using system-level proxy (trust_env) if configured."
)
coordinator = await RateLimitCoordinator.get_instance()
gate_enabled = coordinator.enabled
# Safety bound on the wait-and-resend loop; each 429 normally exits
# via the wait cap in wait_for_slot, this covers pathological 429s
# with tiny Retry-After values.
max_resend_attempts = 5
attempt = 0
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
while True:
if gate_enabled:
try:
await coordinator.wait_for_slot(destination)
except RateLimitError as exc:
exc.gate_handled = True
return False, exc
# Add proxy to kwargs if not already present
if "proxy" not in kwargs:
kwargs["proxy"] = self.proxy_url
async with session.request(
method, url, headers=headers, **kwargs
) as response:
if response.status == 200:
guard.register_success(destination)
# Try to parse as JSON, fall back to text
try:
data = await response.json()
return True, data
except:
text = await response.text()
return True, text
elif response.status == 401:
return False, "Unauthorized access - invalid or missing API key"
elif response.status == 403:
return False, "Access forbidden"
elif response.status == 404:
return False, "Resource not found"
elif response.status == 429:
retry_after = self._extract_retry_after(response.headers)
error_msg = "Request rate limited"
logger.warning(
"Rate limit encountered for %s %s; retry_after=%s",
method,
url,
retry_after,
)
return False, RateLimitError(
error_msg,
retry_after=retry_after,
)
try:
session = await self.session
# Debug log for proxy mode at request time
if self.proxy_url:
logger.debug(f"[make_request] Using app-level proxy: {self.proxy_url}")
else:
return False, f"Request failed with status {response.status}"
logger.debug(
"[make_request] Using system-level proxy (trust_env) if configured."
)
except Exception as e:
if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination)
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
logger.debug("Network unavailable for %s %s: %s", method, url, e)
# Prepare headers
headers = self._get_auth_headers(use_auth)
if custom_headers:
headers.update(custom_headers)
# Add proxy to kwargs if not already present
if "proxy" not in kwargs:
kwargs["proxy"] = self.proxy_url
async with session.request(
method, url, headers=headers, **kwargs
) as response:
if response.status == 200:
guard.register_success(destination)
if gate_enabled:
coordinator.register_success(destination)
# Try to parse as JSON, fall back to text
try:
data = await response.json()
return True, data
except:
text = await response.text()
return True, text
elif response.status == 401:
return False, "Unauthorized access - invalid or missing API key"
elif response.status == 403:
return False, "Access forbidden"
elif response.status == 404:
return False, "Resource not found"
elif response.status == 429:
retry_after = self._extract_retry_after(response.headers)
error_msg = "Request rate limited"
if not gate_enabled:
logger.warning(
"Rate limit encountered for %s %s; retry_after=%s",
method,
url,
retry_after,
)
return False, RateLimitError(
error_msg,
retry_after=retry_after,
)
# The coordinator logs the cooldown notice (INFO once
# per window, DEBUG on extension).
coordinator.register_rate_limit(destination, retry_after)
attempt += 1
if attempt >= max_resend_attempts:
error = RateLimitError(error_msg, retry_after=retry_after)
error.gate_handled = True
return False, error
# Loop back: wait_for_slot blocks until the cooldown
# elapses (or raises once the wait exceeds the cap).
continue
else:
return False, f"Request failed with status {response.status}"
except Exception as e:
if guard.is_network_unreachable_error(e):
guard.register_network_failure(e, destination)
if guard.should_block_request(destination):
return False, OFFLINE_COOLDOWN_ERROR
logger.debug("Network unavailable for %s %s: %s", method, url, e)
return False, str(e)
logger.error(f"Error making {method} request to {url}: {e}")
return False, str(e)
logger.error(f"Error making {method} request to {url}: {e}")
return False, str(e)
async def close(self):
"""Close the HTTP session"""
+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 []
+34 -4
View File
@@ -245,16 +245,23 @@ class MetadataSyncService:
civitai_api_not_found = False
any_rate_limited = False
skip_network_providers = False
for provider_name, provider in provider_attempts:
if skip_network_providers and provider_name != "sqlite":
# A network provider was already rate-limited; failing
# over to another network provider just spreads the flood
# (#1085). The local sqlite archive stays as last resort.
continue
try:
civitai_metadata_candidate, error = await provider.get_model_by_hash(sha256)
except RateLimitError as exc:
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
provider_name or provider.__class__.__name__,
exc.retry_after or 0,
)
any_rate_limited = True
skip_network_providers = True
continue
except Exception as exc: # pragma: no cover - defensive logging
logger.error("Provider %s failed for hash %s: %s", provider_name, sha256, exc)
@@ -419,14 +426,37 @@ class MetadataSyncService:
metadata: Dict[str, Any],
model_id: int,
model_version_id: Optional[int],
provider_name: Optional[str] = None,
) -> Dict[str, Any]:
"""Relink a local metadata record to a specific CivitAI model version."""
"""Relink a local metadata record to a specific CivitAI model version.
When ``provider_name`` is given, the named provider is resolved via the
metadata provider selector instead of the default fallback chain. A
missing/disabled provider surfaces a user-friendly error instead of the
raw selector exception.
"""
if provider_name:
try:
provider = await self._get_provider(provider_name)
except ValueError as exc:
logger.warning(
"Unable to resolve metadata provider %s: %s", provider_name, exc
)
raise ValueError(
"CivitArchive is not available or not enabled. "
"Enable the CivitArchive API in settings to relink via CivArchive."
) from exc
else:
provider = await self._get_default_provider()
provider = await self._get_default_provider()
civitai_metadata = await provider.get_model_version(model_id, model_version_id)
if not civitai_metadata:
provider_label = (
"CivitArchive" if provider_name == "civarchive_api" else "CivitAI"
)
raise ValueError(
f"Model version not found on CivitAI for ID: {model_id}"
f"Model version not found on {provider_label} for ID: {model_id}"
+ (f" with version: {model_version_id}" if model_version_id else "")
)
+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:
+115 -7
View File
@@ -66,6 +66,14 @@ class _RateLimitRetryHelper:
except RateLimitError as exc:
attempt += 1
# The downloader's rate-limit gate already applied the wait
# policy for this request (waited out the vendor window or
# deliberately refused because it exceeds the cap). Sleeping
# again here would double the wait — just propagate.
if getattr(exc, "gate_handled", False):
exc.provider = exc.provider or label
raise
# Determine effective retry limit based on rate-limit magnitude
effective_retry_limit = self._retry_limit # default: 3
if exc.retry_after is not None and exc.retry_after >= 120.0:
@@ -101,6 +109,12 @@ class _RateLimitRetryHelper:
return min(self._max_delay, max(0.0, base_delay))
# Labels of providers that are free to consult even while a network provider
# is rate-limited (local lookups, no vendor cost).
_LOCAL_PROVIDER_LABELS = frozenset({"sqlite"})
class ModelMetadataProvider(ABC):
"""Base abstract class for all model metadata providers"""
@@ -155,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"""
@@ -189,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"""
@@ -451,7 +481,14 @@ class SQLiteModelMetadataProvider(ModelMetadataProvider):
return None
class FallbackMetadataProvider(ModelMetadataProvider):
"""Try providers in order, return first successful result."""
"""Try providers in order, return first successful result.
Rate-limit policy (#1085): once a *network* provider raises
``RateLimitError``, the chain stops consulting further network providers
failing over would just spread the flood to the next vendor. Local-only
providers (see ``_LOCAL_PROVIDER_LABELS``) are still allowed as a last
resort because they cost the vendor nothing.
"""
def __init__(
self,
@@ -486,7 +523,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
)
async def get_model_by_hash(self, model_hash: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result, error = await self._call_with_rate_limit(
label,
@@ -496,8 +536,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result, error
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
@@ -505,11 +546,18 @@ class FallbackMetadataProvider(ModelMetadataProvider):
except Exception as e:
logger.debug("Provider %s failed for get_model_by_hash: %s", label, e)
continue
if rate_limited:
# Distinct from "Model not found": callers must not mistake a
# rate-limited lookup for a confirmed deletion.
return None, "Rate limited"
return None, "Model not found"
async def get_model_versions(self, model_id: str) -> Optional[Dict[str, Any]]:
not_found_confirmed = False
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,
@@ -519,8 +567,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
@@ -539,7 +588,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
return None
async def get_model_version(self, model_id: Optional[int] = None, version_id: Optional[int] = None) -> 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,
@@ -550,8 +602,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
@@ -562,7 +615,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
return None
async def get_model_version_info(self, version_id: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
rate_limited = False
for provider, label in self._iter_providers():
if rate_limited and label not in _LOCAL_PROVIDER_LABELS:
continue
try:
result, error = await self._call_with_rate_limit(
label,
@@ -572,8 +628,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result:
return result, error
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
@@ -581,12 +638,17 @@ class FallbackMetadataProvider(ModelMetadataProvider):
except Exception as e:
logger.debug("Provider %s failed for get_model_version_info: %s", label, e)
continue
if rate_limited:
return None, "Rate limited"
return None, "No provider could retrieve the data"
async def get_model_versions_by_hashes(
self, hashes: List[str]
) -> Optional[List[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,
@@ -598,8 +660,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
except NotImplementedError:
continue
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
@@ -614,7 +677,10 @@ class FallbackMetadataProvider(ModelMetadataProvider):
return None
async def get_user_models(self, username: str, cursor: Optional[str] = None) -> 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,
@@ -625,8 +691,9 @@ class FallbackMetadataProvider(ModelMetadataProvider):
if result is not None:
return result
except RateLimitError as exc:
rate_limited = True
logger.warning(
"Provider %s is rate-limited (retry_after=%.0fs); skipping to next provider",
"Provider %s is rate-limited (retry_after=%.0fs); not failing over to other network providers",
label,
exc.retry_after or 0,
)
@@ -649,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)
@@ -740,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"""
+139 -18
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
@@ -2501,8 +2622,8 @@ class ModelScanner:
})
# Merge every staged per-file batch into ONE undoable batch. On a
# merge failure (cross-volume EXDEV etc.) the response falls back
# to the constituent batch_ids array so the frontend can undo them
# merge failure (defensive) the response falls back to the
# constituent batch_ids array so the frontend can undo them
# sequentially.
batch_field: Dict[str, Any] = {}
if batch_ids:
+111 -84
View File
@@ -274,17 +274,21 @@ class PendingDeleteService:
async def merge_batches(self, batch_ids: Sequence[str]) -> Optional[str]:
"""Merge several batches into the first batch's manifest.
Winner is ``batch_ids[0]``. The staged files of losing batches are
MOVED (os.rename) into the winner's batch dir and their ``staged``
paths rewritten in the merged manifest BEFORE any loser dir is
removed. ``expires_at`` is re-anchored to ``now + TTL`` at merge time
and a FRESH purge timer is armed for the winner.
Winner is ``batch_ids[0]``. Merging is MANIFEST-ONLY: staged files
are NEVER moved, so the merge is a pure metadata operation with zero
data IO and is inherently cross-volume safe (no EXDEV, no rollback).
Every loser's entries are appended to the winner's manifest with
their ``staged`` paths unchanged (files keep living in the loser's
own batch dir - the sibling-of-model staging location), each loser
dir is recorded in the winner manifest's ``merged_sources``, and each
loser manifest is stamped ``merged_into`` so its own purge timer, a
post-restart sweep or a direct undo call no-op. ``expires_at`` is
re-anchored to ``now + TTL`` at merge time and a FRESH purge timer is
armed for the winner.
On any move failure every already-moved file is moved BACK and the
original batch dirs/manifests are left intact; ``None`` is returned so
callers fall back to the ``batch_ids`` array contract. Cross-volume
merges hit EXDEV here - expected and fine (the fallback is the normal
path for those bulks).
Returns the winner id, or ``None`` when the winner batch cannot be
resolved (callers then fall back to the ``batch_ids`` array
contract).
"""
if not batch_ids:
return None
@@ -298,77 +302,68 @@ class PendingDeleteService:
if winner_manifest is None:
return None
# Track (entry, original_staged_path, loser_dir) for rollback.
moved: List[Tuple[Dict[str, Any], str, str]] = []
processed_losers: List[Tuple[str, str]] = [] # (loser_id, loser_dir)
try:
for loser_id in batch_ids[1:]:
loser_dir = await self._find_batch_dir(loser_id)
if not loser_dir or os.path.normpath(loser_dir) == os.path.normpath(
winner_dir
):
# Build the merged manifest in memory: loser entries are appended
# with their staged paths UNCHANGED - no file moves, no IO, no
# EXDEV. Loser dirs remain as physical storage until the merged
# batch is undone or purged.
merged_sources: List[str] = []
seen_loser_dirs: Set[str] = set()
for loser_id in batch_ids[1:]:
loser_dir = await self._find_batch_dir(loser_id)
if not loser_dir or os.path.normpath(loser_dir) == os.path.normpath(
winner_dir
):
continue
loser_abs = os.path.abspath(loser_dir)
if loser_abs in seen_loser_dirs:
continue
seen_loser_dirs.add(loser_abs)
loser_manifest = self._read_manifest(loser_dir)
if loser_manifest is None:
# Corrupted loser: leave it for the sweep to quarantine.
continue
for entry in loser_manifest.get("entries") or []:
if entry.get("restored"):
continue
loser_manifest = self._read_manifest(loser_dir)
if loser_manifest is None:
# Corrupted loser: leave it for the sweep to quarantine.
staged_path = entry.get("staged")
if not staged_path or not os.path.exists(staged_path):
continue
for entry in loser_manifest.get("entries") or []:
if entry.get("restored"):
continue
staged_path = entry.get("staged")
if not staged_path or not os.path.exists(staged_path):
continue
new_staged = os.path.join(
winner_dir, os.path.basename(staged_path)
)
if os.path.exists(new_staged):
# os.rename would silently overwrite the existing
# staged file on POSIX - never drop a staged file.
# Abort the merge so callers fall back to the
# batch_ids array contract.
raise OSError(
f"Merge collision: {os.path.basename(staged_path)} "
"already staged in winner batch"
)
os.rename(staged_path, new_staged)
original_staged = entry["staged"]
entry["staged"] = os.path.abspath(new_staged)
winner_manifest["entries"].append(entry)
moved.append((entry, original_staged, loser_dir))
processed_losers.append((loser_id, loser_dir))
except OSError as exc:
logger.warning(
"Merge of %s failed after moving files: %s; rolling back",
list(batch_ids),
exc,
)
self._rollback_merge_moves(moved)
return None
winner_manifest["entries"].append(entry)
merged_sources.append(loser_abs)
# Re-anchor expiry and persist the merged manifest atomically.
# Re-anchor expiry and persist the merged manifest atomically - it
# becomes the ONLY source of truth for every merged file, wherever
# it physically lives.
winner_manifest["expires_at"] = (
int(time.time()) + PENDING_DELETE_TTL_SECONDS
)
if merged_sources:
winner_manifest["merged_sources"] = merged_sources
try:
self._write_manifest_atomic(winner_dir, winner_manifest)
except OSError as exc:
logger.warning(
"Failed to write merged manifest for %s: %s; rolling back",
"Failed to write merged manifest for %s: %s",
winner_id,
exc,
)
self._rollback_merge_moves(moved)
return None
# All moves committed: remove loser dirs (must be empty by now)
# and drop them from the registry. Skipped losers (missing /
# corrupted / same-dir) stay registered so the sweep still
# quarantines them, exactly as before the registry existed.
for loser_id, loser_dir in processed_losers:
self._remove_manifest(loser_dir)
self._remove_empty_dir(loser_dir)
await self._forget_batch(loser_id)
# Stamp each loser manifest so its own purge timer / a later sweep
# / a direct undo call no-op: the winner owns those files from
# here on. Best-effort coordination; a failed stamp only risks the
# loser being swept at its own (earlier) expiry after a restart.
for loser_dir in merged_sources:
try:
self._mark_merged(loser_dir, winner_id)
except OSError as exc: # pragma: no cover - best-effort
logger.warning(
"Failed to mark merged loser %s: %s", loser_dir, exc
)
# Losers are no longer independently managed.
for loser_dir in merged_sources:
await self._forget_batch(os.path.basename(loser_dir))
await self._remember_batch(winner_id, winner_dir)
# Arm a fresh purge timer for the winner with the re-anchored
@@ -397,6 +392,16 @@ class PendingDeleteService:
if manifest is None:
raise ValueError(f"Manifest missing for batch {batch_id}")
merged_into = manifest.get("merged_into")
if merged_into:
# The batch was merged into another batch: its staged files
# are owned by the winner's manifest. Undo via the winner so
# the whole merged batch stays consistent.
raise ValueError(
f"Batch {batch_id} was merged into batch {merged_into}; "
"undo that batch instead"
)
if manifest.get("state") == "restored":
return self._undo_result(manifest)
@@ -448,6 +453,10 @@ class PendingDeleteService:
self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir)
await self._forget_batch(batch_id)
# Clean up merged loser dirs (their staged files were restored
# above) and drop them from the registry too.
for loser_id in self._remove_merged_batch_dirs(manifest):
await self._forget_batch(loser_id)
logger.info("Restored pending-delete batch %s", batch_id)
return self._undo_result(manifest)
@@ -779,25 +788,35 @@ class PendingDeleteService:
"Failed to remove staged copy %s: %s", staged_path, exc
)
def _rollback_merge_moves(
self, moved: Sequence[Tuple[Dict[str, Any], str, str]]
) -> None:
"""Move already-merged files back to their original loser batch dirs."""
for _entry, original_staged, _loser_dir in reversed(list(moved)):
current = _entry.get("staged")
if not current or not original_staged:
def _mark_merged(self, loser_dir: str, winner_id: str) -> None:
"""Stamp ``merged_into`` on a loser manifest (best-effort).
The stamp makes the loser's own purge timer, post-restart sweeps and
direct undo calls no-op, so the winner's merged batch stays the only
owner of the loser's staged files until it is undone or purged.
"""
loser_manifest = self._read_manifest(loser_dir)
if loser_manifest is None:
return
loser_manifest["merged_into"] = winner_id
self._write_manifest_atomic(loser_dir, loser_manifest)
def _remove_merged_batch_dirs(self, manifest: Dict[str, Any]) -> List[str]:
"""Remove merged loser batch dirs once their files were handled.
Called after a merged batch has been fully undone or purged: each
loser manifest (stamped ``merged_into``) and its now-empty dir are
removed so the sweep never quarantines an orphaned staging dir.
Best-effort - returns the removed batch ids for registry cleanup.
"""
removed: List[str] = []
for src in manifest.get("merged_sources") or []:
if not isinstance(src, str) or not src:
continue
if not os.path.exists(current):
continue
try:
os.rename(current, original_staged)
except OSError as exc: # pragma: no cover - best-effort rollback
logger.warning(
"Failed to roll back merge move %s -> %s: %s",
current,
original_staged,
exc,
)
self._remove_manifest(src)
self._remove_empty_dir(src)
removed.append(os.path.basename(src))
return removed
def _purge_batch_dir(self, batch_dir: str) -> bool:
"""Purge one batch dir. Returns True when the batch was purged/removed."""
@@ -811,6 +830,13 @@ class PendingDeleteService:
self._quarantine_batch_dir(batch_dir)
return True
if manifest.get("merged_into"):
# Merged into another batch: the winner owns these staged files.
# The loser's own purge timer / post-restart sweep must not remove
# them early (the winner re-anchored the merged expiry to give the
# whole bulk one undo window).
return False
if manifest.get("state") == "restored":
return False
@@ -842,6 +868,7 @@ class PendingDeleteService:
self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir)
self._remove_merged_batch_dirs(manifest)
return True
def _quarantine_batch_dir(self, batch_dir: str) -> str:
+24 -5
View File
@@ -52,13 +52,13 @@ class PersistentRecipeCache:
"file_mtime",
"file_size",
"favorite",
"repair_version",
"preview_nsfw_level",
"loras_json",
"checkpoint_json",
"gen_params_json",
"tags_json",
"has_workflow",
"import_info_json",
)
_instances: Dict[str, "PersistentRecipeCache"] = {}
_instance_lock = threading.Lock()
@@ -441,13 +441,13 @@ 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,
gen_params_json TEXT,
tags_json TEXT,
has_workflow INTEGER DEFAULT 0
has_workflow INTEGER DEFAULT 0,
import_info_json TEXT
);
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
@@ -473,6 +473,13 @@ class PersistentRecipeCache:
)
except Exception:
pass # column already exists
# Migration: add import_info_json column to existing databases
try:
conn.execute(
"ALTER TABLE recipes ADD COLUMN import_info_json TEXT"
)
except Exception:
pass # column already exists
conn.commit()
self._schema_initialized = True
except Exception as exc:
@@ -504,6 +511,9 @@ class PersistentRecipeCache:
tags = recipe.get("tags")
tags_json = json.dumps(tags) if tags else None
import_info = recipe.get("import_info")
import_info_json = json.dumps(import_info) if import_info else None
# Get file stats if json_path exists
file_mtime = 0.0
file_size = 0
@@ -529,13 +539,13 @@ 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,
gen_params_json,
tags_json,
1 if recipe.get("has_workflow") else 0,
import_info_json,
)
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
@@ -568,6 +578,13 @@ class PersistentRecipeCache:
except json.JSONDecodeError:
pass
import_info = None
if row["import_info_json"]:
try:
import_info = json.loads(row["import_info_json"])
except json.JSONDecodeError:
pass
recipe = {
"id": row["recipe_id"],
"file_path": row["file_path"] or "",
@@ -579,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,
@@ -592,6 +608,9 @@ class PersistentRecipeCache:
if checkpoint:
recipe["checkpoint"] = checkpoint
if import_info:
recipe["import_info"] = import_info
return recipe
+213
View File
@@ -0,0 +1,213 @@
"""Process-wide, per-destination rate-limit gate for outbound API traffic.
Implements the pacing/gating layer designed in
``docs/plans/issue-1085-rate-limit-design.md``:
- **Reactive gate**: a 429 response arms ``next_allowed_send`` from the
vendor's ``Retry-After`` (or exponential backoff when the header is
missing); subsequent requests to the same destination wait out the window.
- **Preemptive pacing**: a minimum inter-request interval per destination
spaces consecutive sends so bursts never form in the first place.
- **Herd-free**: waiters are serialized through a per-destination lock, so
each one claims a distinct send slot instead of thousands of coroutines
waking up together.
- **Bounded**: waits longer than ``rate_limit_max_wait_seconds`` are refused
by raising :class:`RateLimitError`, leaving the final decision to callers.
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Dict, Optional
from .errors import RateLimitError
logger = logging.getLogger(__name__)
DEFAULT_MIN_INTERVAL_SECONDS = 0.75
DEFAULT_MAX_WAIT_SECONDS = 300.0
BASE_BACKOFF_SECONDS = 30.0
MAX_BACKOFF_SECONDS = 1800.0
@dataclass
class _DestinationState:
"""Rate-limit bookkeeping for one destination (hostname)."""
next_allowed_send: float = 0.0 # time.monotonic() timestamp
consecutive_429: int = 0
last_send_at: float = 0.0 # time.monotonic() timestamp
lock: asyncio.Lock = field(default_factory=asyncio.Lock)
class RateLimitCoordinator:
"""Coordinates outbound request pacing per destination.
Singleton mirroring :class:`ConnectivityGuard`'s pattern. All waits are
bounded by the ``rate_limit_max_wait_seconds`` setting; when the required
wait exceeds the cap, :meth:`wait_for_slot` raises :class:`RateLimitError`
instead of parking the caller.
"""
_instance: "RateLimitCoordinator | None" = None
_instance_lock = asyncio.Lock()
@classmethod
async def get_instance(cls) -> "RateLimitCoordinator":
async with cls._instance_lock:
if cls._instance is None:
cls._instance = cls()
return cls._instance
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
self._initialized = True
self._states: Dict[str, _DestinationState] = {}
# ------------------------------------------------------------------
# Settings (read live so settings edits apply without a restart)
@staticmethod
def _setting(key: str, default):
try:
from .settings_manager import get_settings_manager
return get_settings_manager().get(key, default)
except Exception: # pragma: no cover - defensive: settings unavailable
return default
@property
def enabled(self) -> bool:
return bool(self._setting("rate_limit_gate_enabled", True))
@property
def min_interval_seconds(self) -> float:
try:
return max(0.0, float(self._setting("rate_limit_min_interval_seconds", DEFAULT_MIN_INTERVAL_SECONDS)))
except (TypeError, ValueError):
return DEFAULT_MIN_INTERVAL_SECONDS
@property
def max_wait_seconds(self) -> float:
try:
return max(0.0, float(self._setting("rate_limit_max_wait_seconds", DEFAULT_MAX_WAIT_SECONDS)))
except (TypeError, ValueError):
return DEFAULT_MAX_WAIT_SECONDS
# ------------------------------------------------------------------
# State helpers
@staticmethod
def _normalize(destination: Optional[str]) -> str:
if destination is None or not destination.strip():
return "__global__"
return destination.lower().strip()
def _state_for(self, destination: Optional[str]) -> _DestinationState:
key = self._normalize(destination)
if key not in self._states:
self._states[key] = _DestinationState()
return self._states[key]
def reset(self) -> None:
"""Drop all per-destination state. Test seam."""
self._states.clear()
def in_cooldown(self, destination: Optional[str] = None) -> bool:
return self.remaining_seconds(destination) > 0
def remaining_seconds(self, destination: Optional[str] = None) -> float:
state = self._state_for(destination)
return max(0.0, state.next_allowed_send - time.monotonic())
# ------------------------------------------------------------------
# Gate operations
async def wait_for_slot(self, destination: Optional[str] = None) -> None:
"""Block until this caller may send the next request to *destination*.
Waits for both the rate-limit cooldown (``next_allowed_send``) and the
minimum inter-request interval (``last_send_at + min_interval``).
Waiters queue on the per-destination lock, so concurrent callers are
spaced out instead of stampeding when a cooldown expires.
Raises:
RateLimitError: when the required wait exceeds
``rate_limit_max_wait_seconds``.
"""
state = self._state_for(destination)
deadline = time.monotonic() + self.max_wait_seconds
async with state.lock:
now = time.monotonic()
wake_at = max(
state.next_allowed_send,
state.last_send_at + self.min_interval_seconds,
)
if wake_at > deadline:
raise RateLimitError(
f"Rate limit wait for '{self._normalize(destination)}' "
f"exceeds the {self.max_wait_seconds:.0f}s cap",
retry_after=wake_at - now,
)
delay = wake_at - now
if delay > 0:
logger.debug(
"Rate-limit gate: pacing request to '%s' by %.2fs",
self._normalize(destination),
delay,
)
await asyncio.sleep(delay)
state.last_send_at = time.monotonic()
def register_rate_limit(
self,
destination: Optional[str],
retry_after: Optional[float] = None,
) -> float:
"""Record a 429 for *destination* and arm the cooldown window.
Honors the vendor's ``Retry-After`` when present; otherwise grows an
exponential backoff (30s base, doubling per consecutive 429, capped at
1800s). Returns the cooldown duration in seconds.
"""
state = self._state_for(destination)
state.consecutive_429 += 1
if retry_after is not None and retry_after > 0:
backoff = min(MAX_BACKOFF_SECONDS, float(retry_after))
else:
backoff = min(
MAX_BACKOFF_SECONDS,
BASE_BACKOFF_SECONDS * (2 ** (state.consecutive_429 - 1)),
)
now = time.monotonic()
already_cooling = state.next_allowed_send > now
state.next_allowed_send = max(state.next_allowed_send, now + backoff)
if already_cooling:
logger.debug(
"Rate-limit cooldown for '%s' extended by %.0fs (consecutive_429=%d)",
self._normalize(destination),
backoff,
state.consecutive_429,
)
else:
logger.info(
"Rate limited by '%s'; pausing requests for %.0fs",
self._normalize(destination),
backoff,
)
return backoff
def register_success(self, destination: Optional[str]) -> None:
"""Reset rate-limit state after a successful request.
A 200 proves the vendor is accepting traffic again, so any armed
cooldown window is cleared alongside the backoff counter (mirrors
``ConnectivityGuard.register_success`` semantics).
"""
state = self._state_for(destination)
state.consecutive_429 = 0
state.next_allowed_send = 0.0
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,6 +1,7 @@
"""Recipe service layer implementations."""
from .analysis_service import RecipeAnalysisService
from .import_info import build_import_info, compute_no_loras_reason
from .persistence_service import RecipePersistenceService
from .sharing_service import RecipeSharingService
from .errors import (
@@ -15,6 +16,8 @@ __all__ = [
"RecipeAnalysisService",
"RecipePersistenceService",
"RecipeSharingService",
"build_import_info",
"compute_no_loras_reason",
"RecipeServiceError",
"RecipeValidationError",
"RecipeNotFoundError",
+86 -4
View File
@@ -72,15 +72,28 @@ class RecipeAnalysisService:
metadata = self._exif_utils.extract_image_metadata(temp_path)
if not metadata:
return AnalysisResult(
{"error": "No metadata found in this image", "loras": []}
{
"error": "No metadata found in this image",
"loras": [],
"diagnostics": {
"channel": "upload",
"exif_present": False,
},
}
)
return await self._parse_metadata(
result = await self._parse_metadata(
metadata,
recipe_scanner=recipe_scanner,
image_path=None,
include_image_base64=False,
)
result.payload["diagnostics"] = {
"channel": "upload",
"exif_present": True,
"exif_parser": result.payload.get("parser"),
}
return result
finally:
self._safe_cleanup(temp_path)
@@ -104,9 +117,13 @@ class RecipeAnalysisService:
image_info: Optional[dict[str, Any]] = None
is_video = False
extension = ".jpg" # Default
# Diagnostics collected during analysis; surfaced in the payload so
# callers can persist an import_info block explaining empty LoRA lists.
diagnostics: dict[str, Any] = {"channel": "url"}
try:
civitai_image_id = extract_civitai_image_id(url)
diagnostics["civitai_image"] = bool(civitai_image_id)
if civitai_image_id:
image_info = await civitai_client.get_image_info(
civitai_image_id, source_url=url
@@ -147,11 +164,23 @@ class RecipeAnalysisService:
):
metadata = metadata["meta"]
# Diagnostics: capture the API meta shape before injecting
# modelVersionIds / browsingLevel so the recipe modal can
# explain why an import ended up without LoRAs.
diagnostics["api_meta_present"] = isinstance(metadata, dict)
if isinstance(metadata, dict):
diagnostics["api_meta_keys"] = sorted(metadata.keys())
# Include modelVersionIds from root level if available.
# CivitAI API returns modelVersionIds at root level, not in meta.
# When meta is null (None), create a minimal dict so downstream
# parsers can still discover LoRAs and checkpoints.
model_version_ids = image_info.get("modelVersionIds")
diagnostics["api_model_version_ids"] = (
len(model_version_ids)
if isinstance(model_version_ids, list)
else 0
)
if model_version_ids:
if isinstance(metadata, dict):
metadata["modelVersionIds"] = model_version_ids
@@ -229,6 +258,8 @@ class RecipeAnalysisService:
finally:
self._safe_cleanup(orig_temp_path)
diagnostics["exif_present"] = bool(exif_metadata)
# Parse EXIF data (typically a string like parameters/prompt/workflow)
# and API metadata (dict with modelVersionIds, browsingLevel) separately,
# then merge: API loras/checkpoint override, EXIF gen_params fill in gaps.
@@ -237,6 +268,7 @@ class RecipeAnalysisService:
if isinstance(exif_metadata, str):
exif_parser = self._recipe_parser_factory.create_parser(exif_metadata)
if exif_parser:
diagnostics["exif_parser"] = exif_parser.__class__.__name__
exif_data = await exif_parser.parse_metadata(
exif_metadata, recipe_scanner=recipe_scanner,
)
@@ -270,6 +302,22 @@ class RecipeAnalysisService:
if merged_gp:
result.payload["gen_params"] = merged_gp
# The API-only parse (meta=null with only modelVersionIds)
# yields a checkpoint but no LoRAs; the image EXIF carries the
# full resource list. Fill the gaps the API parse left open.
if not result.payload.get("loras"):
exif_loras = exif_parsed_result.get("loras") or []
if exif_loras:
result.payload["loras"] = exif_loras
if not result.payload.get("checkpoint") and not result.payload.get("model"):
exif_checkpoint = exif_parsed_result.get("model") or exif_parsed_result.get(
"checkpoint"
)
if exif_checkpoint:
result.payload["checkpoint"] = exif_checkpoint
if not result.payload.get("base_model") and exif_parsed_result.get("base_model"):
result.payload["base_model"] = exif_parsed_result["base_model"]
if civitai_image_id and image_info and not result.payload.get("error"):
# Use the metadata dict we built (may contain modelVersionIds
# and browsingLevel from the API root level). Do NOT pass
@@ -308,6 +356,8 @@ class RecipeAnalysisService:
if isinstance(bl, int) and bl > 0:
result.payload["preview_nsfw_level"] = bl
diagnostics["is_video"] = is_video
result.payload["diagnostics"] = diagnostics
return result
finally:
if temp_path:
@@ -318,6 +368,7 @@ class RecipeAnalysisService:
*,
file_path: str | None,
recipe_scanner,
ignore_recipe_metadata: bool = False,
) -> AnalysisResult:
"""Analyze a file already present on disk."""
@@ -332,14 +383,41 @@ class RecipeAnalysisService:
self._exif_utils.extract_image_metadata, normalized_path
)
if not metadata:
return self._metadata_not_found_response(normalized_path)
result = self._metadata_not_found_response(normalized_path)
result.payload["diagnostics"] = {
"channel": "local",
"exif_present": False,
}
return result
return await self._parse_metadata(
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,
image_path=normalized_path,
include_image_base64=True,
)
result.payload["diagnostics"] = {
"channel": "local",
"exif_present": True,
"exif_parser": result.payload.get("parser"),
}
return result
async def analyze_widget_metadata(self, *, recipe_scanner) -> AnalysisResult:
"""Analyse the most recent generation metadata for widget saves."""
@@ -436,6 +514,10 @@ class RecipeAnalysisService:
metadata, recipe_scanner=recipe_scanner
)
# Record which parser handled the metadata so import diagnostics
# can distinguish e.g. ComfyUI workflow sources.
result["parser"] = parser.__class__.__name__
if include_image_base64 and image_path:
result["image_base64"] = self._encode_file(image_path)
+129
View File
@@ -0,0 +1,129 @@
"""Import provenance helpers for recipes.
Builds the ``import_info`` block persisted on a recipe: the import channel
(batch import / single URL / local file / upload / widget) and, when the
recipe ended up with no LoRAs, a machine-readable reason plus the diagnostic
details that led to it. The recipe modal renders this block in a collapsed
"Why no LoRAs?" panel; legacy recipes without ``import_info`` fall back to a
frontend heuristic.
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
# Import channels (how the recipe entered the library).
CHANNEL_BATCH_IMPORT_URL = "batch_import_url"
CHANNEL_BATCH_IMPORT_LOCAL = "batch_import_local"
CHANNEL_URL = "url"
CHANNEL_LOCAL = "local"
CHANNEL_UPLOAD = "upload"
CHANNEL_WIDGET = "widget"
CHANNEL_REIMPORT_URL = "reimport_url"
CHANNEL_REIMPORT_LOCAL = "reimport_local"
_URL_CHANNELS = frozenset(
{CHANNEL_BATCH_IMPORT_URL, CHANNEL_URL, CHANNEL_REIMPORT_URL}
)
# No-LoRA reason codes (persisted, consumed by the recipe modal).
REASON_NO_LORAS_USED = "no_loras_used"
REASON_API_NO_LORA_RESOURCES = "api_meta_no_lora_resources"
REASON_API_META_MISSING = "api_meta_missing"
REASON_NO_EMBEDDED_METADATA = "no_embedded_metadata"
REASON_WORKFLOW_METADATA_LIMITED = "workflow_metadata_limited"
REASON_VIDEO_NO_METADATA = "video_no_metadata"
REASON_METADATA_UNSUPPORTED = "metadata_unsupported"
REASON_UNKNOWN = "unknown"
_COMFY_PARSER_NAME = "ComfyMetadataParser"
# Cap for api_meta_keys kept in details — enough for the UI bullet without
# bloating the recipe JSON.
_MAX_DETAIL_KEYS = 12
def compute_no_loras_reason(
channel: str, diagnostics: Optional[Dict[str, Any]]
) -> str:
"""Classify why an import produced no LoRA entries.
Args:
channel: One of the CHANNEL_* constants.
diagnostics: Signals collected during analysis (see
``RecipeAnalysisService``), or None for channels without analysis
(e.g. widget saves).
"""
diag = diagnostics or {}
if diag.get("is_video"):
return REASON_VIDEO_NO_METADATA
# Embedded metadata that is a ComfyUI workflow: LoRA extraction from
# workflows is limited, so report that specifically.
parser = diag.get("exif_parser") or diag.get("parser")
if parser == _COMFY_PARSER_NAME:
return REASON_WORKFLOW_METADATA_LIMITED
if channel in _URL_CHANNELS:
if not diag.get("civitai_image"):
# Generic (non-CivitAI) URL: only embedded metadata is available.
if not diag.get("exif_present"):
return REASON_NO_EMBEDDED_METADATA
return (
REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
)
# NOTE: no "parsed EXIF means no LoRAs were used" shortcut here.
# CivitAI's onsite generator writes A1111-style EXIF (prompt, seed,
# steps, ...) WITHOUT LoRA references — LoRA usage lives only in
# CivitAI-internal data — so cleanly parsed EXIF cannot prove the
# generation used no LoRAs. Report the API meta shape instead.
api_keys = diag.get("api_meta_keys") or []
api_mvids = diag.get("api_model_version_ids") or 0
if api_keys or api_mvids:
return REASON_API_NO_LORA_RESOURCES
return REASON_API_META_MISSING
if channel == CHANNEL_WIDGET:
return REASON_NO_LORAS_USED
# Local file / upload / local re-import: embedded metadata only.
if not diag.get("exif_present"):
return REASON_NO_EMBEDDED_METADATA
return REASON_NO_LORAS_USED if parser else REASON_METADATA_UNSUPPORTED
def build_import_info(
channel: str,
diagnostics: Optional[Dict[str, Any]],
loras: Optional[List[Dict[str, Any]]],
) -> Dict[str, Any]:
"""Build the ``import_info`` block persisted on a recipe.
Always records the import channel; adds ``reason`` and ``details`` only
when the recipe has no LoRAs.
"""
info: Dict[str, Any] = {"channel": channel}
if loras:
return info
info["reason"] = compute_no_loras_reason(channel, diagnostics)
diag = diagnostics or {}
details: Dict[str, Any] = {}
api_keys = diag.get("api_meta_keys")
if api_keys:
details["api_meta_keys"] = list(api_keys)[:_MAX_DETAIL_KEYS]
api_mvids = diag.get("api_model_version_ids")
if api_mvids is not None:
details["api_model_version_ids"] = api_mvids
if "exif_present" in diag:
details["exif_present"] = bool(diag.get("exif_present"))
if diag.get("exif_parser"):
details["exif_parser"] = diag["exif_parser"]
if diag.get("is_video"):
details["is_video"] = True
if details:
info["details"] = details
return info
+328 -16
View File
@@ -13,9 +13,15 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast
from ...config import config
from ...recipes.constants import GEN_PARAM_KEYS
from ...utils.base_model import (
RELATION_COMPATIBLE,
RELATION_INCOMPATIBLE,
base_model_relation,
)
from ...utils.utils import calculate_recipe_fingerprint
from ..pending_delete_service import get_pending_delete_service
from .errors import RecipeNotFoundError, RecipeValidationError
from .import_info import CHANNEL_UPLOAD, CHANNEL_WIDGET, build_import_info
@dataclass(frozen=True)
@@ -52,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.
@@ -61,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 = []
@@ -81,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:
@@ -129,6 +144,22 @@ class RecipePersistenceService:
if metadata.get("source_path"):
recipe_data["source_path"] = metadata.get("source_path")
# Persist import provenance. Batch import / re-import paths pass a
# prebuilt import_info; frontend-driven saves (upload, single URL,
# local path) carry the analysis payload's diagnostics, from which
# import_info is derived here.
import_info = metadata.get("import_info")
if not isinstance(import_info, dict):
diagnostics = metadata.get("diagnostics")
if isinstance(diagnostics, dict):
import_info = build_import_info(
diagnostics.get("channel") or CHANNEL_UPLOAD,
diagnostics,
loras_data,
)
if isinstance(import_info, dict) and import_info:
recipe_data["import_info"] = import_info
nsfw_level = metadata.get("preview_nsfw_level")
if nsfw_level is not None and isinstance(nsfw_level, int):
recipe_data["preview_nsfw_level"] = nsfw_level
@@ -153,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)
@@ -430,20 +465,31 @@ class RecipePersistenceService:
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_base_model = json.load(file_obj).get("base_model", "")
target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model)
if not target_lora:
matches = await recipe_scanner.find_local_loras_by_name(target_name)
if len(matches) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
if len(matches) == 1:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
matches = await recipe_scanner.find_local_loras_by_name(target_name)
if not matches:
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
# Three-tier base-model guard: exact/unknown labels pass silently;
# labels from the same architecture family (e.g. Pony ↔ Illustrious)
# pass but are reported so the UI can warn; confident architecture
# mismatches stay hard-rejected because they can never load.
eligible: list[tuple[dict, str]] = []
for match in matches:
relation = base_model_relation(recipe_base_model, match.get("base_model"))
if relation != RELATION_INCOMPATIBLE:
eligible.append((match, relation))
if not eligible:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
if len(eligible) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
target_lora, target_relation = eligible[0]
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
recipe_id,
lora_index,
@@ -451,6 +497,43 @@ class RecipePersistenceService:
target_lora=target_lora,
)
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
matching_recipes = []
if "fingerprint" in recipe_data:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(recipe_data["fingerprint"])
if recipe_id in matching_recipes:
matching_recipes.remove(recipe_id)
payload: dict[str, Any] = {
"success": True,
"recipe_id": recipe_id,
"updated_lora": updated_lora,
"matching_recipes": matching_recipes,
}
if target_relation == RELATION_COMPATIBLE:
# Structured data, not prose — the frontend localizes the warning.
payload["base_model_mismatch"] = {
"recipe_base_model": recipe_base_model,
"lora_base_model": target_lora.get("base_model") or "",
}
return PersistenceResult(payload)
async def restore_lora(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
) -> PersistenceResult:
"""Restore a LoRA entry to the state captured before its reconnect."""
recipe_data, updated_lora = await recipe_scanner.restore_lora_entry(
recipe_id, lora_index
)
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
@@ -470,6 +553,231 @@ class RecipePersistenceService:
}
)
async def get_reconnect_suggestions(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
query: str | None = None,
) -> PersistenceResult:
"""Return ranked local LoRA candidates for reconnecting a recipe entry."""
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
loras = recipe_data.get("loras") or []
if lora_index < 0 or lora_index >= len(loras):
raise RecipeValidationError(f"Invalid lora_index: {lora_index}")
suggestions = await recipe_scanner.suggest_reconnect_candidates(
entry=loras[lora_index],
recipe_base_model=recipe_data.get("base_model"),
query=query,
)
return PersistenceResult({"success": True, "suggestions": suggestions})
async def mark_lora_hash_invalid(
self,
*,
recipe_scanner,
recipe_id: str,
lora_index: int,
hash_invalid: bool = True,
) -> PersistenceResult:
"""Mark a recipe LoRA entry's hash as unresolvable on CivitAI.
Called when a download attempt by hash returned "Model not found".
The flag makes the entry an unresolved rematch candidate without
altering its stored hash/file_name.
"""
recipe_data, updated_lora = await recipe_scanner.set_lora_entry_hash_invalid(
recipe_id,
lora_index,
hash_invalid=hash_invalid,
)
return PersistenceResult(
{
"success": True,
"recipe_id": recipe_id,
"hash_invalid": bool(hash_invalid),
"updated_lora": updated_lora,
}
)
async def reconnect_checkpoint(
self,
*,
recipe_scanner,
recipe_id: str,
target_name: str,
) -> PersistenceResult:
"""Reconnect the checkpoint entry within an existing recipe."""
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_base_model = json.load(file_obj).get("base_model", "")
matches = await recipe_scanner.find_local_checkpoints_by_name(target_name)
if not matches:
raise RecipeNotFoundError(
f"Local checkpoint not found with name: {target_name}"
)
# Same three-tier base-model guard as reconnect_lora: exact/unknown
# labels pass silently; same-architecture-family labels pass but are
# reported so the UI can warn; confident mismatches stay hard-rejected.
eligible: list[tuple[dict, str]] = []
for match in matches:
relation = base_model_relation(recipe_base_model, match.get("base_model"))
if relation != RELATION_INCOMPATIBLE:
eligible.append((match, relation))
if not eligible:
raise RecipeValidationError(
f"Local checkpoint '{target_name}' has a different base model "
"than the recipe"
)
if len(eligible) > 1:
raise RecipeValidationError(
f"Multiple local checkpoints match '{target_name}'; "
"include the folder path to disambiguate"
)
target_checkpoint, target_relation = eligible[0]
recipe_data, updated_checkpoint = await recipe_scanner.update_checkpoint_entry(
recipe_id,
target_name=target_name,
target_checkpoint=target_checkpoint,
)
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
matching_recipes = []
if "fingerprint" in recipe_data:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
recipe_data["fingerprint"]
)
if recipe_id in matching_recipes:
matching_recipes.remove(recipe_id)
payload: dict[str, Any] = {
"success": True,
"recipe_id": recipe_id,
"updated_checkpoint": updated_checkpoint,
"matching_recipes": matching_recipes,
}
if target_relation == RELATION_COMPATIBLE:
# Structured data, not prose — the frontend localizes the warning.
payload["base_model_mismatch"] = {
"recipe_base_model": recipe_base_model,
"checkpoint_base_model": target_checkpoint.get("base_model") or "",
}
return PersistenceResult(payload)
async def restore_checkpoint(
self,
*,
recipe_scanner,
recipe_id: str,
) -> PersistenceResult:
"""Restore the checkpoint entry to the state captured before its reconnect."""
recipe_data, updated_checkpoint = await recipe_scanner.restore_checkpoint_entry(
recipe_id
)
image_path = recipe_data.get("file_path")
if image_path and os.path.exists(image_path):
self._exif_utils.append_recipe_metadata(image_path, recipe_data)
matching_recipes = []
if "fingerprint" in recipe_data:
matching_recipes = await recipe_scanner.find_recipes_by_fingerprint(
recipe_data["fingerprint"]
)
if recipe_id in matching_recipes:
matching_recipes.remove(recipe_id)
return PersistenceResult(
{
"success": True,
"recipe_id": recipe_id,
"updated_checkpoint": updated_checkpoint,
"matching_recipes": matching_recipes,
}
)
async def get_checkpoint_reconnect_suggestions(
self,
*,
recipe_scanner,
recipe_id: str,
query: str | None = None,
) -> PersistenceResult:
"""Return ranked local checkpoint candidates for reconnecting a recipe entry."""
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_data = json.load(file_obj)
checkpoint = recipe_data.get("checkpoint")
if not isinstance(checkpoint, dict):
raise RecipeValidationError("Recipe has no checkpoint entry")
suggestions = await recipe_scanner.suggest_checkpoint_reconnect_candidates(
entry=checkpoint,
recipe_base_model=recipe_data.get("base_model"),
query=query,
)
return PersistenceResult({"success": True, "suggestions": suggestions})
async def mark_checkpoint_hash_invalid(
self,
*,
recipe_scanner,
recipe_id: str,
hash_invalid: bool = True,
) -> PersistenceResult:
"""Mark the recipe checkpoint entry's hash as unresolvable on CivitAI.
Called when a download attempt by hash returned "Model not found".
The flag makes the entry an unresolved rematch candidate without
altering its stored hash/file_name.
"""
recipe_data, updated_checkpoint = (
await recipe_scanner.set_checkpoint_entry_hash_invalid(
recipe_id,
hash_invalid=hash_invalid,
)
)
return PersistenceResult(
{
"success": True,
"recipe_id": recipe_id,
"hash_invalid": bool(hash_invalid),
"updated_checkpoint": updated_checkpoint,
}
)
async def bulk_delete(
self,
*,
@@ -533,7 +841,7 @@ class RecipePersistenceService:
# Merge succeeded: one undo action covers the whole bulk.
payload["batch_id"] = merged_batch_id
else:
# Merge failure (e.g. cross-volume move): expose the constituent
# Merge unresolvable (defensive): expose the constituent
# batches so the caller can undo them one at a time.
payload["batch_ids"] = batch_ids
else:
@@ -619,6 +927,9 @@ class RecipePersistenceService:
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
# embedded metadata chunks, so a workflow can never be present.
"has_workflow": False,
# Widget saves read LoRAs straight from the current workflow; an
# empty list means the workflow used no LoRAs.
"import_info": build_import_info(CHANNEL_WIDGET, None, loras_data),
}
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
@@ -793,6 +1104,7 @@ class RecipePersistenceService:
"modelName": lora.get("name", ""),
"modelVersionName": lora.get("version", ""),
"isDeleted": lora.get("isDeleted", False),
"hashInvalid": lora.get("hashInvalid", False),
"exclude": lora.get("exclude", False),
}
+19 -1
View File
@@ -34,6 +34,8 @@ from ..utils.settings_paths import (
APP_NAME,
ensure_settings_file,
get_legacy_settings_path,
get_settings_dir_override,
is_settings_dir_pinned,
)
from ..utils.tag_priorities import (
PriorityTagEntry,
@@ -68,6 +70,9 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"enable_metadata_archive_db": False,
"enable_civarchive_api": True,
"metadata_provider_order": "civitai_archive_sqlite",
"rate_limit_gate_enabled": True,
"rate_limit_max_wait_seconds": 300,
"rate_limit_min_interval_seconds": 0.75,
"proxy_enabled": False,
"proxy_host": "",
"proxy_port": "",
@@ -111,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": "",
@@ -156,7 +162,10 @@ class SettingsManager:
self._check_environment_variables()
self._collect_configuration_warnings()
if os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1":
if (
os.environ.get("LORA_MANAGER_PORTABLE", "0") == "1"
and not is_settings_dir_pinned()
):
if not self.settings.get("use_portable_settings"):
self.settings["use_portable_settings"] = True
self._save_settings()
@@ -1641,6 +1650,15 @@ class SettingsManager:
def _prepare_portable_switch(self, use_portable: bool) -> None:
"""Prepare switching the settings storage location."""
if is_settings_dir_pinned():
logger.info(
"Portable-mode switch ignored: settings directory is pinned via "
"%s/--settings-path (%s)",
"LORA_MANAGER_SETTINGS_DIR",
get_settings_dir_override(),
)
return
legacy_path = get_legacy_settings_path()
user_dir = self._get_user_config_directory()
user_settings_path = os.path.join(user_dir, "settings.json")
-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
+79
View File
@@ -0,0 +1,79 @@
"""Base-model architecture families and compatibility relations.
CivitAI base-model labels describe fine-tune lineages, not architectures.
A LoRA physically loads on any checkpoint sharing its tensor architecture,
so e.g. Pony / Illustrious / NoobAI / SDXL 1.0 LoRAs are interchangeable
(quality varies, but nothing breaks). Different architectures (SD 1.5 vs
SDXL vs Flux) are guaranteed failures and must stay hard-rejected.
Only families with high-confidence architecture equivalence are listed.
Anything not in the table is treated as its own family, i.e. only an exact
label match is accepted unknown new labels never get wrongly waved through.
"""
from __future__ import annotations
from typing import Optional
# Normalized (casefolded, stripped) base-model label -> architecture family.
_BASE_MODEL_FAMILIES = {
# SD 1.x — all share the original 512px latent UNet.
"sd 1.4": "sd1",
"sd 1.5": "sd1",
"sd 1.5 lcm": "sd1",
"sd 1.5 hyper": "sd1",
# SDXL lineage — Pony / Illustrious / NoobAI are SDXL fine-tunes.
# Note: Pony V7 is AuraFlow-based, NOT SDXL, so it is deliberately absent.
"sdxl 1.0": "sdxl",
"sdxl lightning": "sdxl",
"sdxl hyper": "sdxl",
"pony": "sdxl",
"pony diffusion": "sdxl",
"pony diffusion v6 xl": "sdxl",
"illustrious": "sdxl",
"illustrious 0.1": "sdxl",
"illustrious 1.0": "sdxl",
"illustrious 1.1": "sdxl",
"noobai": "sdxl",
# Flux.1 — dev/schnell/Krea share the 12B rectified-flow transformer.
"flux.1 d": "flux1",
"flux.1 s": "flux1",
"flux.1 krea": "flux1",
# SD 3.5 Large and its Turbo distill share the 8B MMDiT. SD 3 (2B) and
# SD 3.5 Medium (2.5B) have different shapes and stay unlisted.
"sd 3.5 large": "sd35-large",
"sd 3.5 large turbo": "sd35-large",
}
_UNKNOWN_TOKENS = {"", "unknown", "other", "none", "null"}
# Relation constants returned by base_model_relation().
RELATION_UNKNOWN = "unknown" # at least one side has no usable label
RELATION_SAME = "same" # identical labels
RELATION_COMPATIBLE = "compatible" # different labels, same architecture family
RELATION_INCOMPATIBLE = "incompatible" # different labels, different/unknown family
def _normalize(label: Optional[str]) -> str:
return (label or "").strip().casefold()
def base_model_relation(a: Optional[str], b: Optional[str]) -> str:
"""Classify how two base-model labels relate for reconnect purposes.
``RELATION_UNKNOWN`` when either side has no usable label (callers treat
it as lenient-allow), ``RELATION_SAME`` for identical labels,
``RELATION_COMPATIBLE`` when both labels map to the same architecture
family, and ``RELATION_INCOMPATIBLE`` otherwise including when a label
is missing from the family table (conservative fallback).
"""
na, nb = _normalize(a), _normalize(b)
if na in _UNKNOWN_TOKENS or nb in _UNKNOWN_TOKENS:
return RELATION_UNKNOWN
if na == nb:
return RELATION_SAME
fa = _BASE_MODEL_FAMILIES.get(na)
fb = _BASE_MODEL_FAMILIES.get(nb)
if fa is not None and fa == fb:
return RELATION_COMPATIBLE
return RELATION_INCOMPATIBLE
+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"""
+81 -7
View File
@@ -13,6 +13,15 @@ from platformdirs import user_config_dir
APP_NAME = "ComfyUI-LoRA-Manager"
_LM_PORTABLE_ENV = "LORA_MANAGER_PORTABLE"
# Explicit settings-directory override. Setting this (env var, or standalone's
# ``--settings-path`` which publishes it) pins the settings location: settings.json,
# cache/, wildcards/, backups/, logs/, stats/ all resolve under this directory,
# bypassing portable mode and the platform user config dir. Useful for sandboxed
# development/E2E runs that must not touch the real user data or the project root.
SETTINGS_DIR_ENV = "LORA_MANAGER_SETTINGS_DIR"
_settings_dir_override: Optional[str] = None
_LOGGER = logging.getLogger(__name__)
@@ -22,6 +31,51 @@ def get_project_root() -> str:
return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
def _normalize_settings_dir(path: str) -> str:
"""Expand ``~`` and absolutize a user-supplied settings directory."""
return os.path.abspath(os.path.expanduser(path))
def set_settings_dir_override(path: Optional[str]) -> Optional[str]:
"""Set or clear the programmatic settings-directory override.
Args:
path: Absolute/relative directory to pin, or ``None`` to clear the
override. ``~`` is expanded and the path absolutized.
Returns:
The previous override value (``None`` when none was active).
"""
global _settings_dir_override
previous = _settings_dir_override
_settings_dir_override = (
_normalize_settings_dir(path) if path else None
)
return previous
def get_settings_dir_override() -> Optional[str]:
"""Return the active explicit settings-directory override, if any.
The ``LORA_MANAGER_SETTINGS_DIR`` environment variable takes precedence over
the programmatic override so that standalone's ``--settings-path`` (which
publishes itself through the environment) wins over embedded callers.
"""
env_path = os.environ.get(SETTINGS_DIR_ENV)
if env_path:
return _normalize_settings_dir(env_path)
return _settings_dir_override
def is_settings_dir_pinned() -> bool:
"""Return ``True`` when an explicit settings-directory override is active."""
return get_settings_dir_override() is not None
def get_legacy_settings_path() -> str:
"""Return the legacy location of ``settings.json`` within the project tree."""
@@ -31,6 +85,11 @@ def get_legacy_settings_path() -> str:
def get_settings_dir(create: bool = True) -> str:
"""Return the user configuration directory for the application.
An explicit override (``LORA_MANAGER_SETTINGS_DIR`` or
:func:`set_settings_dir_override`) takes precedence. Otherwise the portable
project-root ``settings.json`` is used when enabled, falling back to the
platform-specific user configuration directory.
Args:
create: Whether to create the directory if it does not already exist.
@@ -38,11 +97,15 @@ def get_settings_dir(create: bool = True) -> str:
The absolute path to the user configuration directory.
"""
legacy_path = get_legacy_settings_path()
if _should_use_portable_settings(legacy_path, _LOGGER):
config_dir = os.path.dirname(legacy_path)
override = get_settings_dir_override()
if override:
config_dir = override
else:
config_dir = user_config_dir(APP_NAME, appauthor=False)
legacy_path = get_legacy_settings_path()
if _should_use_portable_settings(legacy_path, _LOGGER):
config_dir = os.path.dirname(legacy_path)
else:
config_dir = user_config_dir(APP_NAME, appauthor=False)
if create and config_dir:
os.makedirs(config_dir, exist_ok=True)
@@ -58,9 +121,14 @@ def get_settings_file_path(create_dir: bool = True) -> str:
def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
"""Ensure the settings file resides in the user configuration directory.
If a legacy ``settings.json`` is detected in the project root it is migrated to
the platform-specific user configuration folder. The caller receives the path
to the settings file irrespective of whether a migration was needed.
An explicit override (``LORA_MANAGER_SETTINGS_DIR`` or
:func:`set_settings_dir_override`) pins the settings file to
``<override>/settings.json`` and skips legacy migration entirely.
Otherwise, if a legacy ``settings.json`` is detected in the project root it is
migrated to the platform-specific user configuration folder. The caller
receives the path to the settings file irrespective of whether a migration was
needed.
Args:
logger: Optional logger used for migration messages. Falls back to a
@@ -71,6 +139,12 @@ def ensure_settings_file(logger: Optional[logging.Logger] = None) -> str:
"""
logger = logger or _LOGGER
override = get_settings_dir_override()
if override:
os.makedirs(override, exist_ok=True)
return os.path.join(override, "settings.json")
legacy_path = get_legacy_settings_path()
if _should_use_portable_settings(legacy_path, logger):
+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",
@@ -211,6 +211,17 @@ def main() -> int:
help="Launch the server fully detached (setsid-style) so it survives shell "
"death. REQUIRED for E2E: a plain background process dies with the shell",
)
parser.add_argument(
"--settings-path",
type=str,
default=None,
metavar="DIR",
help="Explicit settings directory passed to standalone.py (--settings-path, "
"equivalent to LORA_MANAGER_SETTINGS_DIR). settings.json, cache/, "
"wildcards/, backups/, logs/, stats/ all live under this directory instead "
"of the project root or the user config dir. Recommended for sandboxed E2E "
"so the real instance and the repo stay untouched",
)
args = parser.parse_args()
@@ -283,6 +294,16 @@ def main() -> int:
"--port",
str(args.port),
]
if args.settings_path:
settings_dir = os.path.abspath(os.path.expanduser(args.settings_path))
if os.path.exists(settings_dir) and not os.path.isdir(settings_dir):
print(
f"ERROR: --settings-path '{settings_dir}' exists but is not a directory."
)
return 2
os.makedirs(settings_dir, exist_ok=True)
cmd.extend(["--settings-path", settings_dir])
print(f"Settings directory: {settings_dir}")
if args.detach:
# Fully detached launch: new session (setsid), no controlling terminal,
+47 -1
View File
@@ -8,12 +8,36 @@ from typing import Any, cast
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from py.middleware.cache_middleware import cache_control
from py.middleware.error_middleware import api_json_error
from py.utils.settings_paths import ensure_settings_file
from py.utils.settings_paths import SETTINGS_DIR_ENV, ensure_settings_file
# Set environment variable to indicate standalone mode
os.environ["LORA_MANAGER_STANDALONE"] = "1"
def _apply_settings_dir_from_argv(argv=None):
"""Apply ``--settings-path`` from argv before any settings resolution runs.
Standalone resolves the settings location at import time (session logging and
the settings manager run before ``main()`` parses arguments), so pre-scan
argv and publish the explicit directory through ``LORA_MANAGER_SETTINGS_DIR``,
which ``py.utils.settings_paths`` honors in both standalone and plugin modes.
Args:
argv: Argument list to scan; defaults to ``sys.argv[1:]``.
"""
args = list(sys.argv[1:] if argv is None else argv)
for index, arg in enumerate(args):
if arg == "--settings-path" and index + 1 < len(args):
os.environ[SETTINGS_DIR_ENV] = args[index + 1]
return
if arg.startswith("--settings-path="):
os.environ[SETTINGS_DIR_ENV] = arg.split("=", 1)[1]
return
_apply_settings_dir_from_argv()
# Create mock modules for py/nodes directory - add this before any other imports
def mock_nodes_directory():
"""Create mock modules for all Python files in the py/nodes directory"""
@@ -395,6 +419,16 @@ def parse_args():
# help="Additional paths to LoRA model directories (optional if settings.json has paths)")
# parser.add_argument("--checkpoints", type=str, nargs="+",
# help="Additional paths to checkpoint model directories (optional if settings.json has paths)")
parser.add_argument(
"--settings-path",
type=str,
default=None,
metavar="DIR",
help="Explicit settings directory: settings.json, cache/, wildcards/, "
"backups/, logs/, stats/ all live under this directory. Overrides portable "
"mode and the default user config dir. Equivalent to the "
"LORA_MANAGER_SETTINGS_DIR environment variable.",
)
parser.add_argument(
"--log-level",
type=str,
@@ -414,6 +448,18 @@ async def main():
"""Main entry point for standalone mode"""
args = parse_args()
# Normalize and validate the explicit settings directory (the pre-import
# argv scan already applied it; re-derive so --settings-path wins over any
# pre-existing LORA_MANAGER_SETTINGS_DIR and is canonicalized the same way).
if args.settings_path:
settings_dir = os.path.abspath(os.path.expanduser(args.settings_path))
if os.path.exists(settings_dir) and not os.path.isdir(settings_dir):
logger.error(
"--settings-path '%s' exists but is not a directory.", settings_dir
)
return
os.environ[SETTINGS_DIR_ENV] = settings_dir
# Set log level (verbose flag overrides to DEBUG)
log_level = "DEBUG" if args.verbose else args.log_level
logging.getLogger().setLevel(getattr(logging, log_level))
+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);
+100 -21
View File
@@ -49,34 +49,113 @@
-ms-user-select: none;
}
/* Remove bulk base model modal specific styles - now using shared components */
/* Use shared metadata editing styles instead */
/* Bulk base model modal dedicated inline-list layout
Unlike the single-model modal (overlay dropdown), the bulk modal renders
the option list inline so it never covers the footer buttons and only the
list itself scrolls. Dropdown internals reuse the shared .base-model-*
styles from lora-modal.css. */
/* Override for bulk base model select to ensure proper width */
.bulk-base-model-select {
#bulkBaseModelModal .modal-content {
width: min(720px, calc(100vw - 2rem));
height: min(640px, calc(100vh - var(--header-height, 48px) - 5.5rem));
display: flex;
flex-direction: column;
overflow: hidden;
}
#bulkBaseModelModal .modal-header {
flex-shrink: 0;
}
#bulkBaseModelModal .modal-body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
#bulkBaseModelModal .bulk-add-tags-info {
flex-shrink: 0;
}
#bulkBaseModelModal .bulk-base-model-label {
flex-shrink: 0;
display: block;
font-weight: 500;
margin-bottom: var(--space-1);
color: var(--text-color);
}
.bulk-base-model-picker {
width: 100%;
max-width: 100%;
padding: 6px 10px;
}
#bulkBaseModelModal .bulk-base-model-picker {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
#bulkBaseModelModal .bulk-base-model-picker .base-model-search-wrapper {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
width: 100%;
z-index: auto;
}
#bulkBaseModelModal .base-model-search-input-wrapper {
flex-shrink: 0;
}
/* Inline list instead of overlay dropdown */
#bulkBaseModelModal .base-model-dropdown {
position: relative;
top: auto;
left: auto;
right: auto;
flex: 1;
min-height: 0;
max-height: none;
overflow-y: auto;
margin-top: var(--space-1);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
font-size: 0.95em;
height: 32px;
box-shadow: none;
z-index: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
scrollbar-width: thin;
scrollbar-color: var(--lora-border) transparent;
}
.bulk-base-model-select:focus {
border-color: var(--lora-accent);
outline: none;
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar {
width: 8px;
}
/* Dark theme support for bulk base model select */
[data-theme="dark"] .bulk-base-model-select {
background-color: rgba(30, 30, 30, 0.9);
color: var(--text-color);
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-thumb {
background: var(--lora-border);
border-radius: 4px;
}
[data-theme="dark"] .bulk-base-model-select option {
background-color: #2d2d2d;
color: var(--text-color);
#bulkBaseModelModal .base-model-dropdown::-webkit-scrollbar-track {
background: transparent;
}
/* The shared dropdown header uses opacity for its muted look, which makes the
sticky background translucent scrolled items bleed through. Keep the
muted text color but restore a fully opaque background (bulk scope only). */
#bulkBaseModelModal .base-model-dropdown-header {
opacity: 1;
color: var(--text-muted, var(--text-color));
}
#bulkBaseModelModal .bulk-base-model-footer {
flex-shrink: 0;
padding-top: var(--space-2);
margin-top: var(--space-2);
border-top: 1px solid var(--lora-border);
}
+28 -5
View File
@@ -671,23 +671,46 @@ body.hide-card-version .hl-badge {
user-select: none;
}
/* Compact LoRA status pill: state icon + available/total fraction (e.g. "2/3").
The icon switches by state (warning/check/layers) so status never relies on
color alone; the tooltip spells out the full details. */
.lora-count {
display: flex;
align-items: center;
gap: 4px;
background: rgba(255, 255, 255, 0.2);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
flex-shrink: 0;
/* Pin to the bottom-right corner of the footer, matching how model card
footer .card-actions behave when the title wraps to multiple lines */
align-self: flex-end;
font-size: 0.85em;
position: relative;
padding: 2px 8px;
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: var(--border-radius-xs);
}
.lora-count.ready {
background: rgba(46, 204, 113, 0.3);
border-color: rgba(46, 204, 113, 0.6);
}
.lora-count.missing {
background: rgba(231, 76, 60, 0.3);
background: rgba(231, 76, 60, 0.35);
border-color: rgba(231, 76, 60, 0.65);
}
/* Partial: usable but degraded some LoRAs are unobtainable (deleted from
the source or unresolvable hash) and are skipped when the recipe is used.
Amber sits between ready green and missing red. */
.lora-count.partial {
background: rgba(243, 156, 18, 0.35);
border-color: rgba(243, 156, 18, 0.65);
}
/* Unavailable: no usable LoRA at all — gray marks the recipe as dead. */
.lora-count.unavailable {
background: rgba(149, 165, 166, 0.35);
border-color: rgba(149, 165, 166, 0.65);
}
.placeholder-message {
+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;
}
-19
View File
@@ -669,25 +669,6 @@
/* Hide the old style */
}
/* Update deleted badge to be more prominent */
.deleted-badge {
display: inline-flex;
align-items: center;
background: var(--lora-warning);
color: white;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
font-size: 0.8em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.deleted-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* Error message styling */
.error-message {
color: var(--lora-error);
@@ -1078,6 +1078,19 @@
color: #facc15;
}
/* Partial: usable but degraded some LoRAs are unobtainable and skipped.
Orange sits between ready green and missing amber. */
.recipe-card__badge--partial {
background: rgba(249, 115, 22, 0.2);
color: #fb923c;
}
/* Unavailable: no usable LoRA at all — red marks the recipe as dead. */
.recipe-card__badge--unavailable {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.recipe-card__badge--empty {
background: rgba(148, 163, 184, 0.18);
color: #e2e8f0;
@@ -1093,6 +1106,16 @@
background: rgba(245, 199, 43, 0.22);
}
[data-theme="light"] .recipe-card__badge--partial {
color: #c2410c;
background: rgba(249, 115, 22, 0.18);
}
[data-theme="light"] .recipe-card__badge--unavailable {
color: #b91c1c;
background: rgba(239, 68, 68, 0.16);
}
[data-theme="light"] .recipe-card__badge--empty {
color: rgba(71, 85, 105, 0.9);
background: rgba(148, 163, 184, 0.2);
@@ -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;
}
+499 -115
View File
@@ -190,6 +190,44 @@
justify-content: center;
}
/* Icon-only companion to the Send button: same pill style as its neighbors,
just without the text label. */
.modal-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
background: var(--surface-subtle);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: var(--border-radius-sm);
color: var(--text-color);
cursor: pointer;
font-size: 0.9em;
transition: var(--transition-base);
}
[data-theme="dark"] .modal-copy-btn {
background: var(--surface-subtle);
border: 1px solid var(--lora-border);
}
.modal-copy-btn:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
border-color: var(--lora-accent);
transform: translateY(-1px);
}
.modal-copy-btn:active {
transform: translateY(0);
}
.modal-copy-btn i {
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
}
@media (max-height: 860px) {
.recipe-header-actions {
padding-bottom: 4px;
@@ -677,6 +715,72 @@
min-height: 0;
}
/* Empty LoRA list + collapsible "Why no LoRAs?" explanation */
.no-loras {
color: var(--text-muted);
font-size: 0.9em;
padding: var(--space-2) 0;
}
.no-loras-reason {
margin: var(--space-1) 0 var(--space-2);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
font-size: 0.85em;
}
.no-loras-reason summary {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
cursor: pointer;
color: var(--text-muted);
user-select: none;
list-style: none;
}
/* Hide the native disclosure triangle; rotate the icon instead. */
.no-loras-reason summary::-webkit-details-marker {
display: none;
}
.no-loras-reason summary i {
transition: transform 0.15s ease;
}
.no-loras-reason[open] summary i {
transform: rotate(90deg);
}
.no-loras-reason summary:hover {
color: var(--text-color);
}
.no-loras-reason-body {
padding: 0 var(--space-3) var(--space-3);
color: var(--text-muted);
}
.no-loras-reason-body ul {
margin: 0;
padding-left: var(--space-5);
}
.no-loras-reason-body li {
margin: var(--space-1) 0;
}
.no-loras-bullet-label {
color: var(--text-color);
font-weight: 600;
}
.no-loras-inferred-note {
font-style: italic;
}
.recipe-checkpoint-container {
display: flex;
flex-direction: column;
@@ -691,6 +795,9 @@
.recipe-lora-item {
display: flex;
/* The reconnect panel is a full-width child that wraps below the
thumbnail + content row. */
flex-wrap: wrap;
gap: var(--space-2);
padding: 10px var(--space-2);
border: 1px solid var(--border-color);
@@ -700,26 +807,42 @@
will-change: transform;
/* Create a new containing block for absolutely positioned descendants */
transform: translateZ(0);
cursor: pointer; /* Make it clear the item is clickable */
/* Rows are not clickable by default; only in-library rows navigate */
cursor: default;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.recipe-lora-item:hover {
/* Click affordance (pointer + hover lift) is reserved for rows that
actually navigate: in-library items open the local detail view. */
.recipe-lora-item.exists-locally {
cursor: pointer;
}
.recipe-lora-item.exists-locally:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-header);
border-color: var(--lora-accent);
}
.recipe-lora-item.exists-locally:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
}
.recipe-lora-item.exists-locally {
background: oklch(var(--lora-accent) / 0.05);
border-left: 4px solid var(--lora-accent);
}
.recipe-lora-item.checkpoint-item {
cursor: pointer;
cursor: default;
padding-top: 8px;
padding-bottom: 8px;
align-items: center;
align-items: flex-start;
}
.recipe-lora-item.checkpoint-item.exists-locally {
cursor: pointer;
}
.recipe-lora-item.missing-locally {
@@ -776,12 +899,24 @@
transform: translateZ(0);
}
.recipe-lora-title {
display: flex;
/* Top-align so the inline Civitai link stays glued to the FIRST line
even when a long model name wraps to two lines. */
align-items: flex-start;
gap: 6px;
flex: 1;
min-width: 0; /* Allow the clamped title to shrink next to the badge */
}
.recipe-lora-content h4 {
margin: 0;
font-size: 1em;
color: var(--text-color);
flex: 1;
max-width: calc(100% - 120px); /* Make room for the badge */
/* Shrink (for the 2-line clamp) but don't grow: the inline Civitai link
should sit right after the name, not pushed to the far edge. */
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
@@ -821,8 +956,36 @@
color: var(--lora-accent);
}
/* Restore icon for manually reconnected entries: its presence on the info
row doubles as the "was reconnected" marker. Shared by LoRA and
checkpoint entries, which use the same info-row flex layout. */
.lora-undo-reconnect,
.checkpoint-undo-reconnect {
margin-left: auto;
background: none;
border: none;
color: var(--text-color);
opacity: 0.55;
cursor: pointer;
padding: 2px 4px;
border-radius: var(--border-radius-xs);
font-size: 0.95em;
line-height: 1;
transition: var(--transition-base);
}
.lora-undo-reconnect:hover,
.lora-undo-reconnect:focus-visible,
.checkpoint-undo-reconnect:hover,
.checkpoint-undo-reconnect:focus-visible {
opacity: 1;
color: var(--lora-accent);
background: var(--lora-surface);
}
.local-badge,
.missing-badge {
.missing-badge,
.invalid-hash-badge {
position: absolute;
right: 0;
top: 0;
@@ -833,21 +996,12 @@
/* Specific styles for recipe modal badges - update z-index */
.recipe-lora-header .local-badge,
.recipe-lora-header .missing-badge {
.recipe-lora-header .missing-badge,
.recipe-lora-header .invalid-hash-badge {
z-index: 2; /* Ensure the badge is above other elements */
backface-visibility: hidden;
}
/* Ensure local-path tooltip is properly positioned and won't move during scroll */
.recipe-lora-header .local-badge .local-path {
z-index: 3;
top: calc(100% + 4px); /* Position tooltip below the badge */
right: -4px; /* Align with the badge */
max-width: 250px;
/* Force hardware acceleration for Chrome */
transform: translateZ(0);
}
.missing-badge {
display: inline-flex;
align-items: center;
@@ -885,49 +1039,42 @@
font-size: 0.9em;
}
/* Add reconnect functionality styles */
.deleted-badge.reconnectable {
position: relative;
cursor: pointer;
transition: background-color 0.2s ease;
}
.deleted-badge.reconnectable:hover {
background-color: var(--lora-accent);
}
.deleted-badge .reconnect-tooltip {
position: absolute;
display: none;
background-color: var(--card-bg);
color: var(--text-color);
padding: 8px 12px;
/* Unresolvable-hash badge: the entry has identity fields, but its hash is
not registered on CivitAI (stale or invalid). */
.invalid-hash-badge {
display: inline-flex;
align-items: center;
background: var(--lora-warning);
color: white;
padding: 3px 6px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-header);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
font-size: 0.85rem;
font-weight: normal;
top: calc(100% + 5px);
left: 0;
margin-left: -100px;
font-size: 0.75em;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.deleted-badge.reconnectable:hover .reconnect-tooltip {
display: block;
.invalid-hash-badge i {
margin-right: 4px;
font-size: 0.9em;
}
/* LoRA reconnect container */
/* Deleted badge is a pure status indicator; the reconnect action lives on
an explicit ghost button in the item's action row. */
/* LoRA reconnect container: an inline extension of the item, not a nested
card a dashed separator reads lighter than another bordered box inside
an already bordered item. It is a direct child of .recipe-lora-item and
spans the full row (thumbnail column included). */
.lora-reconnect-container {
display: none;
flex-direction: column;
background: var(--lora-surface);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
padding: 12px;
margin-top: 10px;
flex-basis: 100%;
/* Flex items default to min-width:auto never let content force the
panel wider than the row. */
min-width: 0;
border-top: 1px dashed var(--border-color);
padding-top: 10px;
gap: 10px;
}
@@ -954,18 +1101,6 @@
font-size: 0.85em;
}
.reconnect-instructions code {
background: rgba(0, 0, 0, 0.1);
padding: 2px 4px;
border-radius: 3px;
font-family: var(--font-mono);
font-size: 0.9em;
}
[data-theme="dark"] .reconnect-instructions code {
background: rgba(255, 255, 255, 0.1);
}
.reconnect-form {
display: flex;
flex-direction: column;
@@ -973,13 +1108,108 @@
}
.reconnect-input {
width: calc(100% - 20px);
box-sizing: border-box;
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-size: 0.95em;
}
.reconnect-error {
display: none;
margin: 0;
color: var(--lora-error);
font-size: 0.85em;
}
.reconnect-error.active {
display: block;
}
.reconnect-suggestions {
display: flex;
flex-direction: column;
gap: 4px;
}
.reconnect-suggestions:empty {
display: none;
}
.reconnect-suggestions-loading,
.reconnect-suggestions-empty {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
padding: 4px 2px;
}
.reconnect-suggestion {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
/* Buttons default to content-box: without this, width:100% + padding +
border overflows the panel by 18px and forces a horizontal scrollbar. */
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface, var(--bg-color));
color: var(--text-color);
font-size: 0.95em;
text-align: left;
cursor: pointer;
transition: var(--transition-base);
}
.reconnect-suggestion:hover,
.reconnect-suggestion:focus-visible {
border-color: var(--lora-accent);
}
.reconnect-suggestion-preview {
width: 40px;
height: 40px;
border-radius: var(--border-radius-xs);
object-fit: cover;
flex-shrink: 0;
background: var(--bg-color);
}
.reconnect-suggestion-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1;
}
.reconnect-suggestion-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reconnect-suggestion-secondary {
font-size: 0.9em;
opacity: 0.7;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reconnect-suggestion-reason {
flex-shrink: 0;
padding: 2px 6px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
color: var(--lora-accent);
font-size: 0.85em;
white-space: nowrap;
}
.reconnect-actions {
@@ -1131,69 +1361,78 @@
align-items: center;
justify-content: flex-end;
flex-shrink: 0;
min-width: 110px;
z-index: 2;
}
/* Update the local-badge and missing-badge to be positioned within the badge-container */
/* Badges are pure status indicators; actions live in .recipe-lora-actions */
.badge-container .local-badge,
.badge-container .missing-badge,
.badge-container .deleted-badge {
.badge-container .deleted-badge,
.badge-container .invalid-hash-badge {
position: static; /* Override absolute positioning */
transform: none; /* Remove the transform */
}
/* Ensure the tooltip is still properly positioned */
.badge-container .local-badge .local-path {
position: fixed; /* Keep as fixed for Chrome */
z-index: 100;
/* Tonal (soft) status badges: a tinted fill + colored text reads calmer
than solid blocks when several rows stack, and matches the tonal
"N missing" summary pill above the list. */
.badge-container .local-badge {
background: oklch(var(--lora-accent) / 0.12);
color: var(--lora-accent);
border: 1px solid oklch(var(--lora-accent) / 0.35);
}
.badge-container .resource-action {
margin-left: auto;
.badge-container .missing-badge {
background: oklch(var(--lora-error) / 0.14);
color: var(--lora-error);
border: 1px solid oklch(var(--lora-error) / 0.35);
}
/* Add styles for missing LoRAs download feature */
.recipe-status.missing {
.badge-container .deleted-badge {
background: rgba(127, 127, 127, 0.15);
color: var(--text-muted);
border: 1px solid rgba(127, 127, 127, 0.35);
}
.badge-container .invalid-hash-badge {
background: oklch(var(--lora-warning) / 0.14);
color: var(--lora-warning);
border: 1px solid oklch(var(--lora-warning) / 0.35);
}
/* Pin the recipe status-badge family (missing / deleted / invalid-hash) to its
compact size. import-modal.css defines unscoped .missing-badge/.deleted-badge
and is loaded AFTER this file, so without this higher-specificity rule its
padding/font-size would clobber recipe modal's, leaving invalid-hash-badge
(which has no import counterpart) at a different size. local-badge
intentionally keeps the global shared.css size. */
#recipeModal .badge-container .missing-badge,
#recipeModal .badge-container .deleted-badge,
#recipeModal .badge-container .invalid-hash-badge {
padding: 3px 6px;
font-size: 0.75em;
}
/* Missing LoRAs status is a real button: the affordance must be visible at
rest (persistent border), not only on hover. */
.recipe-status.missing.clickable {
position: relative;
cursor: pointer;
transition: background-color 0.2s ease;
font: inherit;
background: oklch(var(--lora-error) / 0.12);
color: var(--lora-error);
border: 1px solid oklch(var(--lora-error) / 0.45);
transition: background-color 0.2s ease, box-shadow 0.2s ease;
}
.recipe-status.missing:hover {
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
.recipe-status.missing.clickable:hover {
background: oklch(var(--lora-error) / 0.22);
box-shadow: 0 0 0 2px oklch(var(--lora-error) / 0.25);
}
.recipe-status.missing .missing-tooltip {
position: absolute;
display: none;
background-color: var(--card-bg);
color: var(--text-color);
padding: 8px 12px;
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
box-shadow: var(--shadow-header);
z-index: var(--z-overlay);
width: max-content;
max-width: 200px;
font-size: 0.85rem;
font-weight: normal;
margin-left: -100px;
margin-top: -65px;
}
.recipe-status.missing:hover .missing-tooltip {
display: block;
}
.recipe-status.clickable {
cursor: pointer;
padding: 4px 8px;
border-radius: var(--border-radius-xs);
}
.recipe-status.clickable:hover {
background-color: rgba(var(--lora-warning-rgb, 255, 165, 0), 0.2);
.recipe-status.missing.clickable:focus-visible {
outline: 2px solid var(--lora-error);
outline-offset: 2px;
}
.recipe-checkpoint-meta {
@@ -1205,11 +1444,11 @@
margin-bottom: 2px;
}
.recipe-checkpoint-meta .checkpoint-type {
background: var(--lora-surface);
padding: 2px 8px;
border-radius: var(--border-radius-xs);
color: var(--text-color);
/* Checkpoint type is low-information text (the entry's position above the
divider already implies "checkpoint"), so it renders as plain muted text
instead of a chip competing with the base-model chip. */
.recipe-checkpoint-meta .checkpoint-type-text {
color: var(--text-muted);
}
.recipe-resource-actions {
@@ -1253,3 +1492,148 @@
.resource-action.primary:hover {
background: color-mix(in oklch, var(--lora-accent), black 10%);
}
/* Ghost variant: secondary remediation actions (e.g. Reconnect), matching
the ghost action pattern used in the versions tab. */
.resource-action.ghost {
background: transparent;
color: var(--lora-accent);
border-color: oklch(var(--lora-accent) / 0.4);
}
.resource-action.ghost:hover {
background: oklch(var(--lora-accent) / 0.1);
border-color: var(--lora-accent);
}
/* Per-item action row: remediation lives next to the status badge that
surfaced the problem (download / reconnect / external link). Right-aligned
so the reading order stays: name status meta actions. */
.recipe-lora-actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
/* External-link affordance, mirroring .version-civitai-link in the
versions tab: leaving the app is always an explicit, signposted action. */
.recipe-civitai-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 999px;
color: var(--text-muted);
text-decoration: none;
flex: 0 0 auto;
transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
}
.recipe-civitai-link:hover,
.recipe-civitai-link:focus-visible {
color: var(--lora-accent);
background: color-mix(in oklch, var(--lora-accent) 12%, transparent);
transform: translateY(-1px);
outline: none;
}
/* In titles, size the icon box to the first line box (1em * 1.3 line-height)
so it aligns with the first line of both short and wrapped names. */
.recipe-lora-title .recipe-civitai-link {
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');
}
}
+39 -9
View File
@@ -28,8 +28,14 @@ export class Combobox {
* @param {string[]} [options.presets=[]] Static preset values shown in dropdown.
* @param {(inputValue: string) => Promise<string[]>} [options.fetchOptions]
* Async function returning dynamic suggestions for the current input.
* @param {string} [options.placeholder] Placeholder text for the empty state.
* @param {string} [options.placeholder] Placeholder text for the input and the
* dropdown empty state (see emptyText to override the latter).
* @param {string} [options.emptyText] Text for the dropdown empty state;
* defaults to `placeholder`, then 'No options'. Unlike `placeholder`
* it never touches the input element.
* @param {(value: string) => void} [options.onSelect] Callback when an option is chosen.
* @param {(value: string) => void} [options.onCommit] Callback when Enter is
* pressed without a highlighted option (free-text commit).
*/
constructor(inputElement, options = {}) {
if (!inputElement || inputElement.tagName !== 'INPUT') {
@@ -41,7 +47,9 @@ export class Combobox {
this.presets = Array.isArray(options.presets) ? [...options.presets] : [];
this.fetchOptions = typeof options.fetchOptions === 'function' ? options.fetchOptions : null;
this.placeholder = options.placeholder || '';
this.emptyText = options.emptyText || '';
this.onSelect = typeof options.onSelect === 'function' ? options.onSelect : null;
this.onCommit = typeof options.onCommit === 'function' ? options.onCommit : null;
// Internal state
this._isOpen = false;
@@ -109,19 +117,24 @@ export class Combobox {
// ---- event wiring ----
_bindEvents() {
this.input.addEventListener('focus', () => {
// Keep references so destroy() can detach input listeners — callers
// may destroy a Combobox while its input stays in the DOM.
this._focusHandler = () => {
if (this._suppressInputOpen) return;
this._open();
});
};
this.input.addEventListener('focus', this._focusHandler);
this.input.addEventListener('input', () => {
this._inputHandler = () => {
if (this._suppressInputOpen) return;
this._open(); // no-op if already open
this._refresh(); // re-filter by current input value
this._scheduleFetch();
});
};
this.input.addEventListener('input', this._inputHandler);
this.input.addEventListener('keydown', (event) => this._onKeyDown(event));
this._keyDownHandler = (event) => this._onKeyDown(event);
this.input.addEventListener('keydown', this._keyDownHandler);
// Click an option (delegated)
this.panel.addEventListener('click', (event) => {
@@ -167,6 +180,9 @@ export class Combobox {
event.preventDefault();
this._open();
this._setActiveIndex(0);
} else if (event.key === 'Enter' && typeof this.onCommit === 'function') {
event.preventDefault();
this.onCommit(this.input.value);
}
return;
}
@@ -184,11 +200,17 @@ export class Combobox {
case 'Enter':
// Only intercept Enter to pick an option when one is actively
// highlighted; otherwise let the input's default behavior
// (form submit / free-text commit) proceed.
// highlighted; otherwise commit the free-text value (when an
// onCommit handler is registered) and let the input's default
// behavior proceed otherwise.
if (this._activeIndex >= 0 && this._activeIndex < this._renderedOptions.length) {
event.preventDefault();
this._choose(this._renderedOptions[this._activeIndex]);
} else if (typeof this.onCommit === 'function') {
event.preventDefault();
const value = this.input.value;
this._close();
this.onCommit(value);
}
break;
@@ -254,7 +276,7 @@ export class Combobox {
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'lm-combobox-empty';
empty.textContent = this.placeholder ? this.placeholder : 'No options';
empty.textContent = this.emptyText || this.placeholder || 'No options';
this.panel.appendChild(empty);
this._activeIndex = -1;
return;
@@ -333,11 +355,19 @@ export class Combobox {
if (this.panel && this.panel.parentNode) {
this.panel.parentNode.removeChild(this.panel);
}
this.input.removeEventListener('focus', this._focusHandler);
this.input.removeEventListener('input', this._inputHandler);
this.input.removeEventListener('keydown', this._keyDownHandler);
document.removeEventListener('mousedown', this._outsideClickHandler);
window.removeEventListener('resize', this._resizeHandler);
window.removeEventListener('scroll', this._resizeHandler, true);
}
/** Whether the dropdown panel is currently open. */
isOpen() {
return this._isOpen;
}
_choose(value) {
this.input.value = value;
this._close();
@@ -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,7 +6,7 @@ import { bulkManager } from '../../managers/BulkManager.js';
import { MODEL_CONFIG } from '../../api/apiConfig.js';
import { translate } from '../../utils/i18nHelpers.js';
import { getNsfwLevelSelector } from '../shared/NsfwLevelSelector.js';
import { extractCivitaiModelUrlParts } from '../../utils/civitaiUtils.js';
import { classifyModelRelinkUrl } from '../../utils/civitaiUtils.js';
// Mixin with shared functionality for LoraContextMenu and CheckpointContextMenu
export const ModelContextMenuMixin = {
@@ -106,6 +106,17 @@ export const ModelContextMenuMixin = {
},
// Civitai re-linking methods
getModelTypePrefix() {
// Map the mixin model type to its API route prefix; the relink route
// exists for all model types via COMMON_ROUTE_DEFINITIONS.
const prefixMap = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings'
};
return prefixMap[this.modelType] || 'loras';
},
showRelinkCivitaiModal() {
const filePath = this.currentCard.dataset.filepath;
if (!filePath) return;
@@ -123,43 +134,55 @@ export const ModelContextMenuMixin = {
// Create new bound handler
this._boundRelinkHandler = async () => {
const url = urlInput.value.trim();
const { modelId, modelVersionId } = this.extractModelVersionId(url);
if (!modelId) {
errorDiv.textContent = 'Invalid URL format. Must include model ID.';
const { source, modelId, modelVersionId } = classifyModelRelinkUrl(url);
if (!source || !modelId) {
errorDiv.textContent = 'Invalid URL format. Expected: https://civitai.com/models/{modelId} or https://civarchive.com/models/{modelId}';
return;
}
errorDiv.textContent = '';
modalManager.closeModal('relinkCivitaiModal');
try {
state.loadingManager.showSimpleLoading('Re-linking to Civitai...');
const endpoint = this.modelType === 'checkpoint' ?
'/api/lm/checkpoints/relink-civitai' :
'/api/lm/loras/relink-civitai';
const isCivArchive = source === 'civarchive';
state.loadingManager.showSimpleLoading(
isCivArchive ? 'Re-linking via CivitArchive...' : 'Re-linking to Civitai...'
);
const endpoint = `/api/lm/${this.getModelTypePrefix()}/relink-civitai`;
const payload = {
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
};
// Omitted source keeps backend default-provider behaviour; only
// civarchive pins the provider explicitly.
if (isCivArchive) {
payload.source = source;
}
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_path: filePath,
model_id: modelId,
model_version_id: modelVersionId
})
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Failed to re-link model: ${response.statusText}`);
}
const data = await response.json();
if (data.success) {
showToast('toast.contextMenu.relinkSuccess', {}, 'success');
showToast(
isCivArchive ? 'toast.contextMenu.linkCivArchSuccess' : 'toast.contextMenu.relinkSuccess',
{},
'success'
);
// Reload the current view to show updated data
await this.resetAndReload();
} else {
@@ -255,10 +278,6 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
extractModelVersionId(url) {
return extractCivitaiModelUrlParts(url);
},
parseModelId(value) {
if (value === undefined || value === null || value === '') {
return null;
@@ -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() {
@@ -37,8 +38,7 @@ export class RecipeContextMenu extends BaseContextMenu {
if (recipeId && missingLorasItem) {
// Check if this card has missing LoRAs
const loraCountElement = card.querySelector('.lora-count');
const hasMissingLoras = loraCountElement && loraCountElement.classList.contains('missing');
const hasMissingLoras = Boolean(card.querySelector('.lora-count.missing'));
// Show/hide the download missing LoRAs option based on missing status
if (hasMissingLoras) {
@@ -94,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);
@@ -205,8 +201,9 @@ export class RecipeContextMenu extends BaseContextMenu {
const response = await fetch(`/api/lm/recipe/${recipeId}`);
const recipe = await response.json();
// Get missing LoRAs
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted);
// Get missing LoRAs (still downloadable: not deleted from the
// source and hash still resolvable)
const missingLoras = recipe.loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid);
if (missingLoras.length === 0) {
showToast('recipes.contextMenu.downloadMissing.noMissingLoras', {}, 'info');
@@ -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
+25 -4
View File
@@ -12,6 +12,7 @@ export class DuplicatesManager {
this.duplicateGroups = [];
this.inDuplicateMode = false;
this.selectedForDeletion = new Set();
this._isFindingDuplicates = false;
this._initPromptMatchToggle();
this._initHelpTooltip();
}
@@ -87,6 +88,19 @@ export class DuplicatesManager {
}
async findDuplicates() {
// Guard against re-entry: the scan can take a while on large
// libraries, and repeated clicks would pile up identical requests
// on the backend.
if (this._isFindingDuplicates) {
return false;
}
this._isFindingDuplicates = true;
const triggerButton = document.querySelector('[data-action="find-duplicates"]');
if (triggerButton) {
triggerButton.disabled = true;
triggerButton.classList.add('loading');
}
state.loadingManager?.showSimpleLoading(translate('recipes.duplicates.finding'));
try {
const includePrompt = this._getPromptMatchPreference();
const endpoint = includePrompt
@@ -96,14 +110,14 @@ export class DuplicatesManager {
if (!response.ok) {
throw new Error('Failed to find duplicates');
}
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Unknown error finding duplicates');
}
this.duplicateGroups = data.duplicate_groups || [];
if (this.duplicateGroups.length === 0) {
showToast('toast.duplicates.noDuplicatesFound', { type: 'recipes' }, 'info');
// Keep (or enter) the duplicates view when the user is tuning
@@ -115,13 +129,20 @@ export class DuplicatesManager {
this.enterDuplicateMode();
return true;
}
this.enterDuplicateMode();
return true;
} catch (error) {
console.error('Error finding duplicates:', error);
showToast('toast.duplicates.findFailed', { message: error.message }, 'error');
return false;
} finally {
this._isFindingDuplicates = false;
if (triggerButton) {
triggerButton.disabled = false;
triggerButton.classList.remove('loading');
}
state.loadingManager?.hide();
}
}
+70 -12
View File
@@ -41,9 +41,36 @@ class RecipeCard {
const loras = this.recipe.loras || [];
const lorasCount = loras.length;
// Check if all LoRAs are available in the library
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
// Count LoRAs by availability: in library, missing (still downloadable
// from the source), or unobtainable (deleted from the source, or an
// unresolvable hash) which is silently skipped when the recipe is used.
const availableLorasCount = loras.filter(lora => lora.inLibrary).length;
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid).length;
const unavailableLorasCount = lorasCount - availableLorasCount - missingLorasCount;
// Compact status pill: state icon + available/total fraction.
// Icon switches by state so status never relies on color alone.
// - missing (red): something can still be downloaded, most actionable
// - partial (amber): usable but degraded, unobtainable LoRAs are skipped
// - unavailable (gray, ban): no usable LoRA at all
let loraCountStateClass = '';
let loraCountIcon = 'fa-layer-group';
if (lorasCount > 0) {
if (availableLorasCount === lorasCount) {
loraCountStateClass = 'ready';
loraCountIcon = 'fa-check';
} else if (missingLorasCount > 0) {
loraCountStateClass = 'missing';
loraCountIcon = 'fa-exclamation-triangle';
} else if (availableLorasCount > 0) {
loraCountStateClass = 'partial';
loraCountIcon = 'fa-circle-minus';
} else {
loraCountStateClass = 'unavailable';
loraCountIcon = 'fa-ban';
}
}
const loraCountLabel = lorasCount > 0 ? `${availableLorasCount}/${lorasCount}` : `${lorasCount}`;
// Ensure file_url exists, fallback to API URL if needed
let previewUrl = this.recipe.file_url;
@@ -128,9 +155,8 @@ class RecipeCard {
<span class="model-name">${this.recipe.title}</span>
</div>
${!isDuplicatesMode ? `
<div class="lora-count ${allLorasAvailable ? 'ready' : (lorasCount > 0 ? 'missing' : '')}"
title="${this.getLoraStatusTitle(lorasCount, missingLorasCount)}">
<i class="fas fa-layer-group"></i> ${lorasCount}
<div class="lora-count ${loraCountStateClass}" title="${this.getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount)}">
<i class="fas ${loraCountIcon}" aria-hidden="true"></i> ${loraCountLabel}
</div>
` : ''}
</div>
@@ -148,10 +174,39 @@ class RecipeCard {
return card;
}
getLoraStatusTitle(totalCount, missingCount) {
if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`;
getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
if (totalCount === 0) {
return translate('recipes.loraStatus.none', {}, 'No LoRAs in this recipe');
}
if (availableCount === totalCount) {
return translate('recipes.loraStatus.allAvailable', {}, 'All LoRAs available - Ready to use');
}
if (missingCount > 0 && unavailableCount > 0) {
return translate(
'recipes.loraStatus.missingAndUnavailable',
{ missing: missingCount, unavailable: unavailableCount, total: totalCount },
`${missingCount} of ${totalCount} LoRAs missing, ${unavailableCount} unavailable (deleted from source or unresolvable hash)`
);
}
if (missingCount > 0) {
return translate(
'recipes.loraStatus.missing',
{ missing: missingCount, total: totalCount },
`${missingCount} of ${totalCount} LoRAs missing`
);
}
if (availableCount > 0) {
return translate(
'recipes.loraStatus.partial',
{ unavailable: unavailableCount, total: totalCount },
`${unavailableCount} of ${totalCount} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used`
);
}
return translate(
'recipes.loraStatus.noneUsable',
{ unavailable: unavailableCount, total: totalCount },
`No usable LoRAs - ${unavailableCount} of ${totalCount} deleted from source or unresolvable hash`
);
}
async toggleFavorite(card) {
@@ -240,9 +295,12 @@ class RecipeCard {
// Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) {
card.addEventListener('click', () => {
card.addEventListener('click', (e) => {
if (state.bulkMode) {
bulkManager.toggleCardSelection(card);
if (e.shiftKey) {
e.preventDefault();
}
bulkManager.toggleCardSelection(card, e.shiftKey);
return;
}
this.clickHandler(this.recipe);
File diff suppressed because it is too large Load Diff
+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() {
@@ -0,0 +1,404 @@
/**
* BaseModelPicker.js
* Shared searchable base model picker used by the single-model metadata modal
* (commit mode) and the bulk base model modal (change mode).
*/
import { BASE_MODEL_CATEGORIES, getMergedBaseModels, BASE_MODELS_UPDATED_EVENT } from '../../utils/constants.js';
import { translate } from '../../utils/i18nHelpers.js';
// ── Filename-based base model inference ──────────────────────────────────────
// Rules are ordered by specificity — first match wins for dedup.
// Each rule checks the filename (lowercased) for a regex pattern and suggests
// the associated base model values.
export const BASE_MODEL_FILENAME_RULES = [
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
{ pattern: /illustrious/i, models: ['Illustrious'] },
{ pattern: /noobai/i, models: ['NoobAI'] },
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
{ pattern: /kolors/i, models: ['Kolors'] },
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
{ pattern: /lumina/i, models: ['Lumina'] },
{ pattern: /hidream/i, models: ['HiDream'] },
{ pattern: /qwen/i, models: ['Qwen'] },
{ pattern: /chroma/i, models: ['Chroma'] },
{ pattern: /anima/i, models: ['Anima'] },
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
{ pattern: /mochi/i, models: ['Mochi'] },
{ pattern: /svd/i, models: ['SVD'] },
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
{ pattern: /nucleus/i, models: ['Nucleus'] },
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
];
/**
* Infer likely base model(s) from a filename + model name string.
* Returns a deduplicated array in match-priority order.
* @param {string} filename
* @returns {string[]}
*/
export function inferBaseModelsFromFilename(filename) {
if (!filename || typeof filename !== 'string') return [];
const seen = new Set();
const results = [];
for (const rule of BASE_MODEL_FILENAME_RULES) {
if (rule.pattern.test(filename)) {
for (const model of rule.models) {
if (!seen.has(model)) {
seen.add(model);
results.push(model);
}
}
}
}
return results;
}
/**
* Infer likely base model(s) from a set of file paths (bulk selection).
* Each path contributes its basename to the inference; models are deduplicated
* and sorted by how many selected paths matched them (most matches first).
* Returns an empty array when nothing matches.
* @param {string[]} filePaths
* @returns {string[]}
*/
export function inferBaseModelsFromFilepaths(filePaths) {
if (!Array.isArray(filePaths) || filePaths.length === 0) return [];
const hitCounts = new Map(); // model -> number of paths that matched it
for (const filePath of filePaths) {
if (!filePath || typeof filePath !== 'string') continue;
const basename = filePath.split(/[\\/]/).pop();
for (const model of inferBaseModelsFromFilename(basename)) {
hitCounts.set(model, (hitCounts.get(model) || 0) + 1);
}
}
return Array.from(hitCounts.keys())
.sort((a, b) => hitCounts.get(b) - hitCounts.get(a));
}
/**
* Build the full categorized option list. Reads BASE_MODEL_CATEGORIES and
* getMergedBaseModels() fresh on every call so late-arriving dynamic models
* are picked up; uncategorized dynamic entries land in "Other (API)".
* @returns {Array<{value: string, label: string, category: string}>}
*/
function buildCategorizedOptions() {
const allModels = [];
const categorizedModels = new Set();
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
models.forEach(model => {
allModels.push({ value: model, label: model, category });
categorizedModels.add(model);
});
});
const uncategorizedModels = getMergedBaseModels().filter(model => !categorizedModels.has(model));
uncategorizedModels.forEach(model => {
allModels.push({ value: model, label: model, category: 'Other (API)' });
});
return allModels;
}
/**
* Create a searchable base model picker.
*
* Two commit semantics are supported:
* - 'commit' (default): selecting an item immediately calls onCommit(value).
* Escape or an outside click calls onDismiss().
* - 'change': selecting an item updates the internal value and calls
* onChange(value); the caller owns when the selected value is persisted.
* Typed text doubles as a custom value unless allowCustomValue is false,
* in which case it is search-only.
*
* @param {Object} options
* @param {string[]} [options.suggestions] - Models shown in the Suggested section
* @param {string} [options.initialValue] - Initially selected value
* @param {'commit'|'change'} [options.mode] - Commit semantics
* @param {boolean} [options.allowCustomValue=true] - Accept typed text as a custom value
* @param {(value: string) => void} [options.onCommit] - Commit-mode commit callback
* @param {(value: string) => void} [options.onChange] - Called whenever the value changes
* @param {() => void} [options.onDismiss] - Commit-mode dismiss callback (Escape/outside click)
* @returns {{ element: HTMLElement, getValue: Function, setValue: Function, refreshOptions: Function, destroy: Function }}
*/
export function createBaseModelPicker(options = {}) {
const {
suggestions = [],
initialValue = '',
mode = 'commit',
allowCustomValue = true,
onCommit = null,
onChange = null,
onDismiss = null,
} = options;
const isCommitMode = mode !== 'change';
let currentValue = initialValue || '';
let currentFilter = '';
let destroyed = false;
// ── Build widget DOM ────────────────────────────────────────────────────
const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
const inputWrapper = document.createElement('div');
inputWrapper.className = 'base-model-search-input-wrapper';
const searchIcon = document.createElement('i');
searchIcon.className = 'fas fa-search search-icon';
searchIcon.setAttribute('aria-hidden', 'true');
inputWrapper.appendChild(searchIcon);
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'base-model-search-input';
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
searchInput.autocomplete = 'off';
searchInput.spellcheck = false;
inputWrapper.appendChild(searchInput);
wrapper.appendChild(inputWrapper);
const dropdown = document.createElement('div');
dropdown.className = 'base-model-dropdown';
wrapper.appendChild(dropdown);
// ── Render ──────────────────────────────────────────────────────────────
function renderDropdown(filterText) {
currentFilter = filterText || '';
const lowerFilter = currentFilter.toLowerCase().trim();
const allModels = buildCategorizedOptions();
const suggestedSet = new Set(suggestions);
dropdown.innerHTML = '';
let hasVisibleItems = false;
const fragment = document.createDocumentFragment();
// 1. Suggested section (filtered by search)
const suggestedToShow = lowerFilter
? suggestions.filter(m => m.toLowerCase().includes(lowerFilter))
: suggestions;
if (suggestedToShow.length > 0) {
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header suggested-header';
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
section.appendChild(header);
suggestedToShow.forEach(model => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (model === currentValue) item.classList.add('selected');
item.dataset.value = model;
item.textContent = model;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
}
// 2. Categorized options (deduplicated against suggestions)
const categoryMap = {};
allModels.forEach(m => {
if (suggestedSet.has(m.value)) return; // already shown in Suggested
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
if (!categoryMap[m.category]) categoryMap[m.category] = [];
categoryMap[m.category].push(m);
});
Object.entries(categoryMap).forEach(([category, items]) => {
if (items.length === 0) return;
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header';
header.textContent = category;
section.appendChild(header);
items.forEach(m => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (m.value === currentValue) item.classList.add('selected');
item.dataset.value = m.value;
item.textContent = m.label;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
});
// 3. Empty state
if (!hasVisibleItems) {
const empty = document.createElement('div');
empty.className = 'base-model-dropdown-empty';
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
fragment.appendChild(empty);
}
dropdown.appendChild(fragment);
// Scroll the selected item into view
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
}
// Initial render — show everything
renderDropdown('');
// ── Value handling ──────────────────────────────────────────────────────
function applySelection(value) {
currentValue = value;
if (isCommitMode) {
if (typeof onCommit === 'function') onCommit(value);
return;
}
// Change mode: mirror the selection into the input and notify only.
searchInput.value = value;
// Filter the list down to the selected item instead of resetting to
// the full list (which scroll-jumps to the selection). Custom values
// that are not in the option list keep the full list visible.
const isKnownValue = suggestions.includes(value) ||
buildCategorizedOptions().some(m => m.value === value);
renderDropdown(isKnownValue ? value : '');
if (typeof onChange === 'function') onChange(value);
}
// ── Events ──────────────────────────────────────────────────────────────
let filterTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => {
renderDropdown(searchInput.value);
// Change mode with custom values: typed text is the live value.
if (!isCommitMode && allowCustomValue) {
currentValue = searchInput.value;
if (typeof onChange === 'function') onChange(currentValue);
}
}, 50);
});
// Click to select
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.base-model-dropdown-item');
if (!item) return;
applySelection(item.dataset.value);
});
// Keyboard navigation
searchInput.addEventListener('keydown', (e) => {
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
const activeIdx = items.findIndex(el => el.classList.contains('active'));
if (e.key === 'ArrowDown') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const next = Math.min(activeIdx + 1, items.length - 1);
if (items[next]) {
items[next].classList.add('active');
items[next].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const prev = Math.max(activeIdx - 1, 0);
if (items[prev]) {
items[prev].classList.add('active');
items[prev].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'Enter') {
e.preventDefault();
const activeItem = items.find(el => el.classList.contains('active'));
if (activeItem) {
applySelection(activeItem.dataset.value);
} else if (allowCustomValue && searchInput.value.trim()) {
applySelection(searchInput.value.trim());
}
} else if (e.key === 'Escape') {
e.preventDefault();
if (isCommitMode && typeof onDismiss === 'function') {
onDismiss();
}
}
});
// Commit mode: outside click commits typed text (when custom values are
// allowed) or dismisses. Deferred to avoid the opening click itself.
const outsideClickHandler = (e) => {
if (wrapper.contains(e.target)) return;
const typedValue = searchInput.value.trim();
if (allowCustomValue && typedValue) {
applySelection(typedValue);
} else if (typeof onDismiss === 'function') {
onDismiss();
}
};
let outsideClickTimer = null;
if (isCommitMode) {
outsideClickTimer = setTimeout(() => {
outsideClickTimer = null;
if (!destroyed) {
document.addEventListener('click', outsideClickHandler);
}
}, 0);
}
// Refresh when dynamic base models arrive late; keeps the current search text.
const handleBaseModelsUpdated = () => {
if (destroyed) return;
refreshOptions();
};
window.addEventListener(BASE_MODELS_UPDATED_EVENT, handleBaseModelsUpdated);
// ── Public API ──────────────────────────────────────────────────────────
function getValue() {
return currentValue;
}
function setValue(value) {
currentValue = value || '';
searchInput.value = currentValue;
renderDropdown('');
}
function refreshOptions() {
renderDropdown(currentFilter);
}
function destroy() {
if (destroyed) return;
destroyed = true;
clearTimeout(filterTimeout);
if (outsideClickTimer) {
clearTimeout(outsideClickTimer);
outsideClickTimer = null;
}
document.removeEventListener('click', outsideClickHandler);
window.removeEventListener(BASE_MODELS_UPDATED_EVENT, handleBaseModelsUpdated);
}
return { element: wrapper, getValue, setValue, refreshOptions, destroy };
}
+11 -5
View File
@@ -108,7 +108,10 @@ function handleModelCardEvent_internal(event, modelType) {
}
// If no specific element was clicked, handle the card click (show modal or toggle selection)
handleCardClick(card, modelType);
if (state.bulkMode && event.shiftKey) {
event.preventDefault(); // keep shift+click from extending a text selection
}
handleCardClick(card, modelType, event.shiftKey);
return false; // Continue with other handlers (e.g., bulk selection)
}
@@ -288,12 +291,12 @@ function handleViewLocalVersionsFromCard(card, modelType) {
}
}
function handleCardClick(card, modelType) {
function handleCardClick(card, modelType, extendSelection = false) {
const pageState = getCurrentPageState();
if (state.bulkMode) {
// Toggle selection using the bulk manager
bulkManager.toggleCardSelection(card);
bulkManager.toggleCardSelection(card, extendSelection);
} else if (pageState && pageState.duplicatesMode) {
// In duplicates mode, don't open modal when clicking cards
return;
@@ -543,8 +546,9 @@ export function createModelCard(model, modelType) {
card.classList.add('excluded-model');
}
// Apply selection state if in bulk mode and this card is in the selected set (LoRA only)
if (modelType === MODEL_TYPES.LORA && state.bulkMode && state.selectedLoras.has(model.file_path)) {
// state.selectedModels resolves to the active page's set (selectedLoras
// included) - do not narrow this back to selectedLoras/LORA-only.
if (state.bulkMode && state.selectedModels.has(model.file_path)) {
card.classList.add('selected');
}
@@ -603,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 -302
View File
@@ -3,75 +3,9 @@
* Handles model metadata editing functionality - General version
*/
import { BASE_MODEL_CATEGORIES, getMergedBaseModels } from '../../utils/constants.js';
import { showToast } from '../../utils/uiHelpers.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { translate } from '../../utils/i18nHelpers.js';
// ── Filename-based base model inference ──────────────────────────────────────
// Rules are ordered by specificity — first match wins for dedup.
// Each rule checks the filename (lowercased) for a regex pattern and suggests
// the associated base model values.
const BASE_MODEL_FILENAME_RULES = [
{ pattern: /flux\.?\s*2\s*klein/i, models: ['Flux.2 Klein 9B', 'Flux.2 Klein 9B-base', 'Flux.2 Klein 4B', 'Flux.2 Klein 4B-base'] },
{ pattern: /flux\.?\s*2/i, models: ['Flux.2 D', 'Flux.2 Klein 9B', 'Flux.2 Klein 4B'] },
{ pattern: /flux\.?\s*1\s*(dev|d)\b/i, models: ['Flux.1 D'] },
{ pattern: /flux\.?\s*1\s*(schnell|s)\b/i, models: ['Flux.1 S'] },
{ pattern: /flux/i, models: ['Flux.1 D', 'Flux.1 S', 'Flux.2 D'] },
{ pattern: /sdxl/i, models: ['SDXL 1.0', 'SDXL Lightning', 'SDXL Hyper'] },
{ pattern: /sd\s*1[._-\s]?5/i, models: ['SD 1.5'] },
{ pattern: /sd\s*1[._-\s]?4/i, models: ['SD 1.4'] },
{ pattern: /sd\s*1/i, models: ['SD 1.5', 'SD 1.4', 'SD 1.5 LCM', 'SD 1.5 Hyper'] },
{ pattern: /sd\s*3[._-\s]?5/i, models: ['SD 3.5', 'SD 3.5 Medium', 'SD 3.5 Large', 'SD 3.5 Large Turbo'] },
{ pattern: /sd\s*3/i, models: ['SD 3', 'SD 3.5'] },
{ pattern: /wan\s*\.?\s*video/i, models: ['Wan Video', 'Wan Video 1.3B t2v', 'Wan Video 14B t2v', 'Wan Video 14B i2v 480p', 'Wan Video 14B i2v 720p'] },
{ pattern: /hunyuan\s*\.?\s*video/i, models: ['Hunyuan Video'] },
{ pattern: /ltxv/i, models: ['LTXV', 'LTXV2', 'LTXV 2.3'] },
{ pattern: /cogvideo/i, models: ['CogVideoX'] },
{ pattern: /pony/i, models: ['Pony', 'Pony V7'] },
{ pattern: /illustrious/i, models: ['Illustrious'] },
{ pattern: /noobai/i, models: ['NoobAI'] },
{ pattern: /pixart/i, models: ['PixArt a', 'PixArt E'] },
{ pattern: /aura\s*\.?\s*flow/i, models: ['AuraFlow'] },
{ pattern: /kolors/i, models: ['Kolors'] },
{ pattern: /hunyuan\s*1/i, models: ['Hunyuan 1'] },
{ pattern: /lumina/i, models: ['Lumina'] },
{ pattern: /hidream/i, models: ['HiDream'] },
{ pattern: /qwen/i, models: ['Qwen'] },
{ pattern: /chroma/i, models: ['Chroma'] },
{ pattern: /anima/i, models: ['Anima'] },
{ pattern: /sd\s*2[._-\s]?[01]/i, models: ['SD 2.0', 'SD 2.1'] },
{ pattern: /mochi/i, models: ['Mochi'] },
{ pattern: /svd/i, models: ['SVD'] },
{ pattern: /zimage/i, models: ['ZImageTurbo', 'ZImageBase'] },
{ pattern: /nucleus/i, models: ['Nucleus'] },
{ pattern: /krea/i, models: ['Flux.1 Krea', 'Krea 2'] },
{ pattern: /ernie/i, models: ['Ernie', 'Ernie Turbo'] },
];
/**
* Infer likely base model(s) from a filename + model name string.
* Returns a deduplicated array in match-priority order.
* @param {string} filename
* @returns {string[]}
*/
function inferBaseModelsFromFilename(filename) {
if (!filename || typeof filename !== 'string') return [];
const seen = new Set();
const results = [];
for (const rule of BASE_MODEL_FILENAME_RULES) {
if (rule.pattern.test(filename)) {
for (const model of rule.models) {
if (!seen.has(model)) {
seen.add(model);
results.push(model);
}
}
}
}
return results;
}
import { inferBaseModelsFromFilename, createBaseModelPicker } from './BaseModelPicker.js';
/**
* Resolve the active file path for the currently open model modal.
@@ -321,199 +255,16 @@ export function setupBaseModelEditing(filePath) {
// Handle edit button click
editBtn.addEventListener('click', () => {
baseModelDisplay.classList.add('editing');
// Store the original value to check for changes later
const originalValue = baseModelContent.textContent.trim();
// ── Build the full option list ────────────────────────────────────────
const allModels = []; // { value, label, category }
const categorizedModels = new Set();
Object.entries(BASE_MODEL_CATEGORIES).forEach(([category, models]) => {
models.forEach(model => {
allModels.push({ value: model, label: model, category });
categorizedModels.add(model);
});
});
const mergedModels = getMergedBaseModels();
const uncategorizedModels = mergedModels.filter(model => !categorizedModels.has(model));
if (uncategorizedModels.length > 0) {
uncategorizedModels.forEach(model => {
allModels.push({ value: model, label: model, category: 'Other (API)' });
});
}
// ── Filename-based inference ──────────────────────────────────────────
// Filename-based inference for the Suggested section
const fileName = (document.querySelector('.file-name-content')?.textContent || '') + ' ' +
(document.querySelector('.model-name-content')?.textContent || '');
const inferredModels = inferBaseModelsFromFilename(fileName);
const inferredSet = new Set(inferredModels);
// ── Build search widget DOM ───────────────────────────────────────────
const wrapper = document.createElement('div');
wrapper.className = 'base-model-search-wrapper';
// Search input row
const inputWrapper = document.createElement('div');
inputWrapper.className = 'base-model-search-input-wrapper';
const searchIcon = document.createElement('i');
searchIcon.className = 'fas fa-search search-icon';
searchIcon.setAttribute('aria-hidden', 'true');
inputWrapper.appendChild(searchIcon);
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'base-model-search-input';
searchInput.placeholder = translate('modals.model.metadata.baseModelSearchPlaceholder', {}, 'Search base model…');
searchInput.autocomplete = 'off';
searchInput.spellcheck = false;
inputWrapper.appendChild(searchInput);
wrapper.appendChild(inputWrapper);
// Dropdown list
const dropdown = document.createElement('div');
dropdown.className = 'base-model-dropdown';
wrapper.appendChild(dropdown);
// ── Render ────────────────────────────────────────────────────────────
function renderDropdown(filterText) {
const lowerFilter = (filterText || '').toLowerCase().trim();
dropdown.innerHTML = '';
let hasVisibleItems = false;
const fragment = document.createDocumentFragment();
// 1. Suggested section (filename-inferred, filtered by search)
let suggestedToShow = inferredModels;
if (lowerFilter) {
suggestedToShow = inferredModels.filter(m =>
m.toLowerCase().includes(lowerFilter)
);
}
if (suggestedToShow.length > 0) {
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header suggested-header';
header.innerHTML = '<i class="fas fa-star" aria-hidden="true"></i> ' +
translate('modals.model.metadata.baseModelSuggested', {}, 'Suggested');
section.appendChild(header);
suggestedToShow.forEach(model => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (model === originalValue) item.classList.add('selected');
item.dataset.value = model;
item.textContent = model;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
}
// 2. Categorized options (deduplicated against suggestions)
const categoryMap = {};
allModels.forEach(m => {
if (inferredSet.has(m.value)) return; // already shown in Suggested
if (lowerFilter && !m.label.toLowerCase().includes(lowerFilter)) return;
if (!categoryMap[m.category]) categoryMap[m.category] = [];
categoryMap[m.category].push(m);
});
Object.entries(categoryMap).forEach(([category, items]) => {
if (items.length === 0) return;
const section = document.createElement('div');
section.className = 'base-model-dropdown-section';
const header = document.createElement('div');
header.className = 'base-model-dropdown-header';
header.textContent = category;
section.appendChild(header);
items.forEach(m => {
const item = document.createElement('div');
item.className = 'base-model-dropdown-item';
if (m.value === originalValue) item.classList.add('selected');
item.dataset.value = m.value;
item.textContent = m.label;
section.appendChild(item);
hasVisibleItems = true;
});
fragment.appendChild(section);
});
// 3. Empty state
if (!hasVisibleItems) {
const empty = document.createElement('div');
empty.className = 'base-model-dropdown-empty';
empty.textContent = translate('modals.model.metadata.baseModelNoMatch', {}, 'No matching base models');
fragment.appendChild(empty);
}
dropdown.appendChild(fragment);
// Scroll the selected item into view
const selected = dropdown.querySelector('.base-model-dropdown-item.selected');
if (selected) {
selected.scrollIntoView({ block: 'nearest' });
}
}
// Initial render — show everything
renderDropdown('');
// ── Events ────────────────────────────────────────────────────────────
let filterTimeout;
searchInput.addEventListener('input', () => {
clearTimeout(filterTimeout);
filterTimeout = setTimeout(() => renderDropdown(searchInput.value), 50);
});
// Click to select
dropdown.addEventListener('click', (e) => {
const item = e.target.closest('.base-model-dropdown-item');
if (!item) return;
baseModelContent.textContent = item.dataset.value;
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
});
// Replace content with search widget
baseModelContent.style.display = 'none';
editBtn.style.display = 'none';
baseModelDisplay.insertBefore(wrapper, editBtn);
searchInput.focus();
// ── Cleanup ───────────────────────────────────────────────────────────
function cleanup() {
if (wrapper.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(wrapper);
}
baseModelContent.style.display = '';
editBtn.style.display = '';
baseModelDisplay.classList.remove('editing');
document.removeEventListener('click', outsideClickHandler);
}
// Outside click → save typed/custom value if any
const outsideClickHandler = function(e) {
if (wrapper.contains(e.target)) return;
// If user typed a custom value (not just empty), apply it
const typedValue = searchInput.value.trim();
if (typedValue) {
baseModelContent.textContent = typedValue;
}
cleanup();
const saveIfChanged = () => {
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
@@ -522,56 +273,39 @@ export function setupBaseModelEditing(filePath) {
);
}
};
// Defer listener to avoid the opening click itself
setTimeout(() => {
document.addEventListener('click', outsideClickHandler);
}, 0);
// Keyboard navigation
searchInput.addEventListener('keydown', function onKeydown(e) {
const items = Array.from(dropdown.querySelectorAll('.base-model-dropdown-item'));
const activeIdx = items.findIndex(el => el.classList.contains('active'));
if (e.key === 'ArrowDown') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const next = Math.min(activeIdx + 1, items.length - 1);
if (items[next]) {
items[next].classList.add('active');
items[next].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
items.forEach(el => el.classList.remove('active'));
const prev = Math.max(activeIdx - 1, 0);
if (items[prev]) {
items[prev].classList.add('active');
items[prev].scrollIntoView({ block: 'nearest' });
}
} else if (e.key === 'Enter') {
e.preventDefault();
const activeItem = items.find(el => el.classList.contains('active'));
if (activeItem) {
activeItem.click();
} else if (searchInput.value.trim()) {
// Custom value typed
baseModelContent.textContent = searchInput.value.trim();
cleanup();
const finalValue = baseModelContent.textContent.trim();
if (finalValue !== originalValue) {
saveBaseModel(
getActiveModalFilePath(baseModelContent.dataset.filePath),
originalValue
);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
const picker = createBaseModelPicker({
suggestions: inferredModels,
initialValue: originalValue,
mode: 'commit',
onCommit: (value) => {
baseModelContent.textContent = value;
cleanup();
saveIfChanged();
},
onDismiss: () => {
// Escape or empty outside click: restore the original value
baseModelContent.textContent = originalValue;
cleanup();
}
},
});
function cleanup() {
picker.destroy();
if (picker.element.parentNode === baseModelDisplay) {
baseModelDisplay.removeChild(picker.element);
}
baseModelContent.style.display = '';
editBtn.style.display = '';
baseModelDisplay.classList.remove('editing');
}
// Replace content with search widget
baseModelContent.style.display = 'none';
editBtn.style.display = 'none';
baseModelDisplay.insertBefore(picker.element, editBtn);
const searchInput = picker.element.querySelector('.base-model-search-input');
if (searchInput) searchInput.focus();
});
}
+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
@@ -1426,6 +1426,32 @@ export function initVersionsTab({
}
}
/**
* True when the downloaded version is the newest version in the model's
* remote version set, i.e. the one whose install flips the backend
* update-available flag off. Unknown version sets fall back to "latest"
* so the post-download in-place reconciliation still runs by default.
* (#1078)
*/
function versionIsLatestAvailable(version) {
if (!controller.record || !Array.isArray(controller.record.versions)) {
return true;
}
const versions = controller.record.versions;
if (versions.length === 0) {
return true;
}
const target = Number(version?.versionId);
if (!Number.isFinite(target)) {
return true;
}
const maxId = versions.reduce(
(max, v) => Math.max(max, Number(v?.versionId) || 0),
0
);
return target >= maxId;
}
async function handleDownloadVersion(button, versionId) {
if (!controller.record) {
return;
@@ -1451,6 +1477,7 @@ export function initVersionsTab({
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
useDefaultPaths: resolveTemplatePath ? true : null,
useSaveDirAsRoot: resolveTemplatePath,
isLatestVersion: versionIsLatestAvailable(version),
});
if (success) {
+41 -11
View File
@@ -130,17 +130,29 @@ function renderRecipes(tabElement, recipes, options) {
const baseModel = recipe.base_model || '';
const loras = recipe.loras || [];
const lorasCount = loras.length;
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted).length;
const allLorasAvailable = missingLorasCount === 0 && lorasCount > 0;
const statusClass = lorasCount === 0 ? 'empty' : (allLorasAvailable ? 'ready' : 'missing');
// Missing = still downloadable; unavailable = deleted from the source
// or unresolvable hash, silently skipped when the recipe is used.
const availableLorasCount = loras.filter(lora => lora.inLibrary).length;
const missingLorasCount = loras.filter(lora => !lora.inLibrary && !lora.isDeleted && !lora.hashInvalid).length;
const unavailableLorasCount = lorasCount - availableLorasCount - missingLorasCount;
const statusClass = lorasCount === 0 ? 'empty'
: (availableLorasCount === lorasCount ? 'ready'
: (missingLorasCount > 0 ? 'missing'
: (availableLorasCount > 0 ? 'partial' : 'unavailable')));
let statusLabel;
if (lorasCount === 0) {
statusLabel = 'No linked LoRAs';
} else if (allLorasAvailable) {
} else if (statusClass === 'ready') {
statusLabel = `${lorasCount} LoRA${lorasCount > 1 ? 's' : ''} ready`;
} else if (statusClass === 'missing') {
statusLabel = unavailableLorasCount > 0
? `Missing ${missingLorasCount}, ${unavailableLorasCount} of ${lorasCount} unavailable`
: `Missing ${missingLorasCount} of ${lorasCount}`;
} else if (statusClass === 'partial') {
statusLabel = `${unavailableLorasCount} of ${lorasCount} unavailable - skipped when used`;
} else {
statusLabel = `Missing ${missingLorasCount} of ${lorasCount}`;
statusLabel = 'No usable LoRAs';
}
const imageUrl = recipe.file_url ||
@@ -207,8 +219,16 @@ function renderRecipes(tabElement, recipes, options) {
const statusBadge = document.createElement('span');
statusBadge.className = `recipe-card__badge recipe-card__badge--${statusClass}`;
// Icon switches by state so status never relies on color alone.
const statusIcons = {
ready: 'fa-check',
missing: 'fa-exclamation-triangle',
partial: 'fa-circle-minus',
unavailable: 'fa-ban',
empty: 'fa-layer-group',
};
const statusIcon = document.createElement('i');
statusIcon.className = 'fas fa-layer-group';
statusIcon.className = `fas ${statusIcons[statusClass] || 'fa-layer-group'}`;
statusIcon.setAttribute('aria-hidden', 'true');
statusBadge.appendChild(statusIcon);
@@ -216,7 +236,7 @@ function renderRecipes(tabElement, recipes, options) {
statusText.textContent = statusLabel;
statusBadge.appendChild(statusText);
statusBadge.title = getLoraStatusTitle(lorasCount, missingLorasCount);
statusBadge.title = getLoraStatusTitle(lorasCount, availableLorasCount, missingLorasCount, unavailableLorasCount);
meta.appendChild(statusBadge);
body.appendChild(meta);
@@ -264,13 +284,23 @@ function renderRecipes(tabElement, recipes, options) {
/**
* Returns a descriptive title for the LoRA status indicator
* @param {number} totalCount - Total number of LoRAs in recipe
* @param {number} missingCount - Number of missing LoRAs
* @param {number} availableCount - Number of LoRAs present in the library
* @param {number} missingCount - Number of missing LoRAs (still downloadable)
* @param {number} unavailableCount - Number of unobtainable LoRAs (deleted
* from the source or unresolvable hash)
* @returns {string} Status title text
*/
function getLoraStatusTitle(totalCount, missingCount) {
function getLoraStatusTitle(totalCount, availableCount, missingCount, unavailableCount) {
if (totalCount === 0) return "No LoRAs in this recipe";
if (missingCount === 0) return "All LoRAs available - Ready to use";
return `${missingCount} of ${totalCount} LoRAs missing`;
if (availableCount === totalCount) return "All LoRAs available - Ready to use";
if (missingCount > 0 && unavailableCount > 0) {
return `${missingCount} of ${totalCount} LoRAs missing, ${unavailableCount} unavailable (deleted from source or unresolvable hash)`;
}
if (missingCount > 0) return `${missingCount} of ${totalCount} LoRAs missing`;
if (availableCount > 0) {
return `${unavailableCount} of ${totalCount} LoRAs unavailable (deleted from source or unresolvable hash) - skipped when recipe is used`;
}
return `No usable LoRAs - ${unavailableCount} of ${totalCount} deleted from source or unresolvable hash`;
}
/**
+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');
}
/**

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