Compare commits

..

44 Commits

Author SHA1 Message Date
Will Miao 84146b62fd feat(other-models): announce the feature only when folders are available
Other Models management is opt-in and its folders come from
folder_paths.get_folder_paths(). In plugin mode ComfyUI registers vae,
upscale_models, text_encoders, clip_vision and controlnet out of the box, so
enabling the feature works immediately. Standalone only knows the keys present
in settings.json.folder_paths, and that file is edited by hand - there is no UI
for those keys - so a standalone user who followed the announcement banner
reached "Enable Other Models" and then an empty page.

Gate the announcement on the capability instead of on how the process was
started:

- Config.get_other_models_availability() probes every canonical other key
  (legacy clip collapses into text_encoders where the host exposes
  map_legacy) and reports which sub_types resolve to a folder that exists on
  disk. It deliberately ignores enable_other_models: the question is "could
  this work here at all?". An empty folder counts, because CivitAI downloads
  can target it.
- /api/lm/settings exposes it as the derived, non-persisted
  other_models_paths_available flag; a probe failure yields null and the
  banner fails open.
- BannerService only registers the announcement when the flag is not false.
  `=== false` (not falsy) keeps a cached/older payload working, and nothing is
  written to dismissed_banners, so the banner can return once folders exist.
- The Other page grows an "enabled but nothing to scan" empty state driven by
  config.other_roots, showing the settings.json snippet for standalone and a
  pointer to ComfyUI model paths otherwise, plus an Open Settings action. It
  also covers the corner where only a non-default sub_type has a folder.

Translate the six other.noPaths.* keys into all nine locales and record the
new "folder key" / "on disk" terminology in the i18n guidelines.

Backend tests and pytest tests/i18n could not run in this environment (no
pytest/platformdirs); the probe was exercised against a stubbed folder_paths.
Frontend: 120 files / 1101 JS tests passed.
2026-09-13 21:47:16 +08:00
Will Miao adeb40bfff fix(links): let CivitAI and HuggingFace links coexist (#1094)
A model could have CivitAI metadata and a HuggingFace link at the same time,
but only one of the two "View on ..." entries ever rendered, because both the
model modal and the card globe asked the `from_civitai` provenance flag which
source to show. `set_hf_url` wrote `false` and a CivitAI refresh wrote `true`,
so whichever ran last erased the other: linking HF hid "View on CivitAI" even
though the civitai payload was still in the sidecar, and (on the card) a later
refresh pointed the single globe icon back at CivitAI, hiding the HF entry.

Decide the links from the data itself instead:

- `set_hf_url` no longer touches `from_civitai`; it records where the metadata
  came from, and HF provenance is already tracked by `hf_url`.
- Add `hasCivitaiSource(civitai)` in the shared card/modal utils and gate the
  modal's CivitAI link, the card globe (title, enabled state, click target,
  new `data-has_civitai`) and the context-menu `civitai` action on actual
  CivitAI data (`modelId` / `model_id` / `id`). A dual-source model now shows
  both links, and a CivitAI-only model with no `hf_url` stays as before.
- Agent HF enrichment (`PostProcessor.is_hf_model`) keyed off
  `not from_civitai`, which stopped being a synonym for "has an HF source" once
  both sources can coexist (and already broke after a CivitAI refresh flipped
  the flag back to true). Key it off `hf_url` directly; the post-processor
  tests move to that discriminator and gain a dual-source case.

Regression tests: the set-hf-url handler preserves civitai + `from_civitai`
and no longer forces the flag false, the modal renders both links (including
with `from_civitai: false`), and the card globe targets/opens the right source
and is disabled when neither is available.

Backend: 2749 passed. Frontend: 1098 JS + 91 Vue tests passed.
2026-09-13 21:12:54 +08:00
Will Miao 8a21837ca2 i18n: name all five sub_types in the Other Models opt-in copy
Three pre-enable strings listed exactly the old default set (VAE, upscaler,
text encoder, CLIP vision), so they read as "these are what enabling
manages" - now wrong twice over, since clip_vision became opt-in and
ControlNet was never named.

Point them at the capability instead: other.disabled.description and
banners.otherModels.content enumerate all five sub_types, and
settings.folderSettings.enableOtherModelsHelp names all five folder
categories the master switch gates. Model-type names stay in Latin per the
model-type rule; de compounds as CLIP-Vision- und ControlNet-Ordner and the
slash-list locales keep their existing VAE / Upscaler / Text Encoder / ...
casing. No placeholders or HTML are involved.

Editing en.json leaves the nine locales stale, and the sync script only adds
missing keys, so each locale is updated in the same pass by exact-literal
replacement of the one line - no JSON round-trip, no formatting churn (three
changed lines per file). Record the refreshed strings and the
"capability, not defaults" rule in the i18n guidelines.
2026-09-13 20:12:52 +08:00
Will Miao 3302147a43 fix(other-models): make clip_vision opt-in like controlnet
DEFAULT_ENABLED_OTHER_SUB_TYPES managed vae, upscaler, text_encoder and
clip_vision while controlnet was the sole opt-in type. That split was not
defensible on demand breadth: ControlNet is the broader category by install
base, and clip_vision is the narrower one (IPAdapter/SVD image conditioning,
usually one to three files) whose CivitAI type is retired upstream.

Keep the default set to the dependency-style assets every pipeline needs and
where "which one am I actually using" is the real problem - VAE, upscalers
and text encoders - and treat clip_vision and controlnet symmetrically as
opt-in. The feature is still unreleased, so the change needs no migration.

- Sync all five surfaces holding a default: DEFAULT_ENABLED_OTHER_SUB_TYPES,
  DEFAULT_SETTINGS, both DEFAULT_SETTINGS_BASE/createDefaultSettings lists,
  updateOtherModelsControls()'s fallback and the Jinja fallback.
- The selection is persisted per user, so only the untouched default moves;
  existing default_other_roots entries for a disabled sub_type are preserved.
- Fix the Jinja fallback using `or`, which treated an all-unchecked empty
  allow-list as "unset" and re-checked every box on render; `is none` keeps
  the empty list empty.
- Document the revised defaults and rationale in the plan.

Tests assert the new default trio, the normalize fallback, that both opt-in
types stay out of the default scan, and the auto-set iteration test now
enables clip_vision explicitly since it exercises the loop, not the default.
2026-09-13 20:12:49 +08:00
Will Miao 4d87ae7637 fix(other-models): stop warning about legacy folder keys that alias
Enabling Other Models logged two warnings on a stock ComfyUI install:

  Detected the same folder '.../clip' under multiple other-model categories
  ('.../clip' is already mapped). Keeping the first category; please fix
  your path configuration.

Nothing was wrong with the configuration. ComfyUI's folder_paths rewrites
legacy names before every access (map_legacy: clip -> text_encoders,
unet -> diffusion_models) and registers both legacy directories under the
canonical key, so get_folder_paths("clip") returns exactly the same list as
get_folder_paths("text_encoders"). Both keys are in the enabled allow-list,
so the second pass hit the overlap guard for every text-encoder folder and
printed advice the user cannot act on. The path list itself was correct
(deduped), only the message was wrong.

- Config._collapse_legacy_folder_keys() drops a key when the host exposes
  map_legacy and resolves it to another queried key. That is provably
  lossless: an empty canonical list implies an empty alias list. The
  standalone MockFolderPaths has no map_legacy and its keys are independent
  settings.json entries, so every key is still queried there.
- _prepare_other_paths() now tracks the claiming sub_type alongside the
  business path and downgrades a same-sub_type duplicate to debug, keeping
  the warning for a genuine cross-category collision (and naming the other
  category in the message).

Regression tests cover the aliased-key layout (no warning, no redundant
query, both folders still managed) and the same-sub_type duplicate, and the
opt-in test is parametrized over controlnet and clip_vision.
2026-09-13 20:12:45 +08:00
Will Miao b1a653f18f fix(other-models): hide the folder sidebar by default on the Other page
Other-model downloads now default to a flat layout, so a fresh library shows an
empty folder tree there while the sidebar still consumes 230px. Default the
per-page visibility to hidden for "other" through a small per-page default set.

The preference stays persisted per page, so an explicit show/hide toggle wins
afterwards, and the existing edge indicator keeps the hidden sidebar
discoverable and recoverable. Primary pages keep their visible default.
2026-09-13 11:35:48 +08:00
Will Miao 6fe0543d2e fix(other-models): default downloads to a flat path, not {base_model}/{first_tag}
get_download_path_template() fell back to "{base_model}/{first_tag}" for any
unconfigured model type, so other-model downloads were silently nested under an
arbitrary CivitAI tag even though the settings UI exposes no template row for
"other" and priority_tags has no "other" entry (making {first_tag} resolve to
tags[0]).

Add DEFAULT_DOWNLOAD_PATH_TEMPLATES with other -> "" so unconfigured and
unknown types resolve to a flat layout under the already sub_type-scoped
default_other_roots; explicit settings.json values still win. Mirror the flat
default in the frontend DEFAULT_PATH_TEMPLATES and stop the download/move
default-path previews from rendering "/undefined" or a dangling slash.
2026-09-13 11:30:57 +08:00
Will Miao 931dfbe1d3 fix(ui): stop the header search field from crowding itself when space runs out
At ~628px the header overflowed horizontally by 53px: the labelled nav held
383px that flex could not reclaim, so the search field was clamped to its
200px floor and had only ~96px of text room, letting the placeholder collide
with the Ctrl+F cue and the inline toggles.

Three rules drove that:

- .header-search had a hard min-width: 200px, so it parked at a fixed width
  instead of shrinking with the space it was actually given.
- The input reserved 6.75rem for "options + filter + clear/cue", but that
  declaration never applied: search-filter.css is imported after header.css
  and its .search-container input (equal specificity) set the right padding.
  The inline chrome actually needs 126px, so text ran underneath it.
- Labels stayed on the nav down to 600px, where a labelled nav (~383px) and a
  readable search field (~300px) cannot coexist.

- Drop the min-width floors on .header-search and its container so the field
  compresses naturally.
- Reserve exactly the inline chrome (cue 58 + clear 28 + toggles 56 + gaps and
  edges 16 = 126px) and document why !important is required here.
- Add a 1366px breakpoint that hides the Ctrl+F cue and drops the reservation
  to 68px; the shortcut itself keeps working, only the visual hint goes.
- Move the nav icon fallback from 600px to 700px and keep the <=600px
  container tightening as its own query.

Verified in headless Chrome against the real stylesheet: no horizontal
overflow at any width (was 53px at 628px, 80px at 601px), and the placeholder
plus Ctrl+F cue never overlap (the same collision existed at ~1250px, where
the field now keeps 85px of text room instead of 2.8px).
2026-09-13 09:11:31 +08:00
Will Miao b5c1331911 feat(backend): make model existence checks other-aware
Implements the backend slice (B1-B7) of
lm-civitai-extension/docs/other-models-support.md, which lets the companion
browser extension detect, badge and download the opt-in Other Models types
(VAE / upscaler / text encoder / CLIP vision / ControlNet).

ModelLibraryHandler:
- _normalize_model_type() learns the CivitAI other aliases (vae, upscaler,
  textencoder, clip, clipvision, controlnet, other) and maps them to "other".
- _get_scanner_for_type() resolves "other" through the other scanner, but only
  while enable_other_models is on, so model-versions-status and
  model-version-download-status keep their legacy 400 when the feature is off.
- check_model_exists() / check_models_exist() consult the other scanner last
  (lora -> checkpoint -> embedding -> other) and report modelType "other".
  With the feature disabled both endpoints stay byte-identical to before and
  the other scanner is never touched.

DownloadManager:
- The four other-type default-path failures now carry a machine-readable
  "reason" (contract C4): other_models_disabled, other_sub_type_disabled,
  other_no_default_root, other_sub_type_undecidable. The user-facing "error"
  strings are unchanged; the key is additive and reaches the client because
  both download endpoints pass the result dict through verbatim.

Tests cover the opt-in on/off branches for both existence endpoints, mixed
lora + other ids in the batch endpoint, the CivitAI alias acceptance and the
400 regression for unknown types, and the exact reason/error pairs for all
four download failure modes.
2026-09-13 08:53:49 +08:00
Will Miao 37f2cba72d fix(ui): wrap toolbar controls by available space, not viewport width
The action bar forced .controls-right (Doctor) onto its own full-width row
below 1500px. On high-DPI displays a maximized window reports a CSS viewport
of ~1280-1440px, so the Doctor button wrapped even with ~500px of free space
next to the action buttons.

- .actions / .action-buttons now wrap only on real overflow (flex-wrap plus
  min-width: 0) instead of a viewport breakpoint.
- .controls-right relies on its auto margin to stay right-aligned on either
  row, so the width: 100% + margin-top: 8px override is gone.
- Lower the button min-width floor from 100px to 90px; the old floor alone
  made the row overflow the 1400px container at wide viewports.
- The <=1500px breakpoint now only tightens the buttons (min-width: 0,
  padding, gap) and no longer forces a wrap; drop the no-op 0.8em font-size
  override (base is already 0.85em).
- Keep the stacked mobile layout below 768px.

Verified in headless Chrome against the real stylesheet: one row with the
Doctor button inline down to 1200px (down to 1000px for shorter locales),
right-aligned wrap only when the content genuinely does not fit, and no
horizontal overflow at any width.
2026-09-13 08:42:43 +08:00
Will Miao 0e789cb38c revert(ui): move Doctor trigger back to the page toolbar
Undo the Doctor relocation from fe160134 while keeping that commit's
unrelated header changes (full-width header, 32px click targets,
role/tabindex plus Enter/Space activation).

- Restore the .doctor-control-group button in controls.html
- Drop the .doctor-toggle icon and the hamburger menu entry from the header
- Remove the Header.js 'doctor' dropdown action forwarding to the button
- Drop the header-scoped doctor-toggle styles
- Restore the .doctor-trigger styles (desktop and mobile) in doctor-modal.css
2026-09-13 08:13:55 +08:00
Will Miao f3b3393a16 i18n: translate Other Models feature strings into 9 locales
Complete the 36 keys left as [TODO: Translate] by the Other Models
feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet
management page and its opt-in toggles): settings.folderSettings.*,
other.*, initialization.other.*, toast.settings.otherRootsFailed and
banners.otherModels.*.

Model-type names (VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet)
stay in Latin per the model-type rule, so the five subType* values are
intentionally identical to en.json; "Other Models" is a page/feature
name and is translated. Document the new terminology in the i18n
translation guidelines and note the completed i18n phase in the plan.
2026-09-13 08:08:57 +08:00
Will Miao 480a3f4ea5 docs: record Other Models opt-in toggles in the plan (Phase 3)
Documents the settings keys and defaults, the enabled/disabled behaviour
matrix, the backend and frontend touch points, cache consistency, the
discoverability surfaces (hidden nav + announcement banner + download CTA)
and the minimal settings.json.example policy.
2026-09-13 07:59:28 +08:00
Will Miao 69a62d739c feat(frontend): opt-in Other Models toggles, hidden nav and announcement
- The Other nav entry is hidden while the feature is off
  (nav-item--hidden, toggled client-side after enabling) and now uses the
  fa-shapes icon.
- Shared utils/otherModels.js helpers (enable through the settings API,
  open the settings Library section) are reused by the disabled page, the
  announcement banner and the download modal.
- BannerService registers a one-time dismissible "other-models-announcement"
  banner while the feature is off; SettingsManager drops the banner and
  updates the nav when the master switch flips.
- A disabled download routing answer now surfaces a showActionToast with an
  "Enable Other Models" action.
- Settings UI: master toggle + five sub_type checkboxes whose default-root
  selects are disabled when unchecked; i18n keys added to en.json and synced
  (other locales keep TODO placeholders).
2026-09-13 07:59:28 +08:00
Will Miao 28fbb86dce feat(backend): gate Other Models behind opt-in management toggles
Other Models management is now opt-in: enable_other_models (default false)
plus the enabled_other_sub_types allow-list replace the unreleased additive
enabled_other_folders key.

- config._get_enabled_other_folder_keys() is the single scan gate; a new
  refresh_other_roots() rebuilds roots and preview roots on toggle.
- ModelScanner gains a _should_keep_cached_entry() hydration hook and
  on_library_changed(reconcile=...) so switching a sub_type off drops its
  entries (and hash/autov3 rows) at load time and switching it on rescans.
- OtherScanner filters location-derived entries accordingly.
- Other routes reject every other type while off (or a disabled sub_type) and
  expose an "other_disabled" page flag; download routing returns a disabled
  marker instead of guessing; the download manager refuses other-type
  downloads and default-path routing for switched-off sub_types.
- Doctor / init-status / refresh-all skip the other scanner while off; the
  scanner stays registered so staged pending-deletes still merge.
- Tests updated with explicit opt-in fixtures plus new gating coverage.
2026-09-13 07:59:28 +08:00
Will Miao f88fe2665c chore: keep settings.json.example minimal and document the rule
The example now only carries use_portable_settings, civitai_api_key and the
four core folder_paths keys (loras/checkpoints/unet/embeddings). Optional keys
such as the other-model folders, default_*_root and auto_organize_exclusions
are removed; their defaults live in DEFAULT_SETTINGS and reach the user's
settings.json on demand.

AGENTS.md now forbids adding optional/default keys to the example unless the
user explicitly asks for it.
2026-09-13 07:59:28 +08:00
Will Miao 3592eab48c Merge branch 'feature/other-models-page': Other Models page (VAE/upscaler/text encoder management + CivitAI downloads) 2026-09-12 16:41:09 +08:00
Will Miao 1dbdf5b00c docs: mark Phase 2 implemented in other-models plan 2026-09-12 15:56:47 +08:00
Will Miao fc3b2d7c13 feat(frontend): enable downloads on Other page and default_other_roots settings UI 2026-09-12 15:56:47 +08:00
Will Miao f2a7297cb9 feat(backend): CivitAI download support for other model types with subtype routing 2026-09-12 15:56:47 +08:00
Will Miao 57729375b6 docs: detail Phase 2 download design for Other Models page 2026-09-12 14:24:35 +08:00
Will Miao fa7ce725c1 feat(frontend): add Other Models page with subtype filter and badges 2026-09-12 11:25:51 +08:00
Will Miao 27da7b3ca3 feat(backend): add Other model type (VAE/upscaler/text encoder) scanner, service and routes 2026-09-12 11:25:51 +08:00
Will Miao 3070838a42 docs: plan for Other Models page (VAE/upscaler/text encoder management) 2026-09-12 09:29:42 +08:00
Will Miao fe160134d0 feat(ui): make app header full-width and move Doctor into header controls
- Drop the fixed max-width on .header-container so the header spans the
  viewport while the card grid keeps its own content width
- Keep header icon click targets at 32px at all breakpoints and add
  role/tabindex/aria-label plus Enter/Space activation
- Relocate the Doctor trigger from the page toolbar to the header icon
  group (also available on the statistics page and in the hamburger
  menu), removing the now-unused .doctor-trigger styles
2026-09-12 09:28:16 +08:00
Will Miao 6d3f82976f fix(scanner): serve folder tree from scan-recorded, persisted directory list (#1110)
The include_empty folder tree (download/move modals) walked every model
root synchronously on the event loop via get_all_folders(). On network
(NAS) roots this froze the whole server for the duration of the walk —
blocking WebSocket progress, aria2 RPC and the download queue — and the
5s TTL re-triggered the walk on nearly every modal interaction.

The scanners already visit every directory during cache scans, so record
the full directory list (including empty folders) there instead:

- _gather_model_data/_reconcile_cache collect directories during the
  existing walks; reconcile refreshes and persists the list even when no
  model files changed.
- ModelCache gains an all_folders field (None = never recorded).
- PersistentModelCache stores the list in a new folders table, with a
  cache_meta flag distinguishing 'recorded empty' from legacy snapshots.
- get_all_folders() is now a pure in-memory read. A legacy snapshot
  triggers a one-shot backfill walk in a worker thread (never on the
  event loop) that records and persists the list.
- Moves add the destination folder (and parents) incrementally instead
  of invalidating a TTL cache.
2026-09-11 23:03:24 +08:00
Will Miao 91b2735dad fix(recipes): make batch-import directory browser work on Windows (#1106)
The browse endpoint and its frontend were written with POSIX-only
assumptions, so on Windows pressing Browse immediately failed with
"Access denied to this directory":

- The frontend opened the browser at "/", which resolves to the
  current drive root on Windows.
- The allowlist check used Path("/"), which has no drive letter on
  Windows, so relative_to() rejected every drive-qualified path —
  anything outside the user profile was denied.

Fixes:
- Empty browse path now defaults to the user home directory instead of
  erroring; the frontend sends "" rather than the POSIX-only "/".
- The access check is platform-aware (drive-qualified on Windows,
  absolute on POSIX).
- Parent navigation uses the server-provided parent_path; the root
  check is now path.parent == path (the old str/anchor comparison
  self-looped at Windows drive roots).
- Browsing up from a Windows drive root shows a virtual list of
  available drives so users can switch drives without typing a path.
2026-09-11 22:23:55 +08:00
Will Miao 3112869a21 docs(technical): record Windows case-fold fallback follow-up in reconcile
The Windows-only case-insensitive match in ModelScanner._reconcile_cache
is the only pass left unverified by the recent realpath cleanup: realpath
may already cover case differences on Windows, and if the branch is ever
reachable it is O(files x cache entries). Records the reachability
question, the verification steps for a Windows run, and the two possible
fixes.
2026-09-11 22:23:55 +08:00
Will Miao aa630bf85b perf(services): skip per-file realpath work in cache reconciliation
A no-change Refresh still computed os.path.realpath for every model file
in the library and for every cached entry. Both values are only ever
consulted when a discovered file is missing from the cache, so on a
50k-file library they cost ~1.3s and ~0.6s while being used zero times.

- Compute the per-file realpath only after the exact cache match fails
- Build the physical-path alias map lazily on the first miss; the
  cross-run alias guard (overlapping roots / symlink layout changes)
  still keeps the cached entry instead of a delete + re-add, which would
  re-read metadata and re-hash the whole library
- Snapshot get_model_roots() once for the new-file pass instead of
  re-reading it for every added file
- Run the duplicate-path integrity pass only when the snapshot already
  contained duplicates or files were appended; a clean, unchanged cache
  has nothing to clean. Duplicates can only be introduced by external
  code rewriting raw_data or by this pass's own appends.

Zero-change reconcile drops from ~1400ms to ~120ms on 50k files, and an
alias flip still re-processes 0 files (#1108 investigation).
2026-09-11 22:23:55 +08:00
Will Miao e0052cd237 fix(download): align location-step root selection with backend diffusion routing
The download modal's location step decided between checkpoint and unet
roots using only the CivitAI file-type signal, while the backend also
falls back to DIFFUSION_MODEL_BASE_MODELS. Models like Anima (file type
"Model") were offered checkpoint roots in the UI even though
use_default_paths would route them to the unet root.

- Extract the two-tier decision into py/services/download_routing.py and
  reuse it in DownloadManager._execute_download
- Add POST /api/lm/download/routing so the UI asks the backend for the
  routing decision; fall back to the local file-type check on failure
- ModelVersionsTab: search both checkpoint and unet roots when resolving
  an existing version's download path
2026-09-11 12:41:03 +08:00
Will Miao 3cdc5ba7a2 fix(download): stop aria2 from leaking transfers when a download is cancelled
A cancel landing between aria2.addUri acceptance and the _transfers
registration found no tracked transfer, so DownloadManager tolerated the
"not found" and only cancelled the asyncio task — the daemon kept
downloading the file untracked while history showed the download as
cancelled.

- Register the gid in _transfers immediately after addUri returns,
  before any further await (state-store persist moved after it)
- Shield the addUri RPC so a mid-flight cancellation still learns the
  accepted gid and forceRemoves it before re-raising CancelledError
- On cancellation during the state persist, remove the daemon transfer
  unless it is paused (skip_download relies on paused gids surviving)
2026-09-11 08:14:14 +08:00
Will Miao 04485e384f feat(checkpoints): add Enrich HF Metadata (AI) to card context menu
The option only existed in the LoRA page menu. Move updateEnrichMenuItem
and enrichWithAgent into ModelContextMenuMixin so both pages share the
implementation, and add the menu item to the checkpoints template.
2026-09-09 17:30:42 +08:00
Will Miao a03dc4002f fix(move): recalculate sub_type when moving models across roots
Moving a checkpoint into a unet root (or vice versa) moved the file and
updated the in-memory cache, but three stale spots survived until a
manual cache rebuild:

- The moved .metadata.json kept the old sub_type, and the opportunistic
  sync_cache_from_metadata path (fired by get_model_metadata and example
  image metadata updates) trusted it, reverting the cache entry and the
  SQLite snapshot to the pre-move sub_type. Loader nodes filter strictly
  on sub_type, so the model stayed listed under the old type.
- The manager page discarded the move response's cache_entry, so the
  card badge (CKPT/DM) and context menu label kept showing the old type.

Fixes:
- move_model now re-resolves sub_type from the target location (new
  resolve_sub_type_for_path hook) and persists it into the moved
  .metadata.json.
- _sync_cache_from_metadata_impl runs desired entries through
  adjust_cached_entry so location-derived fields cannot be re-poisoned
  by stale metadata snapshots.
- MoveManager carries cache_entry.sub_type into the in-place card
  update so badge and context menu reflect the new type immediately.
2026-09-09 17:28:41 +08:00
Will Miao cc9d3bff42 fix(download): accept HuggingFace blob URLs in download dialog
detectUrlType only matched /resolve/ links, so pasting a HF web preview
(/blob/) URL fell through to direct-http and surfaced a misleading
'Invalid CivitAI URL format' error. Treat blob URLs as hf-resolve.
2026-09-09 11:47:17 +08:00
Will Miao 2672b3331b i18n: translate rematch summary modal strings into 9 locales 2026-09-09 11:07:42 +08:00
Will Miao 4963bf2b2e feat(recipes): show a summary modal after rematch runs
Replace the post-run toast cascade and the standalone L4 results modal
with a summary modal modeled on the batch download summary: 3-state
header, stat cards (matched / needs review / unresolved / errors),
an L4 review table with per-entry undo, and a copyable report. Wired
into the global, bulk and single-recipe rematch entries; complete
no-op runs keep the lightweight toast. Obsolete results-modal code,
styles and i18n keys are removed.
2026-09-09 10:38:10 +08:00
Will Miao 51cad6f852 i18n: translate recipe rematch options/results strings into 9 locales 2026-09-09 07:05:25 +08:00
Will Miao 1b5cbbbaa0 feat(recipes): add reconnect remediation paths for missing recipe LoRAs
- Snapshot pre-rematch entry state (reconnectSnapshot) so rematched
  entries can be undone via the existing restore flow
- Bulk missing-LoRA downloads mark unresolvable failures hash-invalid,
  flipping those entries from download to reconnect candidacy
- Recipe modal always offers a reconnect action next to download for
  missing LoRA entries
- Rematch runs collect an opt-in relaxed-matching choice (also reconnect
  missing models by file name) via a pre-run options dialog on the
  global, bulk and single-recipe entries
- L4 (filename-level) matches are listed in a results dialog with
  per-entry undo
2026-09-09 06:59:54 +08:00
Will Miao e747946f7a fix(onboarding): keep folder sidebar fixed-positioned during tutorial highlight
The .onboarding-target-highlight class sets position: relative, which
overrode .folder-sidebar's position: fixed (equal specificity, later
stylesheet). The sidebar left fixed positioning and moved in-flow, while
the spotlight/mask cutout stayed at the pre-highlight rect, leaving an
empty highlighted region during the folder sidebar step.
2026-09-07 19:32:58 +08:00
Will Miao 53fa22f39c fix(lora-loader): preserve repeated spaces inside lora names
Whitespace cleanup in cleanupLoraSyntax() and the autocomplete blur
formatter collapsed all whitespace runs, including inside <lora:...>
tags. A file named 'test -  0021.safetensors' was rewritten to
'test - 0021' in the node text, so runtime file resolution failed.

Protect lora tags with placeholders (or segment splitting) so only
whitespace between entries is normalized; names inside tags are kept
byte-for-byte.
2026-09-07 19:20:15 +08:00
Will Miao 82b34097fb refactor(metadata): remove vestigial top-level trainedWords field
The field dates back to a development-stage bug in the enrich-metadata
(agent) pipeline, which briefly wrote trigger words at the top level of
model metadata instead of the established civitai.trainedWords location.
The write path was fixed before the feature merged to main (PR #1013)
and never shipped in any release, so no writer has existed since.

Remove the leftover pieces:

- BaseModelMetadata.trainedWords field (py/utils/models.py); sidecars
  from that dev window now pass the key through _unknown_fields instead
- HF download handler's strip-empty-trainedWords special case, reverting
  to saving the metadata object directly (py/routes/handlers/hf_handlers.py)
- trainedWords in the LLM enrichment context (agent_service.py)
- matching fallbacks/fixtures in the enrich_hf_validation harness and
  post-processor test

Trigger words continue to live in civitai.trainedWords for all model
sources, which is what the UI, agent post-processor, and metadata sync
all read and write.
2026-09-07 16:24:15 +08:00
Will Miao a7995db009 fix(llm): add failure cooldown and lock for model catalog fetch 2026-09-07 10:17:48 +08:00
Will Miao 5ae4aef30e fix(llm): disable brotli for catalog fetch to prevent native crash (#1099, #1101)
models.dev is served by Cloudflare with brotli compression when the
client advertises it, and brotli is a required dependency here, so
aiohttp always negotiates br. A corrupted br stream can crash the
native decoder with a Windows access violation (a Python-level
exception handler cannot catch it), or produce garbage bytes.

Send an explicit "Accept-Encoding: gzip, deflate" header on the model
catalog and Ollama model-list requests so the server never returns
brotli. zlib handles corrupt gzip data by raising ContentEncodingError
(an aiohttp.ClientError subclass), which the existing handlers already
catch and degrade to a warning with an empty-catalog fallback.
2026-09-07 09:54:28 +08:00
willmiao 08023f0cd9 docs: auto-update supporters list in README 2026-09-06 14:29:39 +00:00
157 changed files with 13913 additions and 835 deletions
+9 -1
View File
@@ -166,7 +166,7 @@ The system runs in two modes:
### Model Types & Routes
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*`, `/other/*` 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`
@@ -190,6 +190,8 @@ The system runs in two modes:
- `py/config.py` manages folder paths for models and handles symlink mappings
- Auto-saves paths to `settings.json` in ComfyUI mode
- `settings.json.example` is intentionally minimal (see Important Notes); all
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
### Frontend UI Architecture
@@ -250,6 +252,12 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
## Important Notes
- ALWAYS use English for comments (per copilot-instructions.md)
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
(model-category folders, `default_*_root`, `auto_organize_exclusions`, etc.)
to this file unless the user explicitly asks for it. Defaults belong in
`DEFAULT_SETTINGS` in `py/services/settings_manager.py`.
- 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
+2 -2
View File
File diff suppressed because one or more lines are too long
+74 -3
View File
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
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
Source of truth: `locales/en.json` (10 locales, 1982 leaf keys; all locales share the exact
same key structure).
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
@@ -13,6 +13,26 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> 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.
>
> **Status (2026-09, Other Models):** the `other` model type (VAE / Upscaler / Text Encoder /
> CLIP Vision / ControlNet) and the Other Models opt-in toggles added 36 new keys; all of them
> are now translated in all 9 locales (terminology in §2 "Other Models feature"). There are no
> remaining `[TODO: Translate]` placeholders in any locale.
>
> **Status (2026-09, revision):** `other.disabled.description`, `banners.otherModels.content` and
> `settings.folderSettings.enableOtherModelsHelp` were refreshed in `en.json` to name all five
> sub_types (they had listed four, which read as "these are what enabling manages") and
> re-translated in all 9 locales in the same pass. `clip_vision` and `controlnet` are now both
> opt-in, so the first two describe **capability** and the third the **master switch**, not the
> default set — keep all three enumerating the full five (`VAE / upscaler / text encoder /
> CLIP vision / ControlNet` in `en`; locale slash-list casing follows each file's existing
> `VAE / Upscaler / Text Encoder / …` style, de compounds as `CLIP-Vision- und ControlNet-Ordner`).
>
> **Status (2026-09, "no folders found" state):** the Other Models page gained an *enabled but
> nothing to scan* empty state with 6 new keys (`other.noPaths.*`); translated in all 9 locales
> in the same pass. The `folder_paths` JSON snippet shown in that state lives in
> `templates/other.html`, **not** in the locale files, so it is never translated — only the
> surrounding prose is. Terminology added in §2.
---
@@ -222,6 +242,56 @@ and must be normalized. `en` = keep the English word as-is.
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
### Other Models feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet)
The `other` model type exposes five sub_types. They are **model-type names**, so they follow
R3 and stay in Latin in every locale. The `settings.folderSettings.subType*` values are
therefore **intentionally byte-identical to `en.json`** (same precedent as
`settings.priorityTags.modelTypes` / `checkpoints.modelTypes.checkpoint`) — a §6 sweep must
not "fix" them.
| Term | Rendering | Note |
|---|---|---|
| VAE | `VAE` everywhere | acronym, always upper-case |
| Upscaler | `Upscaler` everywhere | CivitAI `ModelType` name |
| Text Encoder | `Text Encoder` everywhere | de compounds as `Text-Encoder-Stammordner` |
| CLIP Vision | `CLIP Vision` everywhere | de compounds as `CLIP-Vision-Stammordner` |
| ControlNet | `ControlNet` everywhere | brand casing, capital N |
In prose these names sit next to localized nouns the same way `Diffusion Model` does
(zh `VAE 根目录`, ja `VAEルート`, ko `VAE 루트`, ru `Корневая папка VAE`).
**"Other Models" is the page/feature name, not a model type — translate it:**
| Locale | `other.title` | `header.navigation.other` |
|---|---|---|
| fr | Autres modèles | Autres |
| zh-CN | 其他模型 | 其他 |
| zh-TW | 其他模型 | 其他 |
| ja | その他のモデル | その他 |
| ko | 기타 모델 | 기타 |
| de | Weitere Modelle | Andere |
| es | Otros modelos | Otros |
| ru | Другие модели | Другое |
| he | מודלים אחרים | אחרים |
`settings.folderSettings.otherSubTypes` ("Managed Types") must name **model** types, matching
each locale's `header.filter.modelTypes` rendering (zh `管理的模型类型`, ja `管理するモデルタイプ`,
de `Verwaltete Modelltypen`, …).
The "no folders found" empty state (`other.noPaths.*`) uses two phrases that must stay
consistent whenever that copy is edited. `folder key` means the `folder_paths` key name
(`vae`, `upscale_models`, … — Latin per the table above); `on disk` means the folder must
physically exist:
| Phrase | Rendering |
|---|---|
| folder key | zh-CN 文件夹键 · zh-TW 資料夾鍵 · ja フォルダーキー · ko 폴더 키 · fr clé de dossier · de Ordnerschlüssel · es clave de carpeta · ru ключ папки · he מפתח תיקייה |
| on disk | zh-CN 在磁盘上 · zh-TW 在磁碟上 · ja ディスク上 · ko 디스크에 · fr sur le disque · de auf dem Datenträger · es en el disco · ru на диске · he בדיסק |
`settings.json` and `ComfyUI` stay verbatim in every locale; "reload this page" / "restart
LoRA Manager" reuse each locale's existing restart wording (`settings.extraFolderPaths.*`).
---
## 3. Cross-cutting confusion hot-spots (must-fix list)
@@ -312,8 +382,9 @@ blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipe
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").
(`CivitAI → CivArchive → Archive DB`), model-type names (`settings.priorityTags.modelTypes.*`,
`settings.folderSettings.subTypeVae``subTypeControlnet` — see §2), 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
+363
View File
@@ -0,0 +1,363 @@
# Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc.
**Status:** v2 — **Phase 1 implemented** (2026-09-12, commits `27da7b3c` backend + `fa7ce725` frontend; verified live against a running ComfyUI instance: scan/hash/sub_type-derivation/fetch/previews all green). **Phase 2 implemented** (2026-09-12, per §9 design; full pytest + vitest green). **Phase 3 implemented** (§11: opt-in management toggles; default off). **i18n done** (2026-09-13): all 36 new keys translated in the 9 non-English locales — the `[TODO: Translate]` placeholders left by the sync script during development are gone (see `docs/i18n-translation-guidelines.md` §2, "Other Models feature"). **Default set revised (pre-release):** only `vae` / `upscaler` / `text_encoder` are managed by default — `clip_vision` and `controlnet` are both opt-in (§2, §11.1.1).
**Scope (Phase 1):** scan + manage (list, search, filter, tags, folders, preview, rename, move, delete/exclude, CivitAI metadata fetch) for a new model type `other`, exposed as a new web page. **Phase 2 (§9):** one-click download from CivitAI for these types.
## 1. Goal
Today the manager supports three model types:
| page | model_type | sub_types |
|---|---|---|
| `/loras` | `lora` | `lora`, `locon`, `dora` |
| `/checkpoints` | `checkpoint` | `checkpoint`, `diffusion_model` |
| `/embeddings` | `embedding` | `embedding` |
Add a fourth page that manages "everything else" — VAE, upscalers, text encoders / CLIP, CLIP vision, optionally ControlNet — with a folder→sub_type mapping table so new ComfyUI folder categories can be added later by configuration, not code.
## 2. Locked Decisions
1. **Architecture: one scanner + one service + one page, sub_type derived by location.**
Replicates the checkpoint pattern (`CheckpointScanner` aggregates `checkpoints` + `unet` roots and derives `checkpoint` vs `diffusion_model` from the root containing the file, `py/services/checkpoint_scanner.py:384-415`). One `OtherScanner` aggregates all enabled folder roots; `resolve_sub_type_for_path()` maps each root to a sub_type. No per-category scanners.
2. **Naming: internal `model_type = "other"`, route prefix `/other`, page id `other`.**
- `misc` is rejected: `py/routes/misc_routes.py` already owns that name for system/settings routes (`/api/lm/settings`, `/api/lm/doctor/*`).
- `components` is rejected: `templates/components/` and `static/js/components/` directories would make `components.html` / `components.js` confusing neighbors.
- `other` matches CivitAI's `Other` fallback type semantics. The **display name** is an i18n string (`other.title`, e.g. "Other Models") and can be renamed later without touching code.
3. **sub_type values:** snake_case, aligned with CivitAI `ModelType` semantics:
| sub_type | ComfyUI `folder_paths` key(s) | CivitAI ModelType | enabled by default |
|---|---|---|---|
| `vae` | `vae` | `VAE` | yes |
| `upscaler` | `upscale_models` | `Upscaler` | yes |
| `text_encoder` | `text_encoders`, `clip` (legacy) | `TextEncoder` (CLIP is retired upstream) | yes |
| `clip_vision` | `clip_vision` | `CLIPVision` | no (mapping present, opt-in) |
| `controlnet` | `controlnet` | `Controlnet` | no (mapping present, opt-in) |
New folder categories = one line in the mapping table (see §4.1).
**Why only three are on by default** (revised in Phase 3, before release):
VAE, upscalers and text encoders are dependency-style assets every pipeline
needs, and "which one am I actually using" is the recurring problem they
solve. `clip_vision` and `controlnet` are workflow-driven instead
(IPAdapter/SVD image conditioning; per-workflow ControlNet variants), and
ControlNet libraries routinely run to dozens of files, so both are treated
symmetrically as opt-in. Enumerating all five as "the default set" was not
defensible on demand breadth alone.
4. **Phase 1 = scan/manage only.** Downloads from CivitAI (`download_manager.py` type mapping, default-root settings keys, download routing) are Phase 2 (§9). CivitAI **metadata fetch** for existing files IS in Phase 1 (hash-based lookup is type-agnostic; only the type-validation hook needs new values).
5. **Out of scope (default off, revisit later):** usage statistics buckets, recipe matching (`recipe_scanner.py` only merges lora+checkpoint scanners), statistics page, embeddings re-classification (stays its own page — merging would be a breaking change).
## 3. Why This Works With Minimal Churn
- `ModelScanner` (`py/services/model_scanner.py:93`) is specialized entirely via constructor params (`model_type`, `model_class`, `file_extensions`) + optional hooks (`adjust_metadata`, `adjust_cached_entry`, `resolve_sub_type_for_path`, `model_scanner.py:1429-1443`).
- `BaseModelService` subclasses can be one method (`EmbeddingService` implements only `format_response`, `py/services/embedding_service.py:12`).
- Routes: `ModelServiceFactory.register_model_type()` (`py/services/model_service_factory.py:120-136`) + `COMMON_ROUTE_DEFINITIONS` (`py/routes/model_route_registrar.py:23-149`) generate the full `/api/lm/{prefix}/*` surface (~50 endpoints) plus the `GET /{prefix}` page route.
- `PersistentModelCache` (`py/services/persistent_model_cache.py:526-606`) is a single `models` table keyed `(model_type, file_path)` with `model_type` as free text — **zero schema change**.
- Frontend `apiConfig.js` (`static/js/api/apiConfig.js:51`) generates all endpoints from the model-type string; `ModelCard.js:670-675` renders the sub_type badge from data; the checkpoints page already demonstrates the "one page, multiple sub_types" filter (`header.html:298`).
## 4. Backend Changes
### 4.1 New constants — `py/utils/constants.py`
```python
# folder_paths key -> sub_type; single source of truth for extensibility
OTHER_MODEL_FOLDER_SUBTYPES = {
"vae": "vae",
"upscale_models": "upscaler",
"text_encoders": "text_encoder",
"clip": "text_encoder", # legacy ComfyUI key
"clip_vision": "clip_vision",
"controlnet": "controlnet",
}
DEFAULT_OTHER_MODEL_FOLDERS = ("vae", "upscale_models", "text_encoders", "clip", "clip_vision")
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
# CivitAI model.type values accepted for this page (fetch-metadata validation)
VALID_OTHER_CIVITAI_TYPES = {"vae", "upscaler", "textencoder", "clipvision", "controlnet", "other"}
```
Also extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:90`) if user-model queries should include these types.
### 4.2 New files (mirror the embedding/checkpoint implementations)
1. **`py/utils/models.py`** — add `OtherModelMetadata(BaseModelMetadata)`: default `sub_type="vae"` placeholder overridden by scanner hook; `from_civitai_info` mapping CivitAI types → our sub_types (`TextEncoder``text_encoder`, `CLIPVision``clip_vision`, `Upscaler``upscaler`, `VAE``vae`, `Controlnet``controlnet`, else `other`-ish fallback to folder-derived sub_type).
2. **`py/services/other_scanner.py`** — `OtherScanner(ModelScanner)`:
- `model_type="other"`, extensions: reuse the checkpoint set (`safetensors/pt/pt2/bin/pth/pkl/sft/gguf`).
- `get_model_roots()`: iterate `OTHER_MODEL_FOLDER_SUBTYPES` ∩ enabled keys, pull each from `config` (§4.3); dedupe; build `root → sub_type` map (normalized abspaths; multiple keys may share a sub_type).
- Implement all three hooks like `CheckpointScanner` (`checkpoint_scanner.py:384-415`): `resolve_sub_type_for_path` by longest-prefix root match, `adjust_metadata`, `adjust_cached_entry` (sub_type is re-derived on cache load, never persisted).
- **Lazy hashing, checkpoint-style**: text encoders (T5-XXL ≈ 10 GB) make eager sha256 painful. Copy the `hash_status="pending"` + singleflight `calculate_hash_for_model` pattern from `CheckpointScanner`.
3. **`py/services/other_model_service.py`** — `OtherModelService(BaseModelService)`, `format_response` only (no usage_count, like `EmbeddingService`).
4. **`py/routes/other_routes.py`** — `OtherRoutes(BaseModelRoutes)`, `template_name="other.html"`, hooks:
- `_validate_civitai_model_type``VALID_OTHER_CIVITAI_TYPES`
- `_get_expected_model_types`, `_parse_specific_params` (no type-specific download params in Phase 1)
- `initialize_services()` on `app.on_startup` pulling `ServiceRegistry.get_other_scanner()`.
### 4.3 `py/config.py`
- New `other_roots` property: for each enabled key in `OTHER_MODEL_FOLDER_SUBTYPES`, `folder_paths.get_folder_paths(key)` (plugin mode) — standalone mode needs nothing new: `MockFolderPaths` (`standalone.py:66-105`) already serves arbitrary keys from `settings.json.folder_paths`.
- Follow the existing per-type recipe: an `_prepare_other_paths()` (dedupe + symlink registration; also **cross-scanner overlap detection** — warn if an `other` root is already covered by checkpoints/unet/embedding roots, mirroring the checkpoint/unet overlap check).
- Wire into: `_apply_library_paths`, `_symlink_roots()`, `_rebuild_preview_roots()` (hard requirement — preview images are served per registered root), `save_folder_paths_to_settings()`.
### 4.4 Existing-file edits (the "type string scatter" — each is a small branch/entry)
| file | change |
|---|---|
| `py/services/model_service_factory.py:120` | register `("other", OtherModelService, OtherRoutes)` in `register_default_model_types()` |
| `py/services/service_registry.py` | add `get_other_scanner()` (mirror `:297` `get_embedding_scanner`) |
| `py/services/model_scanner.py:67` | `PAGE_TYPE_MAP['other'] = 'other'` (WebSocket progress) |
| `py/services/base_model_service.py:896-906` | `get_model_types()` branch → `VALID_OTHER_SUB_TYPES` |
| `py/lora_manager.py` | `_initialize_services` scanner task list (`:219-242`), `_cleanup` cancel list (`:463`), `_cleanup_backup_files` roots (`:327-330`) |
| `py/routes/handlers/misc_handlers.py` | `scanner_getters` (`:657-661`) + `scanner_factories` (`:757-759`) so Doctor / init-status / refresh-all see the new scanner |
| `py/services/pending_delete_service.py` | `_PAGE_TYPE` map (`:57-61`) + scanner getter list (`:983-985`) |
| `py/metadata_ops/__init__.py:36-38` | `SCANNER_TYPE_MAP['other']` |
| `settings.json.example` | document optional `folder_paths` keys: `vae`, `upscale_models`, `text_encoders`, `clip_vision` |
**Explicitly NOT touched in Phase 1:** `py/services/download_manager.py`, `py/services/download_routing.py`, `py/services/settings_manager.py` default-root keys, `py/routes/stats_routes.py`, `py/utils/usage_stats.py`, `py/services/recipe_scanner.py`, `py/metadata_collector/`, `py/nodes/`.
**Zero-change confirmations (verified):** `PersistentModelCache`, `ModelUpdateService`, `DownloadedVersionHistoryService`, `MetadataSyncService` + provider chain (type-agnostic hash lookups), `ModelFileService` / `ModelMoveService` / `ModelLifecycleService` (scanner + model_type injected), `ModelCache` / `ModelHashIndex`, `AutoV3BackfillService`.
## 5. Frontend Changes
1. **`static/js/api/apiConfig.js`** — `MODEL_TYPES.OTHER = 'other'`; `MODEL_CONFIG.other` entry (displayName, singularName, `supportsMove`, `supportsBulkOperations`; no letter filter); endpoints come free from `getApiEndpoints()` (`:51`).
2. **`static/js/api/otherApi.js`** — thin `OtherApiClient extends BaseModelApiClient` (mirror `embeddingApi.js`); register in `modelApiFactory.js`.
3. **`static/js/other.js`** — page entry (mirror `embeddings.js`): `appCore.initialize()` + `createPageControls('other')` + `initializePageFeatures()` + `ModelDuplicatesManager` + `initActiveFiltersSync('other')`.
4. **Controls & context menu**`OtherControls extends PageControls` and `OtherContextMenu` (start from the embedding variants — the smallest); add branches in the two factories (`components/controls/index.js:15`, `components/ContextMenu/index.js:15`). Context-menu template block lives in `templates/other.html` (`{% block additional_components %}`, the checkpoints/embeddings pattern — do NOT touch the shared `context_menu.html`).
5. **`templates/other.html`** — copy `embeddings.html`: same content blocks (controls + breadcrumb + duplicates banner + folder sidebar + `#modelGrid`), `data-page="other"`, main script `/loras_static/js/other.js`.
6. **`templates/components/header.html`** — nav entry (`:23-43`, active when `request.path.startswith('/other')`); enable the `modelTypes` sub_type filter panel for `other` (`:298-305` pattern from checkpoints); check search-options panel conditions (`:199-224`).
7. **`static/js/utils/constants.js`** — `MODEL_SUBTYPE_ABBREVIATIONS` (`:115`): `vae→VAE`, `upscaler→UPS`, `text_encoder→TE`, `clip_vision→CV`, `controlnet→CN`; matching `MODEL_SUBTYPE_DISPLAY_NAMES` (`:99`). (Unknown fallback already uppercases 4 chars, but explicit mappings read better.)
8. **`static/js/core.js:110` `getPageType()`** — verify `data-page="other"` flows through `state.pages` generically; add only if the page list is enumerated anywhere.
9. No change to `web/comfyui/top_menu_extension.js` (it opens `/loras`; page-to-page nav is the header bar).
## 6. i18n
- `locales/en.json`: add `other.title` (e.g. "Other Models") + minimal `other.contextMenu.*` / `other.modelTypes.*` keys; reuse `modelCard.*`, `loras.contextMenu.*`, `common.*` wherever possible (the established pattern — checkpoints/embeddings already reuse lora keys).
- Run `python scripts/sync_translation_keys.py`; leave `[TODO: Translate]` placeholders in other locales (per `docs/i18n-translation-guidelines.md` §7 — do not translate proactively).
## 7. Testing
Follow existing conventions (`pytest.ini`, `tests/frontend/` vitest):
1. **Backend (pytest, async where needed):**
- `OtherScanner` root aggregation + `resolve_sub_type_for_path` (file under `vae/` root → `vae`; `text_encoders` and legacy `clip` both → `text_encoder`; disabled `controlnet` root not scanned).
- Cache round-trip: sub_type re-derived via `adjust_cached_entry` (not persisted).
- Lazy hash: `hash_status="pending"` default; `calculate_hash_for_model` singleflight.
- `OtherRoutes` registration smoke test: `/api/lm/other/...` endpoints exist; `_validate_civitai_model_type` accepts `vae`/`upscaler`/`textencoder`, rejects `lora`.
- Config: `other_roots` in both modes (mock `folder_paths`, and standalone `settings.json.folder_paths`).
2. **Frontend (vitest + jsdom, `tests/frontend/`):**
- `apiConfig`: `getApiEndpoints('other')` URL shapes; `modelApiFactory` returns the Other client.
- `ModelCard` badge rendering for new sub_types.
- `createPageControls('other')` / `createPageContextMenu('other')` factories.
3. **Manual UI verification by the user** (per AGENTS.md — no sandbox/browser automation): page loads, scans a real library, sub_type filter + badges, context menu actions.
## 8. Execution Order
1. `constants.py` + `OtherModelMetadata` + `config.py` roots
2. `OtherScanner` (+ registry, factory, `PAGE_TYPE_MAP`) → scanner unit tests green
3. `OtherModelService` + `OtherRoutes` + handler/registrar wiring + `lora_manager.py` lifecycle → route tests green
4. Doctor/pending-delete/metadata-ops scatter entries
5. Template + header nav + frontend API/controls/context-menu/card badges → vitest green
6. i18n keys + sync script
7. `pytest` + `npm test` full runs; hand to user for manual UI check
## 9. Phase 2 Detailed Design — CivitAI Downloads for `other`
Designed 2026-09-12 against the Phase-1 code on this branch; decisions marked **[locked]** follow the same recommendations the feature owner approved for Phase 1.
### 9.1 Download pipeline touch points
Flow: `POST /api/lm/download-model` (`py/routes/model_route_registrar.py:104`; GET variant `:105` for the browser extension) → `ModelDownloadHandler.download_model` (`model_handlers.py:1740`) → `DownloadModelUseCase.execute``DownloadCoordinator.schedule_download``DownloadManager.download_from_civitai` (`download_manager.py:386`) → `_execute_original_download` (`:1415`). Inside, seven scatter points need an `other` branch:
1. **Type map** (`:1496-1507`): accept `model.type.lower() in VALID_OTHER_CIVITAI_TYPES``model_type = "other"` (reuses the Phase-1 set, incl. `"other"` itself).
2. **Early version-exists gate** (`:1436-1463`): add `other_scanner.check_model_version_exists`.
3. **File-level exists gate** (`:1640-1655``_find_local_file_entry` `:320-346``_get_scanner_for_model_type` `:230-236`): add explicit `other` branch. **Trap**: the function currently falls through to the lora scanner for unknown types — `"other"` would silently dedupe against loras. Also narrow the fall-through to `"lora"` only / raise on unknown.
4. **Version-level fallback gate** (`:1656-1688`): add `elif model_type == "other"`.
5. **Default-root selection** (`:1690-1727`): for `other`, first resolve sub_type (§9.2), then read `default_other_roots[sub_type]` (§9.3); if sub_type is undecidable or no default root configured → error guiding the user to pick a folder explicitly.
6. **Metadata class selection** (`:1909-1928`) + `_build_metadata_for_resume` (`:969-981`): add `OtherModelMetadata.from_civitai_info` branches.
7. **Post-download cache write** (`_execute_download_pipeline` `:2622-2679`): add `other` scanner branch; `adjust_metadata` re-derives sub_type from the on-disk root automatically. `_get_supported_extensions_for_type` (`:2720-2744`): `other` reuses the checkpoint extension set.
Hooks: `_record_downloaded_version_history` (model_type is free text — zero change); `_sync_downloaded_version` (`:1984` → scanner dispatch `:2130-2135`) add `other`; `py/utils/example_images_download_manager.py` scanner dispatch at `:411-421`, `:591-601`, `:1089+` — add `other` at all three (silent no-scanner otherwise).
Path templates: `get_download_path_template("other")` is unset, so `other` resolves to a **flat** layout (empty template) — downloads land directly under the resolved sub_type root. This is deliberate: other-model roots are already split per sub_type (`default_other_roots`), and `priority_tags` has no `other` entry, so `{first_tag}` would fall back to an arbitrary CivitAI tag and scatter files into unstable folders. Users who want nesting can still set `download_path_templates["other"]` in `settings.json`. See `DEFAULT_DOWNLOAD_PATH_TEMPLATES` (`py/utils/constants.py`) and `DEFAULT_PATH_TEMPLATES` (`static/js/utils/constants.js`).
### 9.2 File-level routing (model.type / file.type → sub_type) **[locked]**
Table-driven, mirroring Phase 1. New in `py/utils/constants.py`:
```python
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
"VAE": "vae", "Upscaler": "upscaler", "Text Encoder": "text_encoder",
"Vision Encoder": "clip_vision", "CLIPVision": "clip_vision",
"ControlNet": "controlnet",
}
```
`download_routing.py` gains `resolve_other_download_sub_type(civitai_model_type, file_types, selected_file_type=None)` with fixed priority:
1. **Explicit user file pick** (`file_params` from #1058's `_resolve_target_file`) — if the picked file's type maps, it wins even when model.type is `Checkpoint`.
2. **model.type** via the existing `CIVITAI_TYPE_TO_OTHER_SUB_TYPE` (`constants.py:120-127`).
3. **file.type fallback** — only when model.type maps to nothing (e.g. model.type `Other` or retired `CLIP`). MUST NOT override a mapped model.type: checkpoint models routinely bundle VAE/Text Encoder component files, and unconditional file-type routing would misroute them.
4. Still undecidable → `None`; `use_default_paths` errors and the UI offers all other roots for manual selection.
HTTP: extend `DownloadRoutingHandler.get_download_routing` (`download_routing_handlers.py:23`) with an `other` branch returning `{root_kind: "other", sub_type: ...}`; add `GET /api/lm/other/roots_by_subtype` in `OtherRoutes.setup_specific_routes` (data from `config._prepare_other_paths`'s per-key roots, aggregating `text_encoders` + legacy `clip` under `text_encoder`).
### 9.3 Settings: single dict key `default_other_roots` **[locked]**
Rejected: four flat keys (`default_vae_root`…) — each flat key costs ~13 touch points in `settings_manager.py` (defaults `:82-85`, `_check_and_auto_set` `:890-895`, `set()` `:1621-1628`, `_update_active_library_entry` `:738-805`, upsert/create signatures `:1953-2132`, `_build_library_payload` `:552-612`, `_sync_active_library_to_root` `:519-547`, three library constructors, frontend `DEFAULT_SETTINGS_BASE`), repeated per future sub_type.
Chosen: one mapping key `default_other_roots: {sub_type: path}`, copying the `extra_folder_paths` precedent (generic Mapping handling at `:533-535`, `:573-578`, `:763-767`). `_check_and_auto_set` generalizes to per-sub_type candidates (union over that sub_type's folder keys — `text_encoder``text_encoders` + `clip`). `set()` validates keys against `VALID_OTHER_SUB_TYPES`.
Also fix the Phase-1 omission: add `"other_scanner"` to `_notify_library_change` (`:2150-2156`) and `_notify_model_name_display_change` (`:1795-1800`) — otherwise switching libraries leaves the other page stale.
### 9.4 Settings UI
- `templates/components/modals/settings/library.html:34-40`: sub_type selectors after the existing four `setting_select`s (Jinja loop; controlnet selector only when `enabled_other_folders` includes it). Dict-subkey save helper `saveOtherRootSetting(subType, value)` alongside the flat `saveSelectSetting`.
- `static/js/managers/SettingsManager.js:1547-1697`: `loadOtherRoots()` mirroring `loadUnetRoots()`, fed by `/api/lm/other/roots_by_subtype`; current values from `state.global.settings.default_other_roots`. `state/index.js:24` `DEFAULT_SETTINGS_BASE` += `default_other_roots: {}`.
- Optional: one `other` row in the download-path-template block (`library.html:153-211`).
- i18n: `settings.folderSettings.*` keys into `locales/en.json` + sync script; other locales keep `[TODO: Translate]`.
- Settings GET (`misc_handlers.py:1528-1536`) already returns all non-sensitive keys — new key reaches the frontend for free.
### 9.5 Frontend download entry
- `templates/components/controls.html:83`: drop the `page_id != 'other'` exclusion on the download button (keyboard shortcut D self-enables via `PageControls.js:196-198`).
- `OtherControls.js:22-55`: add `showDownloadModal: () => downloadManager.showDownloadModal()` (mirror `EmbeddingsControls.js:43-45`).
- `DownloadManager.js` `proceedToLocationContent` (`:955-1017`): add `_resolveOtherSubType()` (mirror `_resolveIsDiffusionModel` `:1026`): selected file type → `/api/lm/download/routing``otherApiClient.fetchModelRoots(subType)` (new); default-root preselect reads `default_other_roots[subType]` instead of `` `default_${singularType}_root` `` (`:974`). Undecidable → list all other roots (`/api/lm/other/roots`) for manual pick; an explicit save_dir skips backend default-root logic, so the two paths cannot disagree.
- `ModelVersionsTab` download buttons are modelType-generic and already work via `getModelApiClient('other')`; context menu has no CivitAI download entry — no change.
- Version-list type validation (`get_civitai_versions``_validate_civitai_model_type`) already accepts `VALID_OTHER_CIVITAI_TYPES` from Phase 1.
### 9.6 CivitAI type mapping decisions **[locked]**
- Download accepts exactly `VALID_OTHER_CIVITAI_TYPES` (`VAE, Upscaler, TextEncoder, CLIP, CLIPVision, Controlnet, Other`) — reuse the Phase-1 tables; do NOT create new ones.
- Extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:133-137`) with the 7 aliases, and point them at the other scanner / `"other"` history bucket in `misc_handlers.py` (`type_scanner_map` `:2793-2797`, `downloaded_version_map` `:2821-2827`) — otherwise creator pages silently filter these models while downloads claim support.
- Fix (small Phase-1 bug): `OtherModelMetadata.from_civitai_info` (`py/utils/models.py:343`) reads `version_info.get("type")`, but the type lives at `version["model"]["type"]` — the mapping never fires and always degrades to the placeholder. Read `version_info.get("model", {}).get("type")` instead. (`CheckpointMetadata:290` has the same shape; leave it alone here.)
### 9.7 Tests
Existing base: `tests/services/test_download_manager_basic.py` (incl. `test_download_rejects_unsupported_model_type` `:1336`), `test_download_manager_error.py`, `test_download_manager_concurrent.py`, `tests/integration/test_download_flow.py`, `tests/services/test_settings_manager.py`; frontend `tests/frontend/managers/downloadManager.routing.test.js`, `settingsManager.library.test.js`.
Add: (1) `resolve_other_download_sub_type` unit tests — every priority tier, bundled-component anti-misrouting, undecidable → None, civarchive-shaped payload; (2) download_manager — six model.types accepted → other scanner (mock), unknown still rejected, no lora-scanner fall-through, per-sub_type default roots + unconfigured error, resume metadata, extension set; (3) settings_manager — `default_other_roots` defaults/auto-set (incl. text_encoder dual-key union)/library sync/upsert passthrough/illegal sub_type rejection; (4) routes — `/api/lm/download/routing` other branch, `roots_by_subtype` shape; (5) example-images dispatch accepts `other` (3 sites); (6) vitest — `_resolveOtherSubType` + root select + default preselect, `loadOtherRoots`; (7) user-models existsLocally for VAE.
### 9.8 Phase 2 file list
Backend: `py/utils/constants.py`, `py/services/download_routing.py`, `py/routes/handlers/download_routing_handlers.py`, `py/services/download_manager.py`, `py/utils/example_images_download_manager.py`, `py/services/settings_manager.py`, `py/utils/models.py`, `py/routes/other_routes.py`, `py/routes/handlers/misc_handlers.py`, `settings.json.example`.
Frontend/templates: `templates/components/controls.html`, `static/js/components/controls/OtherControls.js`, `static/js/managers/DownloadManager.js`, `static/js/api/otherApi.js`, `templates/components/modals/settings/library.html`, `static/js/managers/SettingsManager.js`, `static/js/state/index.js`, `locales/en.json` + sync.
## 10. Risks / Open Questions
- **Root overlap**: a user may point `text_encoders` at a directory already scanned as checkpoints/unet. Realpath dedup inside one scanner won't catch cross-scanner overlap → the `_prepare_other_paths` overlap warning (§4.3) is the mitigation; duplicate cards across pages are cosmetic, not corrupting (cache keyed by `(model_type, file_path)`).
- **Huge text encoders + lazy hash**: CivitAI fetch for a pending-hash model must trigger on-demand hash like checkpoints do — verify that flow (`calculate_hash_for_model`) is reachable from the `other` routes' fetch-metadata handler.
- **Retired CivitAI types**: `CLIP`/`CLIPVision` are retired upstream (grandfathered for existing models); metadata fetch must tolerate both retired and current types — `VALID_OTHER_CIVITAI_TYPES` includes them deliberately.
- **Standalone users** must add the new `folder_paths` keys to `settings.json` themselves; document in `settings.json.example` and the feature doc.
- **Page display name** is i18n-only; if "Other Models" tests poorly, rename `other.title` without code changes.
### Phase 2 risks
- **Bundled component files**: checkpoint models routinely ship VAE/Text Encoder component files — file.type routing must stay a fallback (or explicit user pick), never an override (§9.2 priority is load-bearing; test it).
- **`_get_scanner_for_model_type` lora fall-through** (`download_manager.py:236`): without an explicit `other` branch, dedupe checks run against the lora scanner — the most insidious trap in Phase 2.
- **text_encoder dual folder keys** (`text_encoders` + legacy `clip`): default-root candidates, `roots_by_subtype`, and auto-set must all merge both keys; miss one and the default-root dropdown comes up empty.
- **Undecidable sub_type** (model.type `Other` + unknown file types): must error and ask, never silently default to the vae folder.
- **Lazy hash after download**: downloads carry CivitAI SHA256 (no recompute needed) — ensure the post-download cache write doesn't leave `hash_status="pending"`, or the next metadata fetch re-hashes a 10 GB file.
- **CivArchive source**: same `_execute_original_download` path, same payload shape — cover it once in tests.
## 11. Phase 3 — Opt-in Management Toggles (implemented)
Designed 2026-09-13 against the Phase-1/2 code. Other Models is **opt-in**: after
Phase 3 the feature ships disabled, so no other-model folder is scanned and the
page shows an "enable" empty state until the user turns it on.
### 11.1 Settings (global, not per-library)
| key | type | default | meaning |
|---|---|---|---|
| `enable_other_models` | bool | `false` | master switch |
| `enabled_other_sub_types` | list[str] | `["vae","upscaler","text_encoder"]` | allow-list; `clip_vision` and `controlnet` are opt-in (see §2) |
`enabled_other_folders` (the unreleased, additive, no-UI backend key) was removed
and replaced by the sub_type-level allow-list; there is no migration because the
feature never shipped. `text_encoder` expands to `text_encoders` + legacy `clip`
via `OTHER_SUB_TYPE_FOLDER_KEYS`.
The default allow-list lives on five surfaces that must stay in sync:
`DEFAULT_ENABLED_OTHER_SUB_TYPES` (`py/utils/constants.py`), `DEFAULT_SETTINGS`
(`py/services/settings_manager.py`), the two `DEFAULT_SETTINGS_BASE` /
`createDefaultSettings` lists (`static/js/state/index.js`), the
`updateOtherModelsControls()` fallback (`static/js/managers/SettingsManager.js`)
and the server-rendered Jinja fallback
(`templates/components/modals/settings/library.html`).
### 11.1.1 Legacy key handling in `Config._init_other_paths`
ComfyUI's `folder_paths` rewrites legacy names before every access (`clip`
`text_encoders`, `unet``diffusion_models`) and registers both legacy
directories under the canonical key, so `get_folder_paths("clip")` returns
exactly the same list as `get_folder_paths("text_encoders")`. Querying both keys
made the overlap guard fire twice with `please fix your path configuration` for a
configuration the user cannot fix. `Config._collapse_legacy_folder_keys()` now
drops a key when the host exposes `map_legacy` and resolves it to another queried
key, and `_prepare_other_paths()` downgrades a same-`sub_type` duplicate to
`debug` (a cross-`sub_type` collision still warns). In standalone mode
`MockFolderPaths` has no `map_legacy` and its keys are independent
`settings.json` entries, so every key is still queried there.
`settings.json.example` intentionally stays minimal (only `use_portable_settings`,
`civitai_api_key`, and the four core `folder_paths` keys: `loras`, `checkpoints`,
`unet`, `embeddings`). Optional keys — including the other-model folder paths and
`enable_other_models` — are NOT documented there; they live in `DEFAULT_SETTINGS`
and reach the user's `settings.json` on demand. This supersedes the Phase-1/Phase-2
notes that proposed adding the other-model folder keys to the example.
### 11.2 Behaviour matrix
| state | scan | nav / `/other` | other downloads | `default_other_roots` | Doctor / refresh-all |
|---|---|---|---|---|---|
| master off | nothing (`other_roots == []`) | nav entry hidden (`nav-item--hidden`); `/other` still renders the disabled empty state + Enable button; one-time dismissible announcement banner on first visit | rejected | preserved, never auto-set | scanner skipped |
| sub_type off | that sub_type's folder keys excluded | page keeps working, type disappears from data | auto-routing refused (manual folder still allowed) | preserved, not preselected | normal |
| all on (after enabling) | Phase-1/2 behaviour | normal | normal | normal | normal |
### 11.3 Backend touch points
- `py/utils/constants.py``DEFAULT_ENABLED_OTHER_SUB_TYPES`, `OTHER_SUB_TYPE_FOLDER_KEYS`, `normalize_other_sub_types`.
- `py/config.py``_get_enabled_other_folder_keys()` is the single scan gate (master switch + allow-list); new `refresh_other_roots()` rebuilds roots + preview roots on toggle.
- `py/services/settings_manager.py` — new defaults, `set()` normalization, `is_other_models_enabled()` / `get_enabled_other_sub_types()` / `is_other_sub_type_enabled()`, and `_apply_other_model_settings_change()` which reapplies config and calls `other_scanner.on_library_changed(reconcile=True)`.
- `py/services/model_scanner.py``_should_keep_cached_entry()` hydration hook (default keep) plus `on_library_changed(reconcile=...)` / `initialize_in_background(reconcile=...)`; the hook filters `raw_data` and the hash/autov3 index rows.
- `py/services/other_scanner.py` — drops persisted entries whose folder is no longer a managed root (sub_type is location-derived, so config is the source of truth).
- `py/routes/other_routes.py``_validate_civitai_model_type` rejects everything while off / mapped-but-disabled sub_types; `_get_page_context_provider()` injects `other_disabled` into the template.
- `py/routes/handlers/model_handlers.py` + `base_model_routes.py` — optional `page_context_provider` hook on `ModelPageView`.
- `py/routes/handlers/download_routing_handlers.py` — returns `{sub_type: None, disabled: true, reason}` instead of guessing.
- `py/services/download_manager.py` — rejects other-type downloads while off; disabled sub_type refuses default-path routing with a "pick a folder" error.
- `py/routes/handlers/misc_handlers.py` — Doctor / init-status / refresh-all skip the other scanner while off (`_active_scanner_factories` / `_active_scanner_getters`).
- `py/services/pending_delete_service.py` — deliberately untouched: the scanner stays registered so staged deletes still merge.
### 11.4 Frontend
Discoverability: the nav entry is hidden while the feature is off, and three
lightweight surfaces replace it — a one-time announcement banner, the download
toast, and the settings toggle itself.
- `templates/components/header.html` + `static/css/components/header.css``nav-item--hidden` class (server-rendered when off, client-toggled after enabling) and the `fa-shapes` icon.
- `templates/other.html``other_disabled` branch in `content` + `main_script`; page-scoped CSS for the empty state.
- `static/js/other_disabled.js` — boots `appCore` (shared header) and delegates to the shared enable helper.
- `static/js/utils/otherModels.js` — shared `enableOtherModels()` (POST settings + reload) and `openOtherModelsSettings()` (settings modal on the Library section); used by the disabled page, the banner and the download modal.
- `static/js/managers/BannerService.js``other-models-announcement` banner (only when off and not dismissed; `priority: 0`, dismissal persisted via `dismissed_banners`) with Enable / Open Settings actions; `removeOtherModelsAnnouncement()` drops it without persisting a dismissal.
- `templates/components/modals/settings/library.html` + `SettingsManager.updateOtherModelsControls()` / `saveEnabledOtherSubTypes()` / `updateOtherModelsNavVisibility()` — master toggle + five sub_type checkboxes; unchecked/disabled sub_types have their default-root select disabled.
- `static/js/managers/DownloadManager.js` — a disabled routing answer surfaces a `showActionToast` with an "Enable Other Models" action (opening settings) and falls back to manual selection.
- i18n: `settings.folderSettings.*`, `other.disabled.*` and `banners.otherModels.*` keys in `locales/en.json` + `scripts/sync_translation_keys.py` (other locales keep `[TODO: Translate]`).
### 11.5 Cache consistency
- Disabling purges rows from the in-memory view at hydration time (the
`_should_keep_cached_entry` hook) and from SQLite on the reconcile triggered by
the toggle; the `.metadata.json` sidecars survive, so re-enabling rescans
without recomputing hashes (critical for multi-GB text encoders).
- Enabling triggers a reconcile so newly managed roots are scanned immediately.
- Editing `settings.json` while the server is stopped is still covered by the
hydration hook, so disabled types never appear after a restart.
### 11.6 Tests
Backend: opt-in fixtures added to the other-related suites; new coverage for
"default off scans nothing", per-sub_type gating, routing/download rejection,
`_should_keep_cached_entry`, settings normalization and `other_disabled` page
context. Frontend: `updateOtherModelsControls` / `saveEnabledOtherSubTypes` and
the disabled-page enable flow.
@@ -0,0 +1,92 @@
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
---
## 背景
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
~1400 ms 降到 ~120 ms。
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
因此单独记录,留待 Windows 机器上确认。
---
## 待验证分支(现状)
`py/services/model_scanner.py``_reconcile_cache()` 的 walk 循环内:
```python
# Try case-insensitive match on Windows
if os.name == 'nt':
lower_path = file_path.lower()
matched = False
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
if cached_path.lower() == lower_path:
found_paths.add(cached_path)
matched = True
break
if matched:
continue
```
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
### 为什么可疑
1. **可能不可达**Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
3. **没有测试覆盖**`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
`os.name == 'nt'` 短路,无法覆盖)。
---
## 待办
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
- [ ] **补一个 Windows-only 的回归测试**`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
- [ ] 把验证结论回填到本文件,并同步更新状态行。
---
## 验证方法(Windows
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
2. **看后端日志判据**
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`
且**没有** `Found N new files to process` / `Processing <path>`
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
(缓存路径被当成了新文件 + 旧条目被删)。
3. **跑测试**`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
真实执行 `os.name == 'nt'` 分支)。
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
---
## 已完成(本轮优化,供对照)
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
47 项、全量后端 2567 项全部通过。
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint-Name kopieren",
"copyEmbeddingName": "Embedding-Name kopieren",
"embeddingNameCopied": "Embedding-Syntax kopiert",
"modelNameCopied": "Modellname kopiert",
"sendCheckpointToWorkflow": "An ComfyUI senden",
"sendEmbeddingToWorkflow": "An ComfyUI senden"
},
@@ -216,9 +217,6 @@
"label": "Rezepte lokalen Modellen neu zuordnen",
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
},
@@ -236,6 +234,7 @@
"recipes": "Rezepte",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Andere",
"statistics": "Statistiken"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultEmbeddingRoot": "Embedding-Stammordner",
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultVaeRoot": "VAE-Stammordner",
"defaultVaeRootHelp": "Legen Sie den Standard-VAE-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultUpscalerRoot": "Upscaler-Stammordner",
"defaultUpscalerRootHelp": "Legen Sie den Standard-Upscaler-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultTextEncoderRoot": "Text-Encoder-Stammordner",
"defaultTextEncoderRootHelp": "Legen Sie den Standard-Text-Encoder-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultClipVisionRoot": "CLIP-Vision-Stammordner",
"defaultClipVisionRootHelp": "Legen Sie den Standard-CLIP-Vision-Stammordner für Downloads, Importe und Verschiebungen fest",
"defaultControlnetRoot": "ControlNet-Stammordner",
"defaultControlnetRootHelp": "Legen Sie den Standard-ControlNet-Stammordner für Downloads, Importe und Verschiebungen fest",
"enableOtherModels": "Verwaltung weiterer Modelle",
"enableOtherModelsHelp": "Wenn deaktiviert, werden VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Ordner nicht gescannt, die Seite für weitere Modelle bleibt deaktiviert und diese Modelltypen können nicht heruntergeladen werden.",
"otherSubTypes": "Verwaltete Modelltypen",
"otherSubTypesHelp": "Wählen Sie, welche Kategorien weiterer Modelle gescannt und auf der Seite für weitere Modelle angezeigt werden.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Rezepte-Speicherpfad",
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Embedding-Modelle"
},
"other": {
"title": "Weitere Modelle",
"disabled": {
"title": "Die Verwaltung weiterer Modelle ist deaktiviert",
"description": "Aktivieren Sie die Option, um VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien zu scannen und zu verwalten und sie von CivitAI herunterzuladen.",
"enableButton": "Weitere Modelle aktivieren",
"hint": "Sie können die verwalteten Modelltypen später unter Einstellungen > Bibliothek ändern.",
"enableFailed": "Aktivierung weiterer Modelle fehlgeschlagen",
"downloadBlocked": "Die Verwaltung weiterer Modelle ist für diesen Modelltyp deaktiviert. Aktivieren Sie sie unter Einstellungen > Bibliothek, um diese Datei herunterzuladen.",
"enableAction": "Weitere Modelle aktivieren"
},
"noPaths": {
"title": "Keine Ordner für weitere Modelle gefunden",
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.",
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
"openSettings": "Einstellungen öffnen"
}
},
"sidebar": {
"modelRoot": "Stammverzeichnis",
"collapseAll": "Alle Ordner einklappen",
@@ -1501,6 +1539,41 @@
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
"downloadButton": "{count} LoRA(s) herunterladen"
},
"rematchOptions": {
"title": "Rezepte neu zuordnen",
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
"confirmButton": "Neu zuordnen"
},
"rematchResults": {
"undo": "Rückgängig",
"undone": "Rückgängig gemacht",
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
},
"rematchSummary": {
"title": "Zusammenfassung der Neuordnung",
"successMessage": "{entries} Einträge zugeordnet",
"failed": "Neuordnung fehlgeschlagen",
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
"statMatched": "Zugeordnete Einträge",
"statReview": "Zu überprüfen",
"statUnresolved": "Nicht zugeordnet",
"statErrors": "Fehler",
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
"columnRecipe": "Rezept",
"columnEntry": "Eintrag",
"columnFile": "Zugeordnete Datei",
"columnUndo": "Rückgängig",
"copyReport": "Bericht kopieren",
"close": "Schließen",
"scope_global": "Alle Rezepte",
"scope_bulk": "Ausgewählte Rezepte",
"scope_single": "Einzelnes Rezept"
},
"exampleAccess": {
"title": "Lokale Beispielbilder",
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
@@ -1846,6 +1919,10 @@
"title": "Embedding Manager wird initialisiert",
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
},
"other": {
"title": "Manager für weitere Modelle wird initialisiert",
"message": "Modell-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
},
"recipes": {
"title": "Rezept Manager wird initialisiert",
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
@@ -2168,6 +2245,7 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
"noRecipesSelected": "Keine Rezepte ausgewählt",
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
"reimporting": "Rezept wird aus Quelle neu importiert...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
"otherRootsFailed": "Fehler beim Laden der Stammverzeichnisse weiterer Modelle: {message}",
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "Cache wird neu aufgebaut...",
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
"retry": "Wiederholen"
},
"otherModels": {
"title": "Die Verwaltung weiterer Modelle ist verfügbar",
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
"enable": "Weitere Modelle aktivieren",
"openSettings": "Einstellungen öffnen"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Copy checkpoint name",
"copyEmbeddingName": "Copy embedding name",
"embeddingNameCopied": "Embedding syntax copied",
"modelNameCopied": "Model name copied",
"sendCheckpointToWorkflow": "Send to ComfyUI",
"sendEmbeddingToWorkflow": "Send to ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "Rematch recipes to local models",
"loading": "Rematching recipes to local models...",
"success": "Matched {entries} entries across {recipes} recipes",
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
"allFailed": "Rematch failed for {failures} of {total} recipes",
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
"error": "Recipe rematch failed: {message}"
},
@@ -236,6 +234,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Other",
"statistics": "Stats"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
"defaultEmbeddingRoot": "Embedding Root",
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
"defaultVaeRoot": "VAE Root",
"defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves",
"defaultUpscalerRoot": "Upscaler Root",
"defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves",
"defaultTextEncoderRoot": "Text Encoder Root",
"defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves",
"defaultClipVisionRoot": "CLIP Vision Root",
"defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves",
"defaultControlnetRoot": "ControlNet Root",
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
"enableOtherModels": "Other Models Management",
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP vision / ControlNet folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
"otherSubTypes": "Managed Types",
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Recipes Storage Path",
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Embedding Models"
},
"other": {
"title": "Other Models",
"disabled": {
"title": "Other Models management is off",
"description": "Enable it to scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files, and to download them from CivitAI.",
"enableButton": "Enable Other Models",
"hint": "You can change the managed model types later in Settings > Library.",
"enableFailed": "Failed to enable Other Models",
"downloadBlocked": "Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
"enableAction": "Enable Other Models"
},
"noPaths": {
"title": "No other-model folders found",
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.",
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
"openSettings": "Open Settings"
}
},
"sidebar": {
"modelRoot": "Root",
"collapseAll": "Collapse All Folders",
@@ -1501,6 +1539,41 @@
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
"downloadButton": "Download {count} LoRA(s)"
},
"rematchOptions": {
"title": "Rematch Recipes",
"messageGlobal": "All recipes will be scanned against your local model library.",
"messageSingle": "This recipe will be scanned against your local model library.",
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
"relaxedLabel": "Also reconnect missing models by file name",
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
"confirmButton": "Rematch"
},
"rematchResults": {
"undo": "Undo",
"undone": "Undone",
"undoFailed": "Failed to undo rematch: {message}"
},
"rematchSummary": {
"title": "Rematch Summary",
"successMessage": "Matched {entries} entries",
"failed": "Rematch failed",
"completedWithWarnings": "Rematch completed — review recommended",
"cancelledNote": "Run cancelled before completion — counts are partial.",
"statMatched": "Matched entries",
"statReview": "Needs review",
"statUnresolved": "Unresolved",
"statErrors": "Errors",
"reviewSection": "Filename matches to review ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entry",
"columnFile": "Matched file",
"columnUndo": "Undo",
"copyReport": "Copy Report",
"close": "Close",
"scope_global": "All recipes",
"scope_bulk": "Selected recipes",
"scope_single": "Single recipe"
},
"exampleAccess": {
"title": "Local Example Images",
"message": "No local example images found for this model. View options:",
@@ -1846,6 +1919,10 @@
"title": "Initializing Embedding Manager",
"message": "Scanning and building embedding cache. This may take a few minutes..."
},
"other": {
"title": "Initializing Other Models Manager",
"message": "Scanning and building model cache. This may take a few minutes..."
},
"recipes": {
"title": "Initializing Recipe Manager",
"message": "Loading and processing recipes. This may take a few minutes..."
@@ -2168,6 +2245,7 @@
"createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download",
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
"noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "Failed to browse directory: {message}",
"batchImportDirectorySelected": "Directory selected: {path}",
"noRecipesSelected": "No recipes selected",
"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",
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
"rematchFailed": "Failed to rematch selected recipes: {message}",
"reimporting": "Re-importing recipe from source...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
"otherRootsFailed": "Failed to load other model roots: {message}",
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
"mappingsCleared": "Base model path mappings cleared",
"mappingSaveFailed": "Failed to save base model mappings: {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "Rebuilding cache...",
"rebuildFailed": "Failed to rebuild cache: {error}",
"retry": "Retry"
},
"otherModels": {
"title": "Other Models Management is available",
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
"enable": "Enable Other Models",
"openSettings": "Open Settings"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Copiar nombre del checkpoint",
"copyEmbeddingName": "Copiar nombre del embedding",
"embeddingNameCopied": "Sintaxis de embedding copiada",
"modelNameCopied": "Nombre del modelo copiado",
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "Reasociar recetas con modelos locales",
"loading": "Reasociando recetas con modelos locales...",
"success": "{entries} entradas asociadas en {recipes} recetas",
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
"error": "Falló la reasociación de recetas: {message}"
},
@@ -236,6 +234,7 @@
"recipes": "Recetas",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Otros",
"statistics": "Estadísticas"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
"defaultEmbeddingRoot": "Raíz de embedding",
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
"defaultVaeRoot": "Raíz de VAE",
"defaultVaeRootHelp": "Establecer el directorio raíz predeterminado de VAE para descargas, importaciones y movimientos",
"defaultUpscalerRoot": "Raíz de Upscaler",
"defaultUpscalerRootHelp": "Establecer el directorio raíz predeterminado de Upscaler para descargas, importaciones y movimientos",
"defaultTextEncoderRoot": "Raíz de Text Encoder",
"defaultTextEncoderRootHelp": "Establecer el directorio raíz predeterminado de Text Encoder para descargas, importaciones y movimientos",
"defaultClipVisionRoot": "Raíz de CLIP Vision",
"defaultClipVisionRootHelp": "Establecer el directorio raíz predeterminado de CLIP Vision para descargas, importaciones y movimientos",
"defaultControlnetRoot": "Raíz de ControlNet",
"defaultControlnetRootHelp": "Establecer el directorio raíz predeterminado de ControlNet para descargas, importaciones y movimientos",
"enableOtherModels": "Gestión de otros modelos",
"enableOtherModelsHelp": "Cuando está desactivado, las carpetas VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet no se escanean, la página Otros modelos permanece desactivada y estos tipos de modelos no se pueden descargar.",
"otherSubTypes": "Tipos de modelos gestionados",
"otherSubTypesHelp": "Elige qué categorías de otros modelos se escanean y se muestran en la página Otros modelos.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Ruta de almacenamiento de recetas",
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Modelos embedding"
},
"other": {
"title": "Otros modelos",
"disabled": {
"title": "La gestión de otros modelos está desactivada",
"description": "Actívala para escanear y gestionar archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y para descargarlos desde CivitAI.",
"enableButton": "Activar otros modelos",
"hint": "Puedes cambiar los tipos de modelos gestionados más adelante en Configuración > Biblioteca.",
"enableFailed": "No se pudieron activar los otros modelos",
"downloadBlocked": "La gestión de otros modelos está desactivada para este tipo de modelo. Actívala en Configuración > Biblioteca para descargar este archivo.",
"enableAction": "Activar otros modelos"
},
"noPaths": {
"title": "No se encontraron carpetas de otros modelos",
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.",
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
"openSettings": "Abrir configuración"
}
},
"sidebar": {
"modelRoot": "Raíz",
"collapseAll": "Colapsar todas las carpetas",
@@ -1501,6 +1539,41 @@
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
"downloadButton": "Descargar {count} LoRA(s)"
},
"rematchOptions": {
"title": "Reasociar recetas",
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
"confirmButton": "Reasociar"
},
"rematchResults": {
"undo": "Deshacer",
"undone": "Deshecho",
"undoFailed": "No se pudo deshacer la reasociación: {message}"
},
"rematchSummary": {
"title": "Resumen de la reasociación",
"successMessage": "{entries} entradas asociadas",
"failed": "Falló la reasociación",
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
"statMatched": "Entradas asociadas",
"statReview": "Por revisar",
"statUnresolved": "Sin coincidencia",
"statErrors": "Errores",
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
"columnRecipe": "Receta",
"columnEntry": "Entrada",
"columnFile": "Archivo coincidente",
"columnUndo": "Deshacer",
"copyReport": "Copiar informe",
"close": "Cerrar",
"scope_global": "Todas las recetas",
"scope_bulk": "Recetas seleccionadas",
"scope_single": "Receta individual"
},
"exampleAccess": {
"title": "Imágenes de ejemplo locales",
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
@@ -1846,6 +1919,10 @@
"title": "Inicializando gestor de embedding",
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
},
"other": {
"title": "Inicializando el gestor de otros modelos",
"message": "Escaneando y construyendo la caché de modelos. Esto puede tomar unos minutos..."
},
"recipes": {
"title": "Inicializando gestor de recetas",
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
@@ -2168,6 +2245,7 @@
"createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar",
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
"noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
"noRecipesSelected": "No se han seleccionado recetas",
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
"reimporting": "Reimportando receta desde origen...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
"otherRootsFailed": "Error al cargar raíces de otros modelos: {message}",
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "Reconstruyendo caché...",
"rebuildFailed": "Error al reconstruir la caché: {error}",
"retry": "Reintentar"
},
"otherModels": {
"title": "La gestión de otros modelos ya está disponible",
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
"enable": "Activar otros modelos",
"openSettings": "Abrir configuración"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Copier le nom du checkpoint",
"copyEmbeddingName": "Copier le nom de l'embedding",
"embeddingNameCopied": "Syntaxe dembedding copiée",
"modelNameCopied": "Nom du modèle copié",
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "Réassocier les Recipes aux modèles locaux",
"loading": "Réassociation des Recipes aux modèles locaux...",
"success": "{entries} entrées associées dans {recipes} Recipes",
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
"error": "Échec de la réassociation des Recipes : {message}"
},
@@ -236,6 +234,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Autres",
"statistics": "Statistiques"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
"defaultEmbeddingRoot": "Racine Embedding",
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
"defaultVaeRoot": "Racine VAE",
"defaultVaeRootHelp": "Définir le répertoire racine VAE par défaut pour les téléchargements, imports et déplacements",
"defaultUpscalerRoot": "Racine Upscaler",
"defaultUpscalerRootHelp": "Définir le répertoire racine Upscaler par défaut pour les téléchargements, imports et déplacements",
"defaultTextEncoderRoot": "Racine Text Encoder",
"defaultTextEncoderRootHelp": "Définir le répertoire racine Text Encoder par défaut pour les téléchargements, imports et déplacements",
"defaultClipVisionRoot": "Racine CLIP Vision",
"defaultClipVisionRootHelp": "Définir le répertoire racine CLIP Vision par défaut pour les téléchargements, imports et déplacements",
"defaultControlnetRoot": "Racine ControlNet",
"defaultControlnetRootHelp": "Définir le répertoire racine ControlNet par défaut pour les téléchargements, imports et déplacements",
"enableOtherModels": "Gestion des autres modèles",
"enableOtherModelsHelp": "Lorsque cette option est désactivée, les dossiers VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet ne sont pas analysés, la page Autres modèles reste désactivée et ces types de modèles ne peuvent pas être téléchargés.",
"otherSubTypes": "Types de modèles gérés",
"otherSubTypesHelp": "Choisissez les catégories dautres modèles analysées et affichées sur la page Autres modèles.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Chemin de stockage des Recipes",
"recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Modèles Embedding"
},
"other": {
"title": "Autres modèles",
"disabled": {
"title": "La gestion des autres modèles est désactivée",
"description": "Activez-la pour analyser et gérer les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et pour les télécharger depuis CivitAI.",
"enableButton": "Activer les autres modèles",
"hint": "Vous pourrez modifier les types de modèles gérés plus tard dans Paramètres > Bibliothèque.",
"enableFailed": "Échec de lactivation des autres modèles",
"downloadBlocked": "La gestion des autres modèles est désactivée pour ce type de modèle. Activez-la dans Paramètres > Bibliothèque pour télécharger ce fichier.",
"enableAction": "Activer les autres modèles"
},
"noPaths": {
"title": "Aucun dossier dautres modèles trouvé",
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.",
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés nexiste sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
"openSettings": "Ouvrir les paramètres"
}
},
"sidebar": {
"modelRoot": "Racine",
"collapseAll": "Réduire tous les dossiers",
@@ -1501,6 +1539,41 @@
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
"downloadButton": "Télécharger {count} LoRA(s)"
},
"rematchOptions": {
"title": "Réassocier les Recipes",
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
"confirmButton": "Réassocier"
},
"rematchResults": {
"undo": "Annuler",
"undone": "Annulé",
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
},
"rematchSummary": {
"title": "Résumé de la réassociation",
"successMessage": "{entries} entrées associées",
"failed": "Échec de la réassociation",
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
"statMatched": "Entrées associées",
"statReview": "À vérifier",
"statUnresolved": "Sans correspondance",
"statErrors": "Erreurs",
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
"columnRecipe": "Recipe",
"columnEntry": "Entrée",
"columnFile": "Fichier correspondant",
"columnUndo": "Annuler",
"copyReport": "Copier le rapport",
"close": "Fermer",
"scope_global": "Toutes les Recipes",
"scope_bulk": "Recipes sélectionnées",
"scope_single": "Une seule Recipe"
},
"exampleAccess": {
"title": "Images d'exemple locales",
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
@@ -1846,6 +1919,10 @@
"title": "Initialisation du gestionnaire Embedding",
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
},
"other": {
"title": "Initialisation du gestionnaire Autres modèles",
"message": "Analyse et construction du cache de modèles. Cela peut prendre quelques minutes..."
},
"recipes": {
"title": "Initialisation du gestionnaire de recipes",
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
@@ -2168,6 +2245,7 @@
"createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger",
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
"noPreviousRecipe": "Aucune Recipe précédente",
"noNextRecipe": "Aucune Recipe suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
"noRecipesSelected": "Aucune Recipe sélectionnée",
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
"reimporting": "Ré-import de la Recipe depuis la source...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
"otherRootsFailed": "Échec du chargement des racines des autres modèles : {message}",
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "Reconstruction du cache...",
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
"retry": "Réessayer"
},
"otherModels": {
"title": "La gestion des autres modèles est disponible",
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
"enable": "Activer les autres modèles",
"openSettings": "Ouvrir les paramètres"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק",
"modelNameCopied": "שם המודל הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
},
@@ -236,6 +234,7 @@
"recipes": "מתכונים",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "אחרים",
"statistics": "סטטיסטיקה"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"defaultVaeRoot": "תיקיית שורש VAE",
"defaultVaeRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של VAE להורדות, ייבוא והעברות",
"defaultUpscalerRoot": "תיקיית שורש Upscaler",
"defaultUpscalerRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Upscaler להורדות, ייבוא והעברות",
"defaultTextEncoderRoot": "תיקיית שורש Text Encoder",
"defaultTextEncoderRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Text Encoder להורדות, ייבוא והעברות",
"defaultClipVisionRoot": "תיקיית שורש CLIP Vision",
"defaultClipVisionRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של CLIP Vision להורדות, ייבוא והעברות",
"defaultControlnetRoot": "תיקיית שורש ControlNet",
"defaultControlnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של ControlNet להורדות, ייבוא והעברות",
"enableOtherModels": "ניהול מודלים אחרים",
"enableOtherModelsHelp": "כשהאפשרות כבויה, תיקיות VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet אינן נסרקות, עמוד המודלים האחרים נשאר מושבת ולא ניתן להוריד סוגי מודלים אלה.",
"otherSubTypes": "סוגי מודלים מנוהלים",
"otherSubTypesHelp": "בחר אילו קטגוריות של מודלים אחרים ייסרקו ויוצגו בעמוד המודלים האחרים.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "נתיב אחסון מתכונים",
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "מודלי Embedding"
},
"other": {
"title": "מודלים אחרים",
"disabled": {
"title": "ניהול המודלים האחרים כבוי",
"description": "הפעל כדי לסרוק ולנהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, ולהוריד אותם מ-CivitAI.",
"enableButton": "הפעל מודלים אחרים",
"hint": "ניתן לשנות את סוגי המודלים המנוהלים מאוחר יותר בהגדרות > ספרייה.",
"enableFailed": "הפעלת המודלים האחרים נכשלה",
"downloadBlocked": "ניהול המודלים האחרים מושבת עבור סוג מודל זה. הפעל אותו בהגדרות > ספרייה כדי להוריד קובץ זה.",
"enableAction": "הפעל מודלים אחרים"
},
"noPaths": {
"title": "לא נמצאו תיקיות של מודלים אחרים",
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.",
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
"openSettings": "פתח הגדרות"
}
},
"sidebar": {
"modelRoot": "שורש",
"collapseAll": "כווץ את כל התיקיות",
@@ -1501,6 +1539,41 @@
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
"downloadButton": "הורד {count} LoRA(s)"
},
"rematchOptions": {
"title": "התאמה מחדש של מתכונים",
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
"confirmButton": "התאם מחדש"
},
"rematchResults": {
"undo": "בטל",
"undone": "בוטל",
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
},
"rematchSummary": {
"title": "סיכום התאמה מחדש",
"successMessage": "הותאמו {entries} פריטים",
"failed": "ההתאמה מחדש נכשלה",
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
"statMatched": "פריטים שהותאמו",
"statReview": "טעוני סקירה",
"statUnresolved": "ללא התאמה",
"statErrors": "שגיאות",
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
"columnRecipe": "מתכון",
"columnEntry": "פריט",
"columnFile": "הקובץ שהותאם",
"columnUndo": "בטל",
"copyReport": "העתק דוח",
"close": "סגור",
"scope_global": "כל המתכונים",
"scope_bulk": "מתכונים שנבחרו",
"scope_single": "מתכון בודד"
},
"exampleAccess": {
"title": "תמונות דוגמה מקומיות",
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
@@ -1846,6 +1919,10 @@
"title": "מאתחל מנהל Embedding",
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
},
"other": {
"title": "מאתחל את מנהל המודלים האחרים",
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
},
"recipes": {
"title": "מאתחל מנהל מתכונים",
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
@@ -2168,6 +2245,7 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה",
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
"noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
"noRecipesSelected": "לא נבחרו מתכונים",
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
"reimporting": "מייבא מתכון מחדש מהמקור...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "בונה מחדש את המטמון...",
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
"retry": "נסה שוב"
},
"otherModels": {
"title": "ניהול המודלים האחרים זמין",
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
"enable": "הפעל מודלים אחרים",
"openSettings": "פתח הגדרות"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"modelNameCopied": "モデル名をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
},
@@ -216,9 +217,6 @@
"label": "レシピをローカルモデルに再マッチング",
"loading": "レシピをローカルモデルに再マッチングしています...",
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
"error": "レシピの再マッチングに失敗しました:{message}"
},
@@ -236,6 +234,7 @@
"recipes": "レシピ",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "その他",
"statistics": "統計"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
"defaultEmbeddingRoot": "Embeddingルート",
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
"defaultVaeRoot": "VAEルート",
"defaultVaeRootHelp": "ダウンロード、インポート、移動用のデフォルトVAEルートディレクトリを設定",
"defaultUpscalerRoot": "Upscalerルート",
"defaultUpscalerRootHelp": "ダウンロード、インポート、移動用のデフォルトUpscalerルートディレクトリを設定",
"defaultTextEncoderRoot": "Text Encoderルート",
"defaultTextEncoderRootHelp": "ダウンロード、インポート、移動用のデフォルトText Encoderルートディレクトリを設定",
"defaultClipVisionRoot": "CLIP Visionルート",
"defaultClipVisionRootHelp": "ダウンロード、インポート、移動用のデフォルトCLIP Visionルートディレクトリを設定",
"defaultControlnetRoot": "ControlNetルート",
"defaultControlnetRootHelp": "ダウンロード、インポート、移動用のデフォルトControlNetルートディレクトリを設定",
"enableOtherModels": "その他のモデル管理",
"enableOtherModelsHelp": "オフにすると、VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet フォルダーはスキャンされず、その他のモデルページは無効のままになり、これらのモデルタイプはダウンロードできません。",
"otherSubTypes": "管理するモデルタイプ",
"otherSubTypesHelp": "その他のモデルページでスキャンおよび表示するカテゴリを選択します。",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "レシピ保存先",
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Embeddingモデル"
},
"other": {
"title": "その他のモデル",
"disabled": {
"title": "その他のモデル管理はオフです",
"description": "有効にすると VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enableButton": "その他のモデルを有効にする",
"hint": "管理するモデルタイプは後で「設定 > ライブラリ」で変更できます。",
"enableFailed": "その他のモデルの有効化に失敗しました",
"downloadBlocked": "このモデルタイプではその他のモデル管理が無効です。このファイルをダウンロードするには「設定 > ライブラリ」で有効にしてください。",
"enableAction": "その他のモデルを有効にする"
},
"noPaths": {
"title": "その他のモデルのフォルダーが見つかりません",
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。",
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
"openSettings": "設定を開く"
}
},
"sidebar": {
"modelRoot": "ルート",
"collapseAll": "すべてのフォルダを折りたたむ",
@@ -1501,6 +1539,41 @@
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
"downloadButton": "{count} 個の LoRA をダウンロード"
},
"rematchOptions": {
"title": "レシピの再マッチング",
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
"confirmButton": "再マッチング"
},
"rematchResults": {
"undo": "元に戻す",
"undone": "元に戻しました",
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
},
"rematchSummary": {
"title": "再マッチングの概要",
"successMessage": "{entries} エントリをマッチングしました",
"failed": "再マッチングに失敗しました",
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
"statMatched": "マッチしたエントリ",
"statReview": "要確認",
"statUnresolved": "マッチなし",
"statErrors": "エラー",
"reviewSection": "確認が必要なファイル名マッチ({count})",
"columnRecipe": "レシピ",
"columnEntry": "エントリ",
"columnFile": "マッチしたファイル",
"columnUndo": "元に戻す",
"copyReport": "レポートをコピー",
"close": "閉じる",
"scope_global": "すべてのレシピ",
"scope_bulk": "選択したレシピ",
"scope_single": "単一のレシピ"
},
"exampleAccess": {
"title": "ローカル例画像",
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
@@ -1846,6 +1919,10 @@
"title": "Embedding Managerを初期化中",
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
},
"other": {
"title": "その他のモデルマネージャーを初期化中",
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
},
"recipes": {
"title": "レシピマネージャーを初期化中",
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
@@ -2168,6 +2245,7 @@
"createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません",
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
"noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
"noRecipesSelected": "レシピが選択されていません",
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
"reimporting": "ソースからレシピを再インポート中...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "キャッシュを再構築中...",
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
"retry": "再試行"
},
"otherModels": {
"title": "その他のモデル管理が利用可能になりました",
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enable": "その他のモデルを有効にする",
"openSettings": "設定を開く"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨",
"modelNameCopied": "모델 이름 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
},
@@ -216,9 +217,6 @@
"label": "레시피를 로컬 모델에 다시 매칭",
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
"error": "레시피 재매칭 실패: {message}"
},
@@ -236,6 +234,7 @@
"recipes": "레시피",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "기타",
"statistics": "통계"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
"defaultEmbeddingRoot": "Embedding 루트",
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
"defaultVaeRoot": "VAE 루트",
"defaultVaeRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 VAE 루트 디렉토리를 설정합니다",
"defaultUpscalerRoot": "Upscaler 루트",
"defaultUpscalerRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Upscaler 루트 디렉토리를 설정합니다",
"defaultTextEncoderRoot": "Text Encoder 루트",
"defaultTextEncoderRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Text Encoder 루트 디렉토리를 설정합니다",
"defaultClipVisionRoot": "CLIP Vision 루트",
"defaultClipVisionRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 CLIP Vision 루트 디렉토리를 설정합니다",
"defaultControlnetRoot": "ControlNet 루트",
"defaultControlnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 ControlNet 루트 디렉토리를 설정합니다",
"enableOtherModels": "기타 모델 관리",
"enableOtherModelsHelp": "끄면 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 폴더를 스캔하지 않으며, 기타 모델 페이지가 비활성화된 상태로 유지되고 이러한 모델 유형은 다운로드할 수 없습니다.",
"otherSubTypes": "관리할 모델 유형",
"otherSubTypesHelp": "기타 모델 페이지에서 스캔하고 표시할 카테고리를 선택합니다.",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "레시피 저장 경로",
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Embedding 모델"
},
"other": {
"title": "기타 모델",
"disabled": {
"title": "기타 모델 관리가 꺼져 있습니다",
"description": "활성화하면 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔하고 관리하며 CivitAI에서 다운로드할 수 있습니다.",
"enableButton": "기타 모델 활성화",
"hint": "관리할 모델 유형은 나중에 설정 > 라이브러리에서 변경할 수 있습니다.",
"enableFailed": "기타 모델 활성화 실패",
"downloadBlocked": "이 모델 유형에 대해서는 기타 모델 관리가 비활성화되어 있습니다. 이 파일을 다운로드하려면 설정 > 라이브러리에서 활성화하세요.",
"enableAction": "기타 모델 활성화"
},
"noPaths": {
"title": "기타 모델 폴더를 찾을 수 없습니다",
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.",
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
"openSettings": "설정 열기"
}
},
"sidebar": {
"modelRoot": "루트",
"collapseAll": "모든 폴더 접기",
@@ -1501,6 +1539,41 @@
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
"downloadButton": "{count}개 LoRA 다운로드"
},
"rematchOptions": {
"title": "레시피 재매칭",
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
"confirmButton": "재매칭"
},
"rematchResults": {
"undo": "실행 취소",
"undone": "실행 취소됨",
"undoFailed": "재매칭 실행 취소 실패: {message}"
},
"rematchSummary": {
"title": "재매칭 요약",
"successMessage": "{entries}개 항목이 매칭되었습니다",
"failed": "재매칭 실패",
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
"statMatched": "매칭된 항목",
"statReview": "검토 필요",
"statUnresolved": "매칭 없음",
"statErrors": "오류",
"reviewSection": "검토할 파일 이름 매칭 ({count})",
"columnRecipe": "레시피",
"columnEntry": "항목",
"columnFile": "매칭된 파일",
"columnUndo": "실행 취소",
"copyReport": "보고서 복사",
"close": "닫기",
"scope_global": "모든 레시피",
"scope_bulk": "선택한 레시피",
"scope_single": "단일 레시피"
},
"exampleAccess": {
"title": "로컬 예시 이미지",
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
@@ -1846,6 +1919,10 @@
"title": "Embedding Manager 초기화 중",
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"other": {
"title": "기타 모델 관리자 초기화 중",
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"recipes": {
"title": "레시피 매니저 초기화 중",
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
@@ -2168,6 +2245,7 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
"noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
"batchImportDirectorySelected": "선택한 폴더: {path}",
"noRecipesSelected": "선택한 레시피가 없습니다",
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "캐시 재구축 중...",
"rebuildFailed": "캐시 재구축 실패: {error}",
"retry": "다시 시도"
},
"otherModels": {
"title": "기타 모델 관리를 사용할 수 있습니다",
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
"enable": "기타 모델 활성화",
"openSettings": "설정 열기"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован",
"modelNameCopied": "Имя модели скопировано",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "Повторное сопоставление рецептов с локальными моделями",
"loading": "Повторное сопоставление рецептов с локальными моделями...",
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
"error": "Не удалось выполнить сопоставление рецептов: {message}"
},
@@ -236,6 +234,7 @@
"recipes": "Рецепты",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Другое",
"statistics": "Статистика"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
"defaultEmbeddingRoot": "Корневая папка Embedding",
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
"defaultVaeRoot": "Корневая папка VAE",
"defaultVaeRootHelp": "Установить корневую папку VAE по умолчанию для загрузок, импорта и перемещений",
"defaultUpscalerRoot": "Корневая папка Upscaler",
"defaultUpscalerRootHelp": "Установить корневую папку Upscaler по умолчанию для загрузок, импорта и перемещений",
"defaultTextEncoderRoot": "Корневая папка Text Encoder",
"defaultTextEncoderRootHelp": "Установить корневую папку Text Encoder по умолчанию для загрузок, импорта и перемещений",
"defaultClipVisionRoot": "Корневая папка CLIP Vision",
"defaultClipVisionRootHelp": "Установить корневую папку CLIP Vision по умолчанию для загрузок, импорта и перемещений",
"defaultControlnetRoot": "Корневая папка ControlNet",
"defaultControlnetRootHelp": "Установить корневую папку ControlNet по умолчанию для загрузок, импорта и перемещений",
"enableOtherModels": "Управление другими моделями",
"enableOtherModelsHelp": "Если выключено, папки VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet не сканируются, страница «Другие модели» остаётся отключённой, а эти типы моделей нельзя загрузить.",
"otherSubTypes": "Управляемые типы моделей",
"otherSubTypesHelp": "Выберите, какие категории других моделей сканируются и отображаются на странице «Другие модели».",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "Путь хранения рецептов",
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Модели Embedding"
},
"other": {
"title": "Другие модели",
"disabled": {
"title": "Управление другими моделями отключено",
"description": "Включите, чтобы сканировать и управлять файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загружать их с CivitAI.",
"enableButton": "Включить другие модели",
"hint": "Вы сможете изменить управляемые типы моделей позже в разделе «Настройки > Библиотека».",
"enableFailed": "Не удалось включить другие модели",
"downloadBlocked": "Управление другими моделями отключено для этого типа моделей. Включите его в разделе «Настройки > Библиотека», чтобы загрузить этот файл.",
"enableAction": "Включить другие модели"
},
"noPaths": {
"title": "Папки других моделей не найдены",
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.",
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
"openSettings": "Открыть настройки"
}
},
"sidebar": {
"modelRoot": "Корень",
"collapseAll": "Свернуть все папки",
@@ -1501,6 +1539,41 @@
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
"downloadButton": "Скачать {count} LoRA(s)"
},
"rematchOptions": {
"title": "Повторное сопоставление рецептов",
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
"confirmButton": "Сопоставить"
},
"rematchResults": {
"undo": "Отменить",
"undone": "Отменено",
"undoFailed": "Не удалось отменить сопоставление: {message}"
},
"rematchSummary": {
"title": "Сводка повторного сопоставления",
"successMessage": "Сопоставлено записей: {entries}",
"failed": "Не удалось выполнить сопоставление",
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
"statMatched": "Сопоставленные записи",
"statReview": "Требуют проверки",
"statUnresolved": "Не сопоставлено",
"statErrors": "Ошибки",
"reviewSection": "Совпадения по имени файла для проверки ({count})",
"columnRecipe": "Рецепт",
"columnEntry": "Запись",
"columnFile": "Совпавший файл",
"columnUndo": "Отменить",
"copyReport": "Скопировать отчёт",
"close": "Закрыть",
"scope_global": "Все рецепты",
"scope_bulk": "Выбранные рецепты",
"scope_single": "Один рецепт"
},
"exampleAccess": {
"title": "Локальные примеры изображений",
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
@@ -1846,6 +1919,10 @@
"title": "Инициализация Embedding Manager",
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
},
"other": {
"title": "Инициализация менеджера других моделей",
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
},
"recipes": {
"title": "Инициализация менеджера рецептов",
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
@@ -2168,6 +2245,7 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
"batchImportDirectorySelected": "Выбрана папка: {path}",
"noRecipesSelected": "Рецепты не выбраны",
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
"reimporting": "Переимпорт рецепта из источника...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "Перестроение кэша...",
"rebuildFailed": "Не удалось перестроить кэш: {error}",
"retry": "Повторить"
},
"otherModels": {
"title": "Управление другими моделями доступно",
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
"enable": "Включить другие модели",
"openSettings": "Открыть настройки"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法",
"modelNameCopied": "模型名称已复制",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "将配方重新匹配到本地模型",
"loading": "正在将配方重新匹配到本地模型...",
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"allFailed": "{failures}/{total} 个配方重新匹配失败",
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
"error": "配方重新匹配失败:{message}"
},
@@ -236,6 +234,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"statistics": "统计"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
"defaultEmbeddingRoot": "Embedding 根目录",
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
"defaultVaeRoot": "VAE 根目录",
"defaultVaeRootHelp": "设置下载、导入和移动时的默认 VAE 根目录",
"defaultUpscalerRoot": "Upscaler 根目录",
"defaultUpscalerRootHelp": "设置下载、导入和移动时的默认 Upscaler 根目录",
"defaultTextEncoderRoot": "Text Encoder 根目录",
"defaultTextEncoderRootHelp": "设置下载、导入和移动时的默认 Text Encoder 根目录",
"defaultClipVisionRoot": "CLIP Vision 根目录",
"defaultClipVisionRootHelp": "设置下载、导入和移动时的默认 CLIP Vision 根目录",
"defaultControlnetRoot": "ControlNet 根目录",
"defaultControlnetRootHelp": "设置下载、导入和移动时的默认 ControlNet 根目录",
"enableOtherModels": "其他模型管理",
"enableOtherModelsHelp": "关闭后,不会扫描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 文件夹,其他模型页面保持禁用,且无法下载这些模型类型。",
"otherSubTypes": "管理的模型类型",
"otherSubTypesHelp": "选择要在其他模型页面中扫描和显示的其他模型类别。",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "配方存储路径",
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Embedding 模型"
},
"other": {
"title": "其他模型",
"disabled": {
"title": "其他模型管理已关闭",
"description": "启用后可扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enableButton": "启用其他模型",
"hint": "你可以稍后在“设置 > 库”中更改管理的模型类型。",
"enableFailed": "启用其他模型失败",
"downloadBlocked": "其他模型管理已对此模型类型禁用。请在“设置 > 库”中启用以下载此文件。",
"enableAction": "启用其他模型"
},
"noPaths": {
"title": "未找到其他模型文件夹",
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。",
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
"openSettings": "打开设置"
}
},
"sidebar": {
"modelRoot": "根目录",
"collapseAll": "折叠所有文件夹",
@@ -1501,6 +1539,41 @@
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
"downloadButton": "下载 {count} 个 LoRA(s)"
},
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
"messageSingle": "将对照你的本地模型库扫描此配方。",
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
"relaxedLabel": "同时按文件名重新关联缺失的模型",
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤销",
"undone": "已撤销",
"undoFailed": "撤销重新匹配失败:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 个条目",
"failed": "重新匹配失败",
"completedWithWarnings": "重新匹配已完成——建议检查",
"cancelledNote": "运行在完成前已取消——统计不完整。",
"statMatched": "已匹配条目",
"statReview": "需要检查",
"statUnresolved": "未匹配",
"statErrors": "错误",
"reviewSection": "需要检查的文件名匹配({count}",
"columnRecipe": "配方",
"columnEntry": "条目",
"columnFile": "匹配到的文件",
"columnUndo": "撤销",
"copyReport": "复制报告",
"close": "关闭",
"scope_global": "所有配方",
"scope_bulk": "所选配方",
"scope_single": "单个配方"
},
"exampleAccess": {
"title": "本地示例图片",
"message": "未找到此模型的本地示例图片。可选操作:",
@@ -1846,6 +1919,10 @@
"title": "初始化 Embedding 管理器",
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
},
"other": {
"title": "正在初始化其他模型管理器",
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在加载和处理配方。这可能需要几分钟..."
@@ -2168,6 +2245,7 @@
"createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载",
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
"noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "浏览目录失败:{message}",
"batchImportDirectorySelected": "已选择目录:{path}",
"noRecipesSelected": "未选择任何配方",
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
"rematchFailed": "重新匹配所选配方失败:{message}",
"reimporting": "正在从源重新导入配方...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
"otherRootsFailed": "加载其他模型根目录失败:{message}",
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
"mappingsCleared": "基础模型路径映射已清除",
"mappingSaveFailed": "保存基础模型映射失败:{message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "正在重建缓存...",
"rebuildFailed": "重建缓存失败:{error}",
"retry": "重试"
},
"otherModels": {
"title": "其他模型管理现已可用",
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enable": "启用其他模型",
"openSettings": "打开设置"
}
}
}
+88 -7
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "複製 Checkpoint 名稱",
"copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"modelNameCopied": "模型名稱已複製",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
},
@@ -216,9 +217,6 @@
"label": "將配方重新匹配到本地模型",
"loading": "正在將配方重新匹配到本地模型...",
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
"error": "配方重新匹配失敗:{message}"
},
@@ -236,6 +234,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"statistics": "統計"
},
"search": {
@@ -536,6 +535,25 @@
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
"defaultEmbeddingRoot": "Embedding 根目錄",
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
"defaultVaeRoot": "VAE 根目錄",
"defaultVaeRootHelp": "設定下載、匯入和移動時的預設 VAE 根目錄",
"defaultUpscalerRoot": "Upscaler 根目錄",
"defaultUpscalerRootHelp": "設定下載、匯入和移動時的預設 Upscaler 根目錄",
"defaultTextEncoderRoot": "Text Encoder 根目錄",
"defaultTextEncoderRootHelp": "設定下載、匯入和移動時的預設 Text Encoder 根目錄",
"defaultClipVisionRoot": "CLIP Vision 根目錄",
"defaultClipVisionRootHelp": "設定下載、匯入和移動時的預設 CLIP Vision 根目錄",
"defaultControlnetRoot": "ControlNet 根目錄",
"defaultControlnetRootHelp": "設定下載、匯入和移動時的預設 ControlNet 根目錄",
"enableOtherModels": "其他模型管理",
"enableOtherModelsHelp": "關閉後,不會掃描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 資料夾,其他模型頁面會保持停用,且無法下載這些模型類型。",
"otherSubTypes": "管理的模型類型",
"otherSubTypesHelp": "選擇要在其他模型頁面中掃描和顯示的其他模型類別。",
"subTypeVae": "VAE",
"subTypeUpscaler": "Upscaler",
"subTypeTextEncoder": "Text Encoder",
"subTypeClipVision": "CLIP Vision",
"subTypeControlnet": "ControlNet",
"recipesPath": "配方儲存路徑",
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
"recipesPathPlaceholder": "/path/to/recipes",
@@ -1204,6 +1222,26 @@
"embeddings": {
"title": "Embedding 模型"
},
"other": {
"title": "其他模型",
"disabled": {
"title": "其他模型管理已關閉",
"description": "啟用後可掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enableButton": "啟用其他模型",
"hint": "您稍後可以在「設定 > 模型庫」中變更管理的模型類型。",
"enableFailed": "啟用其他模型失敗",
"downloadBlocked": "其他模型管理已對此模型類型停用。請在「設定 > 模型庫」中啟用以下載此檔案。",
"enableAction": "啟用其他模型"
},
"noPaths": {
"title": "找不到其他模型資料夾",
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。",
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
"openSettings": "開啟設定"
}
},
"sidebar": {
"modelRoot": "根目錄",
"collapseAll": "全部摺疊資料夾",
@@ -1501,6 +1539,41 @@
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
"downloadButton": "下載 {count} 個 LoRA(s)"
},
"rematchOptions": {
"title": "重新匹配配方",
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
"confirmButton": "重新匹配"
},
"rematchResults": {
"undo": "撤銷",
"undone": "已撤銷",
"undoFailed": "撤銷重新匹配失敗:{message}"
},
"rematchSummary": {
"title": "重新匹配摘要",
"successMessage": "已匹配 {entries} 個條目",
"failed": "重新匹配失敗",
"completedWithWarnings": "重新匹配已完成——建議檢查",
"cancelledNote": "執行在完成前已取消——統計不完整。",
"statMatched": "已匹配條目",
"statReview": "需要檢查",
"statUnresolved": "未匹配",
"statErrors": "錯誤",
"reviewSection": "需要檢查的檔案名稱匹配({count})",
"columnRecipe": "配方",
"columnEntry": "條目",
"columnFile": "匹配到的檔案",
"columnUndo": "撤銷",
"copyReport": "複製報告",
"close": "關閉",
"scope_global": "所有配方",
"scope_bulk": "所選配方",
"scope_single": "單個配方"
},
"exampleAccess": {
"title": "本機範例圖片",
"message": "此模型未找到本機範例圖片。可選擇:",
@@ -1846,6 +1919,10 @@
"title": "初始化 Embedding 管理器",
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
},
"other": {
"title": "正在初始化其他模型管理器",
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在載入並處理配方,可能需要幾分鐘..."
@@ -2168,6 +2245,7 @@
"createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載",
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
"noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
@@ -2222,10 +2300,6 @@
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
"batchImportDirectorySelected": "已選擇目錄:{path}",
"noRecipesSelected": "未選取任何配方",
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
"rematchFailed": "重新匹配所選配方失敗:{message}",
"reimporting": "正在從來源重新匯入配方...",
@@ -2304,6 +2378,7 @@
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
"mappingsCleared": "基礎模型路徑對應已清除",
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
@@ -2566,6 +2641,12 @@
"rebuilding": "重建快取中...",
"rebuildFailed": "重建快取失敗:{error}",
"retry": "重試"
},
"otherModels": {
"title": "其他模型管理現已可用",
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
"enable": "啟用其他模型",
"openSettings": "開啟設定"
}
}
}
+276 -1
View File
@@ -17,6 +17,9 @@ import types as _types
import time
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
from .utils.constants import (
OTHER_MODEL_FOLDER_SUBTYPES,
)
from .utils.settings_paths import (
ensure_settings_file,
get_settings_dir,
@@ -172,6 +175,13 @@ class Config:
self.embeddings_roots = None
self.base_models_roots = self._init_checkpoint_paths()
self.embeddings_roots = self._init_embedding_paths()
# Other-model roots (VAE, upscalers, text encoders, ...): flat deduped
# list plus a normalized root -> sub_type map and per-folder_paths-key
# roots for settings persistence.
self.other_roots: Optional[List[str]] = None
self.other_root_subtypes: Dict[str, str] = {}
self.other_folder_roots: Dict[str, List[str]] = {}
self.other_roots = self._init_other_paths()
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
self.extra_loras_roots: List[str] = []
self.extra_checkpoints_roots: List[str] = []
@@ -336,6 +346,10 @@ class Config:
"unet": list(self.unet_roots or []),
"embeddings": list(self.embeddings_roots or []),
}
# Persist the other-model roots under their original folder_paths
# keys so library switching round-trips them.
for key, roots in (self.other_folder_roots or {}).items():
target_folder_paths[key] = list(roots)
normalized_target_paths = _normalize_folder_paths_for_comparison(
target_folder_paths
@@ -522,6 +536,7 @@ class Config:
roots.extend(self.loras_roots or [])
roots.extend(self.base_models_roots or [])
roots.extend(self.embeddings_roots or [])
roots.extend(self.other_roots or [])
# Include extra paths for scanning symlinks
roots.extend(self.extra_loras_roots or [])
roots.extend(self.extra_checkpoints_roots or [])
@@ -862,6 +877,8 @@ class Config:
preview_roots.update(self._expand_preview_root(root))
for root in self.embeddings_roots or []:
preview_roots.update(self._expand_preview_root(root))
for root in self.other_roots or []:
preview_roots.update(self._expand_preview_root(root))
# Include extra paths for preview access
for root in self.extra_loras_roots or []:
preview_roots.update(self._expand_preview_root(root))
@@ -882,7 +899,7 @@ class Config:
path for path in preview_roots if path.is_absolute()
}
logger.debug(
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings",
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d other roots, %d symlink mappings",
len(self._preview_root_paths),
len(self.loras_roots or []),
len(self.extra_loras_roots or []),
@@ -890,6 +907,7 @@ class Config:
len(self.extra_checkpoints_roots or []),
len(self.embeddings_roots or []),
len(self.extra_embeddings_roots or []),
len(self.other_roots or []),
len(self._path_mappings),
)
@@ -1128,6 +1146,155 @@ class Config:
return unique_paths
def _get_enabled_other_folder_keys(self) -> List[str]:
"""Return the OTHER_MODEL_FOLDER_SUBTYPES keys that are enabled.
Other Models management is opt-in: while ``enable_other_models`` is
off (the default) no other-model folder is scanned at all. When it is
on, only the folder keys of the enabled sub_types are scanned
(text_encoder merges ``text_encoders`` with the legacy ``clip`` key).
"""
try:
from .services.settings_manager import get_settings_manager
enabled_sub_types = get_settings_manager().get_enabled_other_sub_types()
except Exception:
enabled_sub_types = []
if not enabled_sub_types:
return []
allowed = set(enabled_sub_types)
return [
key
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
if sub_type in allowed
]
@staticmethod
def _collapse_legacy_folder_keys(keys: List[str]) -> List[str]:
"""Drop folder keys the host already normalizes onto another queried key.
ComfyUI's ``folder_paths`` rewrites legacy names before every access
(``clip`` -> ``text_encoders``, ``unet`` -> ``diffusion_models``), and
registers both legacy directories under the canonical key, so
``get_folder_paths("clip")`` returns exactly the same list as
``get_folder_paths("text_encoders")``. Querying both therefore reports
every text-encoder folder twice and trips the overlap guard with a
conflict the user cannot fix.
When the host exposes ``map_legacy`` the alias is provably redundant and
is skipped (an empty canonical list implies an empty alias list).
Without it - the standalone mock, whose keys are independent
``settings.json`` entries - every key is kept, because a ``clip``-only
configuration is then genuinely distinct.
"""
map_legacy = getattr(folder_paths, "map_legacy", None)
if not callable(map_legacy):
return list(keys)
queried = set(keys)
collapsed: List[str] = []
for key in keys:
try:
canonical = map_legacy(key)
except Exception:
canonical = key
if canonical != key and canonical in queried:
logger.debug(
"Skipping legacy folder key '%s'; the host resolves it to "
"'%s', which is queried as well.",
key,
canonical,
)
continue
collapsed.append(key)
return collapsed
def _prepare_other_paths(
self, folder_path_map: Mapping[str, Iterable[str]]
) -> Tuple[List[str], Dict[str, str], Dict[str, List[str]]]:
"""Prepare other-model paths from a folder_paths-key -> raw paths map.
Returns:
Tuple of (all_unique_roots, business_root -> sub_type map,
folder_paths key -> business roots). This method does NOT modify
instance variables - callers must set them.
"""
unique_paths: List[str] = []
sub_type_map: Dict[str, str] = {}
per_key_roots: Dict[str, List[str]] = {}
# real path -> (business path, sub_type) of the category that claimed it
seen_real_paths: Dict[str, Tuple[str, str]] = {}
# Cross-scanner overlap detection: warn when an "other" root is
# already covered by the checkpoints/unet or embeddings scanners.
# Kept (not dropped) on purpose - duplicate cards across pages are
# cosmetic, while dropping would silently unmanage the files.
covered_real_paths = {
os.path.normpath(os.path.realpath(path)).replace(os.sep, "/"): path
for path in [
*(self.base_models_roots or []),
*(self.embeddings_roots or []),
]
if isinstance(path, str) and path.strip() and os.path.exists(path)
}
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
raw_paths = folder_path_map.get(key)
if not raw_paths:
continue
path_map = self._dedupe_existing_paths(raw_paths)
key_roots: List[str] = []
for real_path, business_path in sorted(
path_map.items(), key=lambda item: item[1].lower()
):
seen = seen_real_paths.get(real_path)
if seen is not None:
seen_business_path, seen_sub_type = seen
if seen_sub_type == sub_type:
# Same category reached through a second folder_paths
# key (legacy alias, or a sub_type spanning two keys).
# Expected, so never a "fix your configuration" warning.
logger.debug(
"Ignoring duplicate folder '%s' for category '%s' "
"(already covered by '%s').",
business_path,
sub_type,
seen_business_path,
)
else:
logger.warning(
"Detected the same folder '%s' under multiple other-model "
"categories ('%s' is already mapped as '%s'). Keeping the "
"first category; please fix your path configuration.",
business_path,
seen_business_path,
seen_sub_type,
)
continue
seen_real_paths[real_path] = (business_path, sub_type)
unique_paths.append(business_path)
key_roots.append(business_path)
sub_type_map[business_path] = sub_type
if real_path != business_path:
self.add_path_mapping(business_path, real_path)
covered_by = covered_real_paths.get(real_path)
if covered_by:
logger.warning(
"Detected an other-model root ('%s', category '%s') that "
"overlaps an existing checkpoints/embeddings root ('%s'). "
"The same files will appear on both pages; please review "
"your path configuration.",
business_path,
key,
covered_by,
)
if key_roots:
per_key_roots[key] = key_roots
return unique_paths, sub_type_map, per_key_roots
def _apply_library_paths(
self,
folder_paths: Mapping[str, Any],
@@ -1151,6 +1318,16 @@ class Config:
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
other_path_map = {
key: folder_paths.get(key, []) or []
for key in self._get_enabled_other_folder_keys()
}
(
self.other_roots,
self.other_root_subtypes,
self.other_folder_roots,
) = self._prepare_other_paths(other_path_map)
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
extra_paths = extra_folder_paths or {}
extra_lora_paths = extra_paths.get("loras", []) or []
@@ -1267,6 +1444,104 @@ class Config:
logger.warning(f"Error initializing embedding paths: {e}")
return []
def _init_other_paths(self) -> List[str]:
"""Initialize and validate other-model paths from ComfyUI settings.
Iterates the enabled OTHER_MODEL_FOLDER_SUBTYPES keys and pulls each
from ``folder_paths.get_folder_paths(key)`` (in standalone mode the
mock serves arbitrary keys from ``settings.json.folder_paths``).
Legacy aliases the host normalizes onto a canonical key (``clip`` ->
``text_encoders``) are collapsed first so the same folders are not
reported twice.
"""
try:
folder_path_map: Dict[str, List[str]] = {}
for key in self._collapse_legacy_folder_keys(
self._get_enabled_other_folder_keys()
):
try:
folder_path_map[key] = folder_paths.get_folder_paths(key)
except Exception as exc:
logger.debug("Error reading folder paths for '%s': %s", key, exc)
(
unique_paths,
self.other_root_subtypes,
self.other_folder_roots,
) = self._prepare_other_paths(folder_path_map)
logger.info(
"Found other model roots:"
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
)
if not unique_paths:
logger.info("No valid other-model folders found in configuration")
return []
return unique_paths
except Exception as e:
logger.warning(f"Error initializing other model paths: {e}")
return []
def refresh_other_roots(self) -> None:
"""Rebuild other-model roots after the management toggles changed.
Called when ``enable_other_models`` / ``enabled_other_sub_types`` are
updated so the scanner immediately reflects the new folder set without
a full application restart.
"""
self.other_roots = self._init_other_paths()
self._rebuild_preview_roots()
def get_other_models_availability(self) -> Dict[str, Any]:
"""Report the other-model folders the host can actually expose.
Independent of the opt-in ``enable_other_models`` toggle: this answers
"could Other Models management work here at all?". ComfyUI mode almost
always has these folder keys registered, while standalone mode only
knows the keys present in ``settings.json.folder_paths`` - so the UI
uses this to decide whether announcing the feature would be actionable.
Returns:
``{"available": bool, "sub_types": {sub_type: [existing roots]}}``.
A folder only counts when it exists on disk; an empty folder still
counts because CivitAI downloads can target it.
"""
sub_types: Dict[str, List[str]] = {}
try:
keys = self._collapse_legacy_folder_keys(
list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
)
except Exception: # pragma: no cover - defensive
keys = list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
for key in keys:
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
if not sub_type:
continue
try:
raw_paths = folder_paths.get_folder_paths(key)
except Exception as exc:
logger.debug("Error probing folder paths for '%s': %s", key, exc)
continue
bucket = sub_types.setdefault(sub_type, [])
for root in sorted(
self._dedupe_existing_paths(raw_paths or []).values(),
key=lambda path: path.lower(),
):
if root not in bucket:
bucket.append(root)
available_sub_types = {
sub_type: roots for sub_type, roots in sub_types.items() if roots
}
return {
"available": bool(available_sub_types),
"sub_types": available_sub_types,
}
def get_preview_static_url(self, preview_path: str) -> str:
if not preview_path:
return ""
+7 -1
View File
@@ -219,6 +219,7 @@ class LoraManager:
lora_scanner = await ServiceRegistry.get_lora_scanner()
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Initialize recipe scanner if needed
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
@@ -236,6 +237,10 @@ class LoraManager:
embedding_scanner.initialize_in_background(),
name="embedding_cache_init",
),
asyncio.create_task(
other_scanner.initialize_in_background(),
name="other_cache_init",
),
asyncio.create_task(
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
),
@@ -328,6 +333,7 @@ class LoraManager:
all_roots.update(config.loras_roots)
all_roots.update(config.base_models_roots or [])
all_roots.update(config.embeddings_roots or [])
all_roots.update(config.other_roots or [])
total_deleted = 0
total_size_freed = 0
@@ -460,7 +466,7 @@ class LoraManager:
# Cancel any in-flight scanner initialization tasks so thread-pool
# workers (e.g. _initialize_cache_sync) can break out of their loops
# when the server shuts down (e.g. Ctrl+C on WSL).
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner"):
scanner = ServiceRegistry.get_service_sync(name)
if scanner is not None and hasattr(scanner, "cancel_task"):
scanner.cancel_task()
+3 -2
View File
@@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = {
"get_lora_scanner": "lora",
"get_checkpoint_scanner": "checkpoint",
"get_embedding_scanner": "embedding",
"get_other_scanner": "other",
}
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
@@ -80,8 +81,8 @@ async def _find_scanner_for_model(
async def identify_model_type(model_path: str) -> str:
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
``\"embedding\"``) for *model_path*.
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
``\"embedding\"``, or ``\"other\"``) for *model_path*.
Falls back to ``\"lora\"`` when unknown.
"""
+5
View File
@@ -149,6 +149,7 @@ class BaseModelRoutes(ABC):
settings_service=self._settings,
server_i18n=self._server_i18n,
logger=logger,
page_context_provider=self._get_page_context_provider(),
)
listing = ModelListingHandler(
service=service,
@@ -250,6 +251,10 @@ class BaseModelRoutes(ABC):
"""Get expected model types string for error messages - to be overridden by subclasses."""
return "any model type"
def _get_page_context_provider(self):
"""Optional hook returning extra template context for the page view."""
return None
def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
@@ -0,0 +1,110 @@
"""HTTP handler for download target routing decisions."""
from __future__ import annotations
import json
import logging
from aiohttp import web
from ...services.download_routing import (
is_diffusion_model_download,
resolve_other_download_sub_type,
)
from ...utils.constants import VALID_OTHER_CIVITAI_TYPES
logger = logging.getLogger(__name__)
class DownloadRoutingHandler:
"""Expose the download-time checkpoint/diffusion-model routing decision.
The web UI calls this when the user reaches the download location step
so the root dropdown offers the same root set (checkpoint vs unet) that
the download manager would pick for ``use_default_paths``.
"""
async def get_download_routing(self, request: web.Request) -> web.Response:
try:
payload = await request.json()
except json.JSONDecodeError:
return web.json_response(
{"success": False, "error": "Invalid JSON payload"}, status=400
)
model_type = payload.get("model_type", "")
base_model = payload.get("base_model") or ""
file_types = payload.get("file_types") or []
selected_file_type = payload.get("selected_file_type")
if not isinstance(model_type, str) or not model_type:
return web.json_response(
{"success": False, "error": "model_type is required"}, status=400
)
if not isinstance(base_model, str) or not isinstance(file_types, list):
return web.json_response(
{
"success": False,
"error": "base_model must be a string and file_types a list",
},
status=400,
)
if selected_file_type is not None and not isinstance(selected_file_type, str):
return web.json_response(
{"success": False, "error": "selected_file_type must be a string"},
status=400,
)
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
from ...services.settings_manager import get_settings_manager
settings = get_settings_manager()
if not settings.is_other_models_enabled():
# Opt-in feature is off: never auto-route, the UI falls back to
# manual folder selection and the download manager rejects it.
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": None,
"disabled": True,
"reason": "other_models_disabled",
}
)
sub_type = resolve_other_download_sub_type(
model_type,
file_types=(str(t) for t in file_types),
selected_file_type=selected_file_type,
)
if sub_type and not settings.is_other_sub_type_enabled(sub_type):
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": None,
"disabled": True,
"reason": "other_sub_type_disabled",
"requested_sub_type": sub_type,
}
)
return web.json_response(
{
"success": True,
"root_kind": "other",
"sub_type": sub_type,
}
)
is_diffusion = is_diffusion_model_download(
model_type,
file_types=(str(t) for t in file_types),
base_model=base_model,
)
return web.json_response(
{
"success": True,
"is_diffusion_model": is_diffusion,
"root_kind": "unet" if is_diffusion else model_type,
}
)
+5 -6
View File
@@ -122,12 +122,8 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
metadata._unknown_fields["hf_url"] = hf_url
metadata.from_civitai = False # HF models are not from CivitAI
metadata_dict = metadata.to_dict()
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
del metadata_dict["trainedWords"]
# 3. Save metadata atomically
await MetadataManager.save_metadata(dest_path, metadata_dict)
await MetadataManager.save_metadata(dest_path, metadata)
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
# 4. Determine relative folder path for cache
@@ -244,7 +240,10 @@ class HfHandler:
})
existing["hf_url"] = hf_url
existing["from_civitai"] = False
# NOTE: deliberately do NOT touch `from_civitai` here. It records
# where the metadata came from, and the UI must show the CivitAI
# link whenever CivitAI data is present — linking HuggingFace must
# not hide it (#1094). HF provenance is tracked via `hf_url`.
await MetadataManager.save_metadata(file_path, existing)
await _add_to_scanner_cache(file_path, existing)
+131 -7
View File
@@ -53,9 +53,11 @@ from ...utils.constants import (
PREVIEW_EXTENSIONS,
SUPPORTED_MEDIA_EXTENSIONS,
VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
)
from .hf_handlers import HfHandler
from .agent_handlers import AgentHandler
from .download_routing_handlers import DownloadRoutingHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import (
@@ -657,9 +659,21 @@ class HealthCheckHandler:
"lora": ServiceRegistry.get_lora_scanner,
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
"embedding": ServiceRegistry.get_embedding_scanner,
"other": ServiceRegistry.get_other_scanner,
"recipe": ServiceRegistry.get_recipe_scanner,
}
def _active_scanner_getters(
self,
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
getters = self._scanner_getters
if "other" not in getters:
return getters
if get_settings_manager().is_other_models_enabled():
return getters
return {name: getter for name, getter in getters.items() if name != "other"}
async def health_check(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
@@ -671,7 +685,7 @@ class HealthCheckHandler:
page accepts the update and only reloads once all scanners are done.
"""
pending: list[str] = []
for name, getter in self._scanner_getters.items():
for name, getter in self._active_scanner_getters().items():
try:
scanner = await getter()
except Exception:
@@ -756,10 +770,19 @@ class DoctorHandler:
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
("other", "Other Models", ServiceRegistry.get_other_scanner),
)
)
self._app_version_getter = app_version_getter
def _active_scanner_factories(
self,
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
"""Drop the opt-in other scanner while Other Models is disabled."""
if self._settings.is_other_models_enabled():
return self._scanner_factories
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
try:
client_version = (request.query.get("clientVersion") or "").strip()
@@ -807,7 +830,7 @@ class DoctorHandler:
repaired: list[dict[str, Any]] = []
failures: list[dict[str, str]] = []
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
@@ -839,7 +862,7 @@ class DoctorHandler:
renamed: list[dict[str, Any]] = []
try:
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
hash_index = getattr(scanner, "_hash_index", None)
@@ -1071,7 +1094,7 @@ class DoctorHandler:
overall_status = "ok"
summary = "All model caches look healthy."
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
try:
scanner = await factory()
persisted = None
@@ -1156,7 +1179,7 @@ class DoctorHandler:
total_conflict_groups = 0
total_conflict_files = 0
for model_type, label, factory in self._scanner_factories:
for model_type, label, factory in self._active_scanner_factories():
# Duplicate filename detection targets LoRAs which use basename-only
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
# models via relative paths with extensions, so conflicts there would
@@ -1536,6 +1559,22 @@ class SettingsHandler:
response_data["civitai_api_key_set"] = bool(raw_key)
raw_llm_key = self._settings.get("llm_api_key")
response_data["llm_api_key_set"] = bool(raw_llm_key)
# Derived capability flag (not persisted): whether the host exposes
# any other-model folder at all. Standalone installs only know the
# folder_paths keys present in settings.json, so the announcement
# banner uses this to avoid promising a page that cannot list
# anything.
try:
availability = config.get_other_models_availability()
response_data["other_models_paths_available"] = bool(
availability.get("available")
)
except Exception as availability_error: # pragma: no cover - defensive
logger.debug(
"Could not resolve Other Models availability: %s",
availability_error,
)
response_data["other_models_paths_available"] = None
settings_file = getattr(self._settings, "settings_file", None)
if settings_file:
response_data["settings_file"] = settings_file
@@ -2065,6 +2104,7 @@ class ServiceRegistryAdapter:
get_embedding_scanner: Callable[[], Awaitable[Any]]
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
class ModelLibraryHandler:
@@ -2089,6 +2129,8 @@ class ModelLibraryHandler:
return "checkpoint"
if normalized in {"embedding", "textualinversion"}:
return "embedding"
if normalized in VALID_OTHER_CIVITAI_TYPES:
return "other"
return None
async def _get_scanner_for_type(self, model_type: str | None):
@@ -2099,6 +2141,13 @@ class ModelLibraryHandler:
return normalized_type, await self._service_registry.get_checkpoint_scanner()
if normalized_type == "embedding":
return normalized_type, await self._service_registry.get_embedding_scanner()
if normalized_type == "other":
# Opt-in feature: the other scanner only resolves while the master
# switch is on, so callers keep returning the legacy "required"
# error (400) when it is off.
if not get_settings_manager().is_other_models_enabled():
return None, None
return normalized_type, await self._service_registry.get_other_scanner()
return None, None
async def _get_download_history_service(self):
@@ -2190,6 +2239,11 @@ class ModelLibraryHandler:
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
# Opt-in: probe the other scanner only while Other Models is enabled,
# so the disabled behaviour stays byte-identical to the legacy one.
other_scanner = None
if get_settings_manager().is_other_models_enabled():
other_scanner = await self._service_registry.get_other_scanner()
if model_version_id_str:
try:
@@ -2228,6 +2282,13 @@ class ModelLibraryHandler:
exists = True
model_type = "embedding"
matched_scanner = embedding_scanner
elif (
other_scanner
and await other_scanner.check_model_version_exists(model_version_id)
):
exists = True
model_type = "other"
matched_scanner = other_scanner
if exists:
return web.json_response(
@@ -2245,7 +2306,7 @@ class ModelLibraryHandler:
history_service = await self._get_download_history_service()
has_been_downloaded = False
history_type = None
for candidate_type in ("lora", "checkpoint", "embedding"):
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
if await history_service.has_been_downloaded(
candidate_type,
model_version_id,
@@ -2267,6 +2328,7 @@ class ModelLibraryHandler:
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
checkpoint_versions = []
embedding_versions = []
other_versions = []
if not lora_versions and checkpoint_scanner:
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
model_id
@@ -2275,6 +2337,13 @@ class ModelLibraryHandler:
embedding_versions = await embedding_scanner.get_model_versions_by_id(
model_id
)
if (
not lora_versions
and not checkpoint_versions
and not embedding_versions
and other_scanner
):
other_versions = await other_scanner.get_model_versions_by_id(model_id)
model_type = None
versions = []
@@ -2306,9 +2375,18 @@ class ModelLibraryHandler:
"downloadedVersionIds": [],
}
)
if other_versions:
return web.json_response(
{
"success": True,
"modelType": "other",
"versions": self._with_downloaded_flag(other_versions),
"downloadedVersionIds": [],
}
)
history_service = await self._get_download_history_service()
for candidate_type in ("lora", "checkpoint", "embedding"):
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
candidate_downloaded_version_ids = (
await history_service.get_downloaded_version_ids(
candidate_type,
@@ -2363,6 +2441,11 @@ class ModelLibraryHandler:
lora_scanner = await self._service_registry.get_lora_scanner()
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
embedding_scanner = await self._service_registry.get_embedding_scanner()
# Opt-in: keep the other probe last so model cards for lora /
# checkpoint / embedding ids are unaffected by the extra scanner.
other_scanner = None
if get_settings_manager().is_other_models_enabled():
other_scanner = await self._service_registry.get_other_scanner()
results: list[dict[str, Any]] = []
for model_id in model_ids:
@@ -2398,6 +2481,17 @@ class ModelLibraryHandler:
})
continue
if other_scanner:
other_versions = await other_scanner.get_model_versions_by_id(model_id)
if other_versions:
results.append({
"modelId": model_id,
"modelType": "other",
"versions": self._with_downloaded_flag(other_versions),
"downloadedVersionIds": [],
})
continue
results.append({
"modelId": model_id,
"modelType": None,
@@ -2786,12 +2880,32 @@ class ModelLibraryHandler:
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
}
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
other_type_aliases = {
model_type.lower() for model_type in VALID_OTHER_CIVITAI_TYPES
}
# Acquire the other scanner lazily so adapters without it only
# fail when the payload actually contains other-type models.
# While the opt-in feature is off the scanner still exists (its
# cache is empty), so other types simply report inLibrary=False.
needs_other_scanner = any(
isinstance(model, dict)
and str(model.get("type", "")).lower() in other_type_aliases
for model in models
)
other_scanner = None
if needs_other_scanner:
other_scanner = await self._service_registry.get_other_scanner()
type_scanner_map: Dict[str, Any] = {
**{alias: lora_scanner for alias in lora_type_aliases},
"checkpoint": checkpoint_scanner,
"textualinversion": embedding_scanner,
}
if other_scanner is not None:
type_scanner_map.update(
{alias: other_scanner for alias in other_type_aliases}
)
versions: list[dict[str, Any]] = []
history_service = await self._get_download_history_service()
@@ -2815,12 +2929,17 @@ class ModelLibraryHandler:
"embedding",
model_ids,
)
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
"other",
model_ids,
)
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
"lora": lora_downloaded,
"locon": lora_downloaded,
"dora": lora_downloaded,
"checkpoint": checkpoint_downloaded,
"textualinversion": embedding_downloaded,
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
}
for model in models:
if not isinstance(model, dict):
@@ -3884,6 +4003,7 @@ class MiscHandlerSet:
base_model: BaseModelHandlerSet,
hf_handler: Any = None,
agent_handler: Any = None,
download_routing: Any = None,
) -> None:
self.health = health
self.settings = settings
@@ -3904,6 +4024,7 @@ class MiscHandlerSet:
self.base_model = base_model
self.hf_handler = hf_handler
self.agent_handler = agent_handler
self.download_routing = download_routing
def to_route_mapping(
self,
@@ -3962,6 +4083,8 @@ class MiscHandlerSet:
"get_agent_skills": self.agent_handler.get_agent_skills,
"execute_agent_skill": self.agent_handler.execute_agent_skill,
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
# Download routing handler
"get_download_routing": self.download_routing.get_download_routing,
# Base model handlers
"get_base_models": self.base_model.get_base_models,
"refresh_base_models": self.base_model.refresh_base_models,
@@ -3975,6 +4098,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
get_lora_scanner=ServiceRegistry.get_lora_scanner,
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
get_other_scanner=ServiceRegistry.get_other_scanner,
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
get_backup_service=ServiceRegistry.get_backup_service,
)
+12
View File
@@ -90,6 +90,7 @@ class ModelPageView:
settings_service: SettingsManager,
server_i18n,
logger: logging.Logger,
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
) -> None:
self._template_env = template_env
self._template_name = template_name
@@ -97,6 +98,7 @@ class ModelPageView:
self._settings = settings_service
self._server_i18n = server_i18n
self._logger = logger
self._page_context_provider = page_context_provider
def _load_supporters(self) -> dict[str, Any]:
"""Load supporters data from JSON file."""
@@ -210,6 +212,16 @@ class ModelPageView:
self._logger.error("Error loading cache data: %s", cache_error)
template_context["is_initializing"] = True
if self._page_context_provider is not None:
try:
extra_context = self._page_context_provider(request)
if isinstance(extra_context, dict):
template_context.update(extra_context)
except Exception as context_error: # pragma: no cover - logging path
self._logger.error(
"Error building page context: %s", context_error
)
rendered = self._template_env.get_template(self._template_name).render(
**template_context
)
@@ -35,6 +35,7 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
"loras": "get_lora_scanner",
"checkpoints": "get_checkpoint_scanner",
"embeddings": "get_embedding_scanner",
"other": "get_other_scanner",
}
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
+100 -30
View File
@@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
async def _parse_relaxed_flag(request: web.Request) -> bool:
"""Read the relaxed-rematch flag from the JSON body or query string.
The flag defaults to False (strict candidacy). A JSON body value wins;
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
"""
relaxed = False
if request.can_read_body:
try:
data = await request.json()
except Exception: # noqa: BLE001 - any parse failure means no flag
data = None
if isinstance(data, dict):
relaxed = bool(data.get("relaxed"))
if not relaxed:
relaxed = request.query.get("relaxed", "").lower() == "true"
return relaxed
@dataclass(frozen=True)
class RecipeHandlerSet:
"""Group of handlers providing recipe route implementations."""
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
recipe_scanner.reset_cancellation()
relaxed = await _parse_relaxed_flag(request)
async def progress_callback(data):
await self._ws_manager.broadcast_recipe_rematch_progress(data)
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
async def run_rematch():
try:
await recipe_scanner.rematch_all_recipes(
progress_callback=progress_callback
progress_callback=progress_callback,
relaxed=relaxed,
)
except Exception as e:
self._logger.error(
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
status=400,
)
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
relaxed = bool(data.get("relaxed")) or (
request.query.get("relaxed", "").lower() == "true"
)
result = await recipe_scanner.rematch_recipes_bulk(
recipe_ids, relaxed=relaxed
)
return web.json_response(result)
except Exception as exc:
self._logger.error(
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
)
recipe_id = request.match_info["recipe_id"]
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
relaxed = await _parse_relaxed_flag(request)
result = await recipe_scanner.rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
return web.json_response(result)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
@@ -3092,6 +3124,12 @@ class RecipeWorkflowHandler:
class BatchImportHandler:
"""Handle batch import operations for recipes."""
# Virtual path token for the Windows drive list. Browsing up from a drive
# root (e.g. C:\) lands here so users can switch drives without typing a
# path. Only meaningful on Windows; elsewhere it falls through to normal
# path handling and fails the existence check.
WINDOWS_DRIVES_TOKEN = "__drives__"
def __init__(
self,
*,
@@ -3265,31 +3303,27 @@ class BatchImportHandler:
data = await request.json()
directory_path = data.get("path", "")
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
return self._windows_drives_response()
# Default to the user's home directory. The frontend previously
# sent "/" as the initial path, which is POSIX-only: on Windows it
# resolves to the current drive root and then fails the access
# check below.
if not directory_path:
return web.json_response(
{"success": False, "error": "Directory path is required"},
status=400,
)
path = Path.home()
else:
path = Path(directory_path).expanduser().resolve()
# Normalize the path
path = Path(directory_path).expanduser().resolve()
# Security check: ensure path is within allowed directories
# Allow common image/model directories
allowed_roots = [
Path.home(),
Path("/"), # Allow browsing from root for flexibility
]
# Check if path is within any allowed root
is_allowed = False
for root in allowed_roots:
try:
path.relative_to(root)
is_allowed = True
break
except ValueError:
continue
# Access check: browsing intentionally covers the whole server
# filesystem (the server operator browses their own machine). On
# POSIX every absolute path is under "/", but Path("/") has no
# drive letter on Windows and can never anchor a drive-qualified
# path in relative_to(), so test for a drive there instead.
if os.name == "nt":
is_allowed = bool(path.drive)
else:
is_allowed = path.is_absolute()
if not is_allowed:
return web.json_response(
@@ -3356,15 +3390,24 @@ class BatchImportHandler:
directories.sort(key=lambda x: x["name"].lower())
image_files.sort(key=lambda x: x["name"].lower())
# Add parent directory if not at root
parent_path = path.parent
show_parent = str(path) != str(path.root)
# Parent directory. A filesystem root is its own parent
# (parent == path): POSIX "/" gets no parent, while a Windows
# drive root (C:\) links up to the virtual drive list so users
# can switch drives. The previous str(path) != str(path.root)
# check misfired on Windows, where a drive root's parent is
# itself, producing an infinite self-loop.
if path.parent == path:
parent_path = (
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
)
else:
parent_path = str(path.parent)
return web.json_response(
{
"success": True,
"current_path": str(path),
"parent_path": str(parent_path) if show_parent else None,
"parent_path": parent_path,
"directories": directories,
"image_files": image_files,
"image_count": len(image_files),
@@ -3391,3 +3434,30 @@ class BatchImportHandler:
except Exception as exc:
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
def _windows_drives_response(self) -> web.Response:
"""List available drive letters as a virtual directory (Windows only)."""
try:
drives = os.listdrives()
except AttributeError: # Python < 3.12
drives = [
f"{letter}:\\"
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
if os.path.exists(f"{letter}:\\")
]
directories = [
{"name": drive, "path": drive, "is_parent": False} for drive in drives
]
return web.json_response(
{
"success": True,
# Empty current_path marks the virtual level; the frontend
# disables folder selection there.
"current_path": "",
"parent_path": None,
"directories": directories,
"image_files": [],
"image_count": 0,
"directory_count": len(directories),
}
)
+4
View File
@@ -103,6 +103,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
),
# Download target routing decision (checkpoint vs diffusion model roots)
RouteDefinition(
"POST", "/api/lm/download/routing", "get_download_routing"
),
RouteDefinition(
"POST", "/api/lm/download-hf-model", "download_hf_model"
),
+3
View File
@@ -41,6 +41,7 @@ from .handlers.misc_handlers import (
from .handlers.base_model_handlers import BaseModelHandlerSet
from .handlers.hf_handlers import HfHandler
from .handlers.agent_handlers import AgentHandler
from .handlers.download_routing_handlers import DownloadRoutingHandler
from .misc_route_registrar import MiscRouteRegistrar
logger = logging.getLogger(__name__)
@@ -140,6 +141,7 @@ class MiscRoutes:
base_model = BaseModelHandlerSet()
hf_handler = HfHandler()
agent_handler = AgentHandler()
download_routing = DownloadRoutingHandler()
return self._handler_set_factory(
health=health,
@@ -161,6 +163,7 @@ class MiscRoutes:
base_model=base_model,
hf_handler=hf_handler,
agent_handler=agent_handler,
download_routing=download_routing,
)
+139
View File
@@ -0,0 +1,139 @@
import logging
import os
from typing import Any, Dict, List
from aiohttp import web
from .base_model_routes import BaseModelRoutes
from .model_route_registrar import ModelRouteRegistrar
from ..config import config
from ..services.other_model_service import OtherModelService
from ..services.service_registry import ServiceRegistry
from ..utils.constants import (
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
OTHER_MODEL_FOLDER_SUBTYPES,
VALID_OTHER_CIVITAI_TYPES,
)
logger = logging.getLogger(__name__)
class OtherRoutes(BaseModelRoutes):
"""Other-model-specific route controller (VAE, upscaler, text encoder, ...)"""
def __init__(self):
"""Initialize Other-model routes with OtherModel service"""
super().__init__()
self.template_name = "other.html"
async def initialize_services(self):
"""Initialize services from ServiceRegistry"""
other_scanner = await ServiceRegistry.get_other_scanner()
update_service = await ServiceRegistry.get_model_update_service()
self.service = OtherModelService(other_scanner, update_service=update_service)
self.set_model_update_service(update_service)
# Attach service dependencies
self.attach_service(self.service)
def setup_routes(self, app: web.Application, prefix: str = "other"):
"""Setup Other-model routes"""
# Schedule service initialization on app startup
app.on_startup.append(lambda _: self.initialize_services())
# Setup common routes with 'other' prefix (includes page route)
super().setup_routes(app, prefix)
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
"""Setup Other-model-specific routes"""
# Other-model info by name
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_other_model_info)
# Other-model roots grouped by sub_type (text_encoders + legacy clip
# are aggregated under text_encoder)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/roots_by_subtype', prefix, self.get_roots_by_subtype)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for other models.
Accepts retired CivitAI types (CLIP, CLIPVision) as well grandfathered
models on CivitAI still carry them. Types whose sub_type is currently
disabled (or every type while the opt-in feature is off) are rejected.
"""
normalized = (model_type or "").strip().lower()
if normalized not in VALID_OTHER_CIVITAI_TYPES:
return False
if not self._settings.is_other_models_enabled():
return False
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized)
if sub_type is None:
# CivitAI "Other" has no sub_type of its own; it is only usable
# while at least one sub_type is enabled.
return bool(self._settings.get_enabled_other_sub_types())
return self._settings.is_other_sub_type_enabled(sub_type)
def _get_page_context_provider(self):
"""Expose the opt-in feature state to the Other Models page template."""
return self._page_context_for_other
def _page_context_for_other(self, request: web.Request) -> Dict[str, Any]:
if not self._settings.is_other_models_enabled():
return {"other_disabled": True, "other_no_paths": False}
# Enabled but nothing to scan: folder paths for the managed sub_types
# resolved to no existing folder. Render an actionable empty state
# instead of an apparently broken empty grid.
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
return {
"other_disabled": False,
"other_no_paths": not bool(config.other_roots),
"standalone_mode": standalone_mode,
}
def _get_expected_model_types(self) -> str:
"""Get expected model types string for error messages"""
return "VAE, Upscaler, TextEncoder, CLIPVision, Controlnet, or Other"
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
"""Parse other-model-specific parameters (none in Phase 1)."""
return {}
async def get_roots_by_subtype(self, request: web.Request) -> web.Response:
"""Return other-model roots grouped by sub_type.
Aggregates the per-folder_paths-key roots from config
(``text_encoders`` and the legacy ``clip`` key both land under
``text_encoder``).
"""
try:
roots_by_subtype: Dict[str, List[str]] = {}
for key, roots in (config.other_folder_roots or {}).items():
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
if not sub_type:
continue
bucket = roots_by_subtype.setdefault(sub_type, [])
for root in roots:
if root and root not in bucket:
bucket.append(root)
return web.json_response(
{"success": True, "roots_by_subtype": roots_by_subtype}
)
except Exception as e:
logger.error(f"Error getting other roots by sub_type: {e}", exc_info=True)
return web.json_response(
{"success": False, "error": str(e)}, status=500
)
async def get_other_model_info(self, request: web.Request) -> web.Response:
"""Get detailed information for a specific other model by name"""
try:
name = request.match_info.get('name', '')
model_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
if model_info:
return web.json_response(model_info)
else:
return web.json_response({"error": "Model not found"}, status=404)
except Exception as e:
logger.error(f"Error in get_other_model_info: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
-1
View File
@@ -407,7 +407,6 @@ class AgentService:
"base_model": metadata.get("base_model", ""),
"tags": metadata.get("tags", []),
"modelDescription": metadata.get("modelDescription", ""),
"trainedWords": metadata.get("trainedWords", []),
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
"size": metadata.get("size", 0),
}
+4 -1
View File
@@ -92,7 +92,10 @@ class PostProcessor:
preview_downloaded = False
# -- Determine whether this is an HF-sourced model -----------------
is_hf_model = not metadata.get("from_civitai", True)
# Key off `hf_url` directly: `from_civitai` records provenance and can
# be true for a model that is also linked to HuggingFace (both sources
# coexist, see #1094), so it must not gate HF enrichment.
is_hf_model = bool(metadata.get("hf_url", ""))
# -- Collect updates -----------------------------------------------
updates: Dict[str, Any] = {}
+81 -11
View File
@@ -161,6 +161,11 @@ class Aria2Downloader:
(typically an expired CivitAI signed URL): a fresh URL is resolved
and the partial download continues. Recovery is bounded by
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
Cancellation never leaks daemon transfers: the gid is tracked in
``_transfers`` before any post-``addUri`` await, and a gid accepted
by the daemon while the caller is being cancelled is removed again
before the ``CancelledError`` propagates.
"""
await self._ensure_process()
@@ -251,7 +256,11 @@ class Aria2Downloader:
await asyncio.sleep(self._poll_interval)
finally:
current = self._transfers.get(download_id)
if current is not None and current.gid == transfer.gid:
if (
transfer is not None
and current is not None
and current.gid == transfer.gid
):
self._transfers.pop(download_id, None)
async def _get_status_with_retry(
@@ -339,21 +348,43 @@ class Aria2Downloader:
resolved_url != url,
)
# Shield the addUri RPC from cancellation: the daemon may accept the
# download even when the caller is cancelled while the request is in
# flight. On cancellation, wait for the RPC result so the freshly
# created gid can be removed instead of leaking an untracked
# download that keeps running in the daemon.
add_task = asyncio.ensure_future(
self._rpc_call("aria2.addUri", [[resolved_url], options])
)
try:
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
gid = await asyncio.shield(add_task)
except asyncio.CancelledError:
leaked_gid: Any = None
try:
leaked_gid = await add_task
except Exception:
leaked_gid = None
if isinstance(leaked_gid, str) and leaked_gid:
logger.info(
"Removing aria2 gid %s accepted while download %s was "
"being cancelled",
leaked_gid,
download_id,
)
try:
await self._rpc_call("aria2.forceRemove", [leaked_gid])
except Exception as exc:
logger.warning(
"Failed to remove leaked aria2 gid %s for download %s: %s",
leaked_gid,
download_id,
exc,
)
raise
except Exception as exc:
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": save_path,
"status": "downloading",
"url": url,
},
)
return gid
async def _register_transfer(
@@ -372,7 +403,46 @@ class Aria2Downloader:
headers=headers,
)
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
# Register the transfer before any further await: once the daemon
# holds the gid, cancel_download() must be able to find it. An await
# in between would open a window where a concurrent cancel reports
# "Download task not found" and the daemon keeps downloading
# untracked.
self._transfers[download_id] = transfer
try:
await self._state_store.upsert(
download_id,
{
"gid": gid,
"save_path": transfer.save_path,
"status": "downloading",
"url": url,
},
)
except asyncio.CancelledError:
# The task was cancelled while persisting state and the
# coordinator's cancel ran before the transfer was registered
# above. Remove the daemon transfer unless it was deliberately
# paused (skip_download preserves paused transfers for resume).
status = None
try:
status = await self.get_status(download_id)
except Exception:
status = None
if status is not None and status.get("status") != "paused":
try:
await self._rpc_call("aria2.forceRemove", [gid])
except Exception as exc:
logger.warning(
"Failed to remove aria2 gid %s for cancelled download %s: %s",
gid,
download_id,
exc,
)
current = self._transfers.get(download_id)
if current is not None and current.gid == gid:
self._transfers.pop(download_id, None)
raise
return transfer
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
+6 -1
View File
@@ -7,7 +7,7 @@ import logging
import os
import time
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES, VALID_OTHER_SUB_TYPES
from ..utils.models import BaseModelMetadata
from ..utils.metadata_manager import MetadataManager
from ..utils.usage_stats import UsageStats
@@ -904,6 +904,11 @@ class BaseModelService(ABC):
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
):
continue
if (
self.model_type == "other"
and normalized_type not in VALID_OTHER_SUB_TYPES
):
continue
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
+5 -3
View File
@@ -410,6 +410,10 @@ class CheckpointScanner(ModelScanner):
return None
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Resolve sub_type from the configured root that contains the file."""
return self._resolve_sub_type(self._find_root_for_file(file_path))
def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path)
@@ -419,9 +423,7 @@ class CheckpointScanner(ModelScanner):
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
sub_type = self._resolve_sub_type(
self._find_root_for_file(entry.get("file_path"))
)
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
if sub_type:
entry["sub_type"] = sub_type
return entry
+122 -26
View File
@@ -17,13 +17,18 @@ from dataclasses import dataclass, field
import uuid
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.models import (
LoraMetadata,
CheckpointMetadata,
EmbeddingMetadata,
OtherModelMetadata,
)
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
VALID_OTHER_CIVITAI_TYPES,
)
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256, calculate_autov3
@@ -32,6 +37,7 @@ from ..utils.utils import sanitize_folder_name
from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
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
@@ -228,12 +234,21 @@ class DownloadManager:
return False
async def _get_scanner_for_model_type(self, model_type: str):
"""Return the scanner responsible for the given model type."""
"""Return the scanner responsible for the given model type.
Every supported type resolves explicitly an unknown type must never
fall through to the lora scanner (an "other" download would silently
dedupe against the lora library).
"""
if model_type == "checkpoint":
return await self._get_checkpoint_scanner()
if model_type == "embedding":
return await ServiceRegistry.get_embedding_scanner()
return await self._get_lora_scanner()
if model_type == "other":
return await ServiceRegistry.get_other_scanner()
if model_type == "lora":
return await self._get_lora_scanner()
raise ValueError(f'Unknown model type "{model_type}"')
@staticmethod
def _resolve_target_file(
@@ -978,6 +993,8 @@ class DownloadManager:
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "embedding":
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
if model_type == "other":
return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path)
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
@@ -1438,6 +1455,7 @@ class DownloadManager:
lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner()
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
other_scanner = await ServiceRegistry.get_other_scanner()
# Check lora scanner first
if await lora_scanner.check_model_version_exists(model_version_id):
@@ -1462,6 +1480,13 @@ class DownloadManager:
"error": "Model version already exists in embedding library",
}
# Check other scanner
if await other_scanner.check_model_version_exists(model_version_id):
return {
"success": False,
"error": "Model version already exists in other library",
}
# Use CivArchive provider directly when source is 'civarchive'
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
if source == "civarchive":
@@ -1500,6 +1525,20 @@ class DownloadManager:
model_type = "lora"
elif model_type_from_info == "textualinversion":
model_type = "embedding"
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
if not get_settings_manager().is_other_models_enabled():
return {
"success": False,
"error": (
"Other Models management is disabled. Enable it in "
"Settings > Library before downloading VAE, upscaler, "
"text encoder or CLIP files."
),
# Machine-readable failure code consumed by the companion
# browser extension (docs/other-models-support.md C4).
"reason": "other_models_disabled",
}
model_type = "other"
else:
return {
"success": False,
@@ -1621,27 +1660,13 @@ class DownloadManager:
}
# Check if this checkpoint should be treated as a diffusion model
# Priority: (1) any file has type "UNet" or "Diffusion Model",
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
is_diffusion_model = False
if model_type == "checkpoint":
# Check file types first (more direct signal from CivitAI)
version_files = version_info.get("files", [])
for f in version_files:
f_type = f.get("type", "")
if f_type in ("UNet", "Diffusion Model"):
is_diffusion_model = True
logger.info(
f"File type '{f_type}' detected, routing checkpoint to unet folder"
)
break
# Fallback to baseModel name check
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
is_diffusion_model = True
logger.info(
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# (shared with the download routing endpoint so the UI location
# step and the actual download agree on the target roots).
is_diffusion_model = is_diffusion_model_download(
model_type,
file_types=(f.get("type", "") for f in version_info.get("files", [])),
base_model=base_model_value,
)
# Existence check after the metadata fetch (#1058):
# - An explicit file selection only blocks when THIS file is
@@ -1700,6 +1725,13 @@ class DownloadManager:
"success": False,
"error": "Model version already exists in embedding library",
}
elif model_type == "other":
other_scanner = await ServiceRegistry.get_other_scanner()
if await other_scanner.check_model_version_exists(version_id):
return {
"success": False,
"error": "Model version already exists in other library",
}
# Handle use_default_paths
if use_default_paths:
@@ -1739,6 +1771,60 @@ class DownloadManager:
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
elif model_type == "other":
other_sub_type = resolve_other_download_sub_type(
model_type_from_info,
file_types=(
f.get("type", "")
for f in version_info.get("files", [])
if isinstance(f, dict)
),
selected_file_type=(
target_file.get("type") if explicit_file else None
),
)
default_other_roots = (
settings_manager.get("default_other_roots") or {}
)
if other_sub_type and not settings_manager.is_other_sub_type_enabled(
other_sub_type
):
return {
"success": False,
"error": (
f"Other-model sub-type '{other_sub_type}' is "
f"disabled in settings. Please pick a destination "
f"folder explicitly instead of using default paths."
),
"reason": "other_sub_type_disabled",
}
default_path = (
default_other_roots.get(other_sub_type)
if other_sub_type
else None
)
if not isinstance(default_path, str) or not default_path:
if other_sub_type:
detail = (
f"No default root configured for other-model "
f"sub-type '{other_sub_type}'"
)
reason = "other_no_default_root"
else:
detail = (
"Could not determine the other-model sub-type "
"from the model metadata"
)
reason = "other_sub_type_undecidable"
return {
"success": False,
"error": (
f"{detail}. Please pick a destination folder "
f"explicitly instead of using default paths."
),
"reason": reason,
}
save_dir = default_path
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -1935,6 +2021,11 @@ class DownloadManager:
version_info, file_info, save_path
)
logger.info(f"Creating EmbeddingMetadata for {file_name}")
elif model_type == "other":
metadata = OtherModelMetadata.from_civitai_info(
version_info, file_info, save_path
)
logger.info(f"Creating OtherModelMetadata for {file_name}")
else:
return {
"success": False,
@@ -2147,6 +2238,8 @@ class DownloadManager:
scanner = await self._get_checkpoint_scanner()
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
except Exception as exc:
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
@@ -2643,6 +2736,9 @@ class DownloadManager:
elif model_type == "embedding":
scanner = await ServiceRegistry.get_embedding_scanner()
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
elif model_type == "other":
scanner = await ServiceRegistry.get_other_scanner()
logger.info(f"Updating other-model cache for {actual_file_paths[0]}")
adjust_cached_entry = (
getattr(scanner, "adjust_cached_entry", None)
@@ -2732,7 +2828,7 @@ class DownloadManager:
return {"success": False, "error": str(e)}
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
if model_type == "checkpoint":
if model_type in ("checkpoint", "other"):
return {
".ckpt",
".pt",
+103
View File
@@ -0,0 +1,103 @@
"""Shared download routing logic.
Decides whether a download initiated from the checkpoint library should be
routed to the unet/diffusion-model roots instead of the checkpoint roots.
Used by both the download manager (at download time) and the download
routing HTTP endpoint (when the user picks a location in the UI), so the
two can never disagree.
"""
from __future__ import annotations
import logging
from typing import Iterable, Optional
from ..utils.constants import (
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
DIFFUSION_MODEL_BASE_MODELS,
)
logger = logging.getLogger(__name__)
# File types reported by the CivitAI API that indicate a raw diffusion
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
def is_diffusion_model_download(
model_type: str,
file_types: Iterable[str] = (),
base_model: str = "",
) -> bool:
"""Return True when a download should be routed to the unet roots.
Only applies to downloads initiated from the checkpoint library.
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
direct signal from CivitAI), (2) baseModel is a known diffusion model.
"""
if model_type != "checkpoint":
return False
for file_type in file_types:
if file_type in DIFFUSION_FILE_TYPES:
logger.info(
"File type '%s' detected, routing checkpoint to unet folder",
file_type,
)
return True
if base_model in DIFFUSION_MODEL_BASE_MODELS:
logger.info(
"baseModel '%s' is a known diffusion model, routing to unet folder",
base_model,
)
return True
return False
def resolve_other_download_sub_type(
civitai_model_type: str,
file_types: Iterable[str] = (),
selected_file_type: Optional[str] = None,
) -> Optional[str]:
"""Resolve the "other"-page sub_type for a download.
Fixed priority (locked design, docs/plans/other-models-page.md §9.2):
1. Explicit user file pick when the picked file's type maps, it wins
even when model.type maps to something else.
2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE.
3. file.type fallback only when model.type maps to nothing. Must NOT
override a mapped model.type: checkpoint models routinely bundle
VAE/Text Encoder component files.
4. Still undecidable -> None (caller must ask the user for a folder).
"""
if selected_file_type:
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type)
if mapped:
logger.info(
"Explicit file pick type '%s' routes other download to '%s'",
selected_file_type,
mapped,
)
return mapped
normalized_model_type = (civitai_model_type or "").strip().lower()
mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type)
if mapped:
return mapped
for file_type in file_types:
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type)
if mapped:
logger.info(
"model.type '%s' unmapped; file type '%s' routes other download to '%s'",
civitai_model_type,
file_type,
mapped,
)
return mapped
return None
+91 -48
View File
@@ -11,6 +11,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Any, Dict, List, Optional
import aiohttp
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
# ``{provider_id: {model_id: max_output_tokens}}``.
_model_output_limits: Dict[str, Dict[str, int]] = {}
# Monotonic timestamp of the last failed catalog fetch (None = no failure
# yet). Failed fetches are negatively cached: further calls return the
# empty fallback without hitting the network until the cooldown elapses,
# so users on broken networks don't stall on every settings-modal open.
_catalog_last_failure: Optional[float] = None
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
# Serializes catalog fetches so concurrent callers don't duplicate requests.
_catalog_lock = asyncio.Lock()
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
# Cloudflare serves brotli when the client advertises it, and brotli is a
# required dependency here — a corrupted br stream can crash the native
# decoder with a Windows access violation (issue #1099). Request gzip
# instead; zlib decompression is not affected and corrupt gzip data only
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
# exception handlers below already catch.
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
async def _load_model_catalog() -> Dict[str, List[str]]:
"""Fetch and parse the model catalog.
@@ -46,61 +65,85 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
value has a ``models`` sub-dict keyed by model ID. The result is cached
in memory after the first successful fetch.
Subsequent calls return the cached data immediately.
Failed fetches are negatively cached: further calls return an empty
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
has elapsed, so a broken network does not stall every settings-modal
open. Concurrent callers are serialized behind :data:`_catalog_lock`
so only one request is ever in flight.
"""
global _catalog_cache, _model_output_limits
global _catalog_cache, _model_output_limits, _catalog_last_failure
if _catalog_cache is not None:
return _catalog_cache
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
async with session.get(_MODEL_CATALOG_URL) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
return _catalog_cache or {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
return _catalog_cache or {}
async with _catalog_lock:
# Re-check under the lock: another caller may have fetched (or
# failed) while we were waiting.
if _catalog_cache is not None:
return _catalog_cache
if (
_catalog_last_failure is not None
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
):
logger.debug(
"Skipping model catalog fetch: last attempt failed %.0fs ago",
time.monotonic() - _catalog_last_failure,
)
return {}
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
return _catalog_cache or {}
try:
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200:
logger.warning("Model catalog returned HTTP %s", resp.status)
_catalog_last_failure = time.monotonic()
return {}
data = await resp.json()
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
logger.warning("Failed to fetch model catalog: %s", exc)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
if not isinstance(data, dict):
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
_catalog_last_failure = time.monotonic()
return {}
result: Dict[str, List[str]] = {}
output_limits: Dict[str, Dict[str, int]] = {}
for provider_id, provider_info in data.items():
if not isinstance(provider_info, dict):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids:
result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
models_dict = provider_info.get("models")
if not isinstance(models_dict, dict):
continue
model_ids: List[str] = []
provider_limits: Dict[str, int] = {}
for mid, model_info in models_dict.items():
if not isinstance(mid, str):
continue
model_ids.append(mid)
if isinstance(model_info, dict):
limit = model_info.get("limit")
if isinstance(limit, dict):
output = limit.get("output")
if isinstance(output, (int, float)) and output > 0:
provider_limits[mid] = int(output)
if model_ids:
result[provider_id] = model_ids
if provider_limits:
output_limits[provider_id] = provider_limits
_catalog_cache = result
_model_output_limits = output_limits
logger.debug(
"Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result),
sum(len(m) for m in result.values()),
len(output_limits),
)
return result
_catalog_cache = result
_model_output_limits = output_limits
logger.debug(
"Loaded model catalog: %d providers, %d total models "
"(%d providers have output limits)",
len(result),
sum(len(m) for m in result.values()),
len(output_limits),
)
return result
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
@@ -126,7 +169,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
url = f"{api_base.rstrip('/')}/models"
try:
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
async with session.get(url) as resp:
async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
if resp.status != 200:
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
return []
+5
View File
@@ -33,6 +33,11 @@ class ModelCache:
raw_data: List[Dict[str, Any]]
folders: List[str]
# Every directory under the model roots (including empty ones), as
# recorded by the last scan/hydration. ``None`` means "never recorded"
# (e.g. a persisted snapshot predating this field) and triggers a
# background filesystem backfill in the scanner.
all_folders: Optional[List[str]] = None
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
# Multi-valued companion to version_index: every local file entry of a
+256 -77
View File
@@ -62,16 +62,13 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
# 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',
'other': 'other',
}
@@ -89,6 +86,10 @@ class CacheBuildResult:
hash_index: ModelHashIndex
tags_count: Dict[str, int]
excluded_models: List[str]
# Every directory under the model roots (including empty ones) discovered
# during the scan, or None when the source has no folder information
# (e.g. a persisted snapshot predating folder recording).
all_folders: Optional[List[str]] = None
class ModelScanner:
"""Base service for scanning and managing model files"""
@@ -144,8 +145,9 @@ class ModelScanner:
self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
# Guard against concurrent all-folders backfill walks (cold fallback
# for persisted snapshots that predate folder recording).
self._all_folders_backfill_running = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
@@ -208,8 +210,14 @@ class ModelScanner:
"""
self._cache_version += 1
def on_library_changed(self) -> None:
"""Reset caches when the active library changes."""
def on_library_changed(self, reconcile: bool = False) -> None:
"""Reset caches when the active library changes.
When ``reconcile`` is True an incremental reconcile runs right after
the cache is re-hydrated, so newly configured roots are scanned and
entries for removed roots are purged. Used when scanner-affecting
settings (e.g. the Other Models toggles) change.
"""
self._persistent_cache = get_persistent_cache()
self._cache = None
self._hash_index = ModelHashIndex()
@@ -217,7 +225,6 @@ class ModelScanner:
self._excluded_models = []
self._is_initializing = False
self._name_display_mode = self._resolve_name_display_mode()
self.invalidate_all_folders_cache()
self.bump_cache_version()
try:
@@ -228,7 +235,7 @@ class ModelScanner:
if loop and not loop.is_closed():
self._loop = loop
self.loop = loop
loop.create_task(self.initialize_in_background())
loop.create_task(self.initialize_in_background(reconcile=reconcile))
def _resolve_name_display_mode(self) -> str:
"""Return the configured display mode for name sorting."""
@@ -459,8 +466,14 @@ class ModelScanner:
_, license_flags = resolve_license_info(license_source)
entry['license_flags'] = license_flags
async def initialize_in_background(self) -> None:
"""Initialize cache in background using thread pool"""
async def initialize_in_background(self, reconcile: bool = False) -> None:
"""Initialize cache in background using thread pool
Args:
reconcile: When True and a persisted snapshot is hydrated, run an
incremental reconcile afterwards so the cache matches the
current root configuration.
"""
try:
# Set initial empty cache to avoid None reference errors
if self._cache is None:
@@ -500,6 +513,11 @@ class ModelScanner:
logger.info(
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
)
if reconcile:
# Root configuration changed (e.g. Other Models toggles):
# pick up newly enabled folders and drop rows for folders
# that are no longer managed.
await self.get_cached_data(force_refresh=True)
return
# Persistent load failed; fall back to a full scan
@@ -662,21 +680,33 @@ class ModelScanner:
if not persisted or not persisted.raw_data:
return None
# Drop entries the scanner no longer manages (e.g. an other-model
# sub_type the user just disabled) before rebuilding the indexes, so
# hash/autov3 lookups cannot resolve to unmanaged files either.
kept_items = [
item
for item in persisted.raw_data
if self._should_keep_cached_entry(item)
]
kept_paths = {
item.get("file_path") for item in kept_items if item.get("file_path")
}
hash_index = ModelHashIndex()
for sha_value, path in persisted.hash_rows:
if sha_value and path:
if sha_value and path and path in kept_paths:
hash_index.add_entry(sha_value.lower(), path)
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
# cover every known autov3 -> path mapping regardless of whether a
# sha256 row also exists for the same file.
for autov3_value, path in persisted.autov3_hash_rows:
if autov3_value and path:
if autov3_value and path and path in kept_paths:
hash_index.add_autov3(autov3_value.lower(), path)
tags_count: Dict[str, int] = {}
adjusted_raw_data: List[Dict[str, Any]] = []
for item in persisted.raw_data:
for item in kept_items:
# load_cache builds a fresh dict per row, and validate_batch below
# works on its own per-entry copy when auto_repair=True, so no
# additional dict copy is needed here.
@@ -702,7 +732,8 @@ class ModelScanner:
raw_data=valid_entries,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=list(persisted.excluded_models)
excluded_models=list(persisted.excluded_models),
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
)
return scan_result, invalid_entries
@@ -737,6 +768,7 @@ class ModelScanner:
hash_snapshot,
list(scan_result.excluded_models),
autov3_snapshot,
scan_result.all_folders,
)
except Exception as exc:
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
@@ -784,7 +816,12 @@ class ModelScanner:
raw_data=list(self._cache.raw_data),
hash_index=self._hash_index,
tags_count=dict(self._tags_count),
excluded_models=list(self._excluded_models)
excluded_models=list(self._excluded_models),
all_folders=(
list(self._cache.all_folders)
if self._cache.all_folders is not None
else None
),
)
await self._save_persistent_cache(snapshot)
await self._sync_download_history(snapshot.raw_data, source='scan')
@@ -1005,20 +1042,36 @@ class ModelScanner:
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
# Get current cached file paths
cached_size_before = len(self._cache.raw_data)
cached_paths = {item['file_path'] for item in self._cache.raw_data}
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
# physical path -> cached business path, for the alias case where the
# same file is reachable under a different path than the cached one
# (overlapping roots / symlink layout changes): keep the existing
# entry instead of delete + re-add (which would re-read metadata and
# re-hash every file). Built lazily on the first miss, because a
# realpath per cached entry is ~half the cost of a no-change
# reconcile and the map is only ever consulted for misses.
cached_real_paths: Optional[Dict[str, str]] = None
def lookup_cached_real_path(real_path: str) -> Optional[str]:
nonlocal cached_real_paths
if cached_real_paths is None:
cached_real_paths = {}
for cached_path in cached_paths:
try:
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
except Exception:
continue
return cached_real_paths.get(real_path)
# Track found files and new files
found_paths = set()
new_files = []
visited_real_paths = set()
discovered_real_files = set()
discovered_folders: Set[str] = set()
# Scan all model roots
for root_path in self.get_model_roots():
@@ -1033,19 +1086,31 @@ class ModelScanner:
continue
visited_real_paths.add(real_root)
# Record every visited directory (including empty ones) so
# the folder tree stays accurate without a live walk.
rel_dir = os.path.relpath(
os.path.abspath(root), os.path.abspath(root_path)
).replace(os.path.sep, "/")
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
discovered_folders.add(rel_dir)
for file in files:
ext = os.path.splitext(file)[1].lower()
if ext in self.file_extensions:
# Construct paths exactly as they would be in cache
file_path = os.path.join(root, file).replace(os.sep, '/')
real_file_path = os.path.realpath(os.path.join(root, file))
# Check if this file is already in cache
if file_path in cached_paths:
found_paths.add(file_path)
continue
cached_real_match = cached_real_paths.get(real_file_path)
# Only a cache miss needs the physical path, so the
# realpath syscalls are paid per changed file rather
# than per file in the library.
real_file_path = os.path.realpath(os.path.join(root, file))
cached_real_match = lookup_cached_real_path(real_file_path)
if cached_real_match:
found_paths.add(cached_real_match)
continue
@@ -1090,6 +1155,9 @@ class ModelScanner:
total_new = len(new_files)
processed_new = 0
last_progress_time = time.time()
# Snapshot the roots once: this matches the walk above (which
# also snapshots them) and avoids a config read per new file.
model_roots = self.get_model_roots()
for i in range(0, total_new, batch_size):
batch = new_files[i:i+batch_size]
for path in batch:
@@ -1098,12 +1166,10 @@ class ModelScanner:
try:
# Find the appropriate root path for this file
root_path = None
model_roots = self.get_model_roots()
normalized_path = os.path.normpath(path)
for potential_root in model_roots:
# Normalize both paths for comparison
normalized_path = os.path.normpath(path)
normalized_root = os.path.normpath(potential_root)
if normalized_path.startswith(normalized_root):
if normalized_path.startswith(os.path.normpath(potential_root)):
root_path = potential_root
break
@@ -1200,25 +1266,41 @@ class ModelScanner:
# Update cache data
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
dedup_removed = 0
seen_paths: set[str] = set()
deduped: list[Dict[str, Any]] = []
for item in reversed(self._cache.raw_data):
path = item.get('file_path', '')
if path not in seen_paths:
seen_paths.add(path)
deduped.append(item)
else:
for tag in item.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
dedup_removed += 1
if dedup_removed > 0:
self._cache.raw_data = list(reversed(deduped))
total_removed += dedup_removed
# Defensive integrity pass: drop entries sharing a business path.
# Duplicates can only be introduced by external code rewriting
# raw_data directly or by this pass's own appends, so an unchanged
# filesystem walk over a clean cache has nothing to clean. The size
# mismatch is an O(1) tell that the snapshot already contained
# duplicates; skipping the O(N) pass when it is provably clean is
# what keeps a no-change Refresh cheap.
if cached_size_before != len(cached_paths) or total_added > 0:
dedup_removed = 0
seen_paths: set[str] = set()
deduped: list[Dict[str, Any]] = []
for item in reversed(self._cache.raw_data):
path = item.get('file_path', '')
if path not in seen_paths:
seen_paths.add(path)
deduped.append(item)
else:
for tag in item.get('tags', []):
if tag in self._tags_count:
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
if self._tags_count[tag] == 0:
del self._tags_count[tag]
dedup_removed += 1
if dedup_removed > 0:
self._cache.raw_data = list(reversed(deduped))
total_removed += dedup_removed
# The walk above visited every directory, so refresh the recorded
# folder list (including empty folders) even when no model files
# changed — e.g. an empty folder was created or removed externally.
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
folders_changed = self._cache.all_folders != sorted_discovered
if folders_changed:
self._cache.all_folders = sorted_discovered
# Resort cache if changes were made
if total_added > 0 or total_removed > 0:
# Update folders list
@@ -1231,6 +1313,8 @@ class ModelScanner:
await self._cache.resort()
await self._persist_current_cache()
elif folders_changed:
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(
@@ -1270,22 +1354,73 @@ class ModelScanner:
raise NotImplementedError("Subclasses must implement get_model_roots")
async def get_all_folders(self) -> List[str]:
"""Return every known directory under the model roots.
The directory list (including empty ones) is recorded during cache
scans and hydrated from the persisted snapshot, so this is a pure
in-memory read no filesystem walk ever runs on the event loop
(walking network roots synchronously used to freeze the whole
server, see issue #1110). The result is unioned with the
model-derived folders so it is always a superset of
``cache.folders``.
Cold fallback: when the cache was hydrated from a persisted snapshot
that predates folder recording (``all_folders is None``), a one-shot
background walk is scheduled off the event loop to backfill and
persist the list; until it lands, the models-only folders are
returned.
"""
folders: Set[str] = set()
cache = self._cache
if cache is not None:
folders |= {item.get('folder', '') for item in cache.raw_data}
recorded = getattr(cache, 'all_folders', None)
if recorded is None:
self._schedule_all_folders_backfill()
else:
folders |= set(recorded)
else:
self._schedule_all_folders_backfill()
return sorted(folders, key=lambda x: x.lower())
def _schedule_all_folders_backfill(self) -> None:
"""Kick off a one-shot background folder walk if none is running."""
if self._all_folders_backfill_running:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
self._all_folders_backfill_running = True
loop.create_task(self._run_all_folders_backfill())
async def _run_all_folders_backfill(self) -> None:
"""Walk the roots in a worker thread, then record and persist the result."""
try:
loop = asyncio.get_running_loop()
folders = await loop.run_in_executor(None, self._walk_all_folders_sync)
cache = self._cache
# A scan may have recorded the list while the walk was in flight;
# prefer the fresher scan data in that case.
if cache is not None and cache.all_folders is None:
cache.all_folders = folders
await self._persist_current_cache()
except Exception as exc:
logger.warning(
"%s Scanner: all-folders backfill failed: %s",
self.model_type.capitalize(),
exc,
)
finally:
self._all_folders_backfill_running = False
def _walk_all_folders_sync(self) -> List[str]:
"""Enumerate every directory under the model roots, live from disk.
Unlike the models-only ``cache.folders``, this includes empty
directories, so it stays accurate even when the in-memory cache was
hydrated from a persisted snapshot without a filesystem walk. Hidden
directories (any segment starting with '.') and the pending-delete
staging dir are excluded. The result is unioned with the model-derived
folders so it is always a superset of ``cache.folders``, and cached
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
Runs in a worker thread. Hidden directories (any segment starting
with '.') and the pending-delete staging dir are excluded.
"""
now = time.monotonic()
if self._all_folders_ttl_cache is not None:
cached_at, cached_folders = self._all_folders_ttl_cache
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
return cached_folders
discovered: Set[str] = set()
visited_real_paths: Set[str] = set()
@@ -1307,17 +1442,7 @@ class ModelScanner:
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
discovered.add(rel_dir)
folders = set(discovered)
if self._cache is not None:
folders |= {item.get('folder', '') for item in self._cache.raw_data}
result = sorted(folders, key=lambda x: x.lower())
self._all_folders_ttl_cache = (now, result)
return result
def invalidate_all_folders_cache(self) -> None:
"""Drop the cached get_all_folders() result (e.g. after a move)."""
self._all_folders_ttl_cache = None
return sorted(discovered, key=lambda x: x.lower())
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
"""Get model file info and metadata (extensible for different model types)"""
@@ -1339,6 +1464,23 @@ class ModelScanner:
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
return entry
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
"""Hook for subclasses: decide whether a persisted entry is still managed.
Entries rejected here are dropped (with their hash/autov3 index rows)
while hydrating the persisted cache, so a scanner whose configured
roots shrank does not surface stale models before the next reconcile.
"""
return True
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Hook for subclasses: resolve the location-derived sub_type for a file.
Returns ``None`` when the model type has no location-derived sub-types
(the default), in which case any stored value is left untouched.
"""
return None
@staticmethod
def _normalize_path_value(path: Optional[str]) -> str:
if not path:
@@ -1533,6 +1675,9 @@ class ModelScanner:
else:
self._cache.raw_data = list(scan_result.raw_data)
if scan_result.all_folders is not None:
self._cache.all_folders = list(scan_result.all_folders)
# resort() rebuilds folders and the version index on every path, so a
# separate rebuild_version_index() call here would be redundant.
await self._cache.resort()
@@ -1630,6 +1775,7 @@ class ModelScanner:
processed_files = 0
processed_real_files: Set[str] = set()
visited_real_dirs: Set[str] = set()
discovered_folders: Set[str] = set()
async def handle_progress(current_name: str = '') -> None:
if progress_callback is None:
@@ -1708,6 +1854,13 @@ class ModelScanner:
elif entry.is_dir(follow_symlinks=True):
if _is_excluded_dir(entry.name):
continue
# Record every directory (including empty ones) so
# the folder tree can be served without a live walk.
rel_dir = os.path.relpath(
os.path.abspath(entry.path), os.path.abspath(root_path)
).replace(os.path.sep, "/")
if not _is_hidden_relative_path(rel_dir):
discovered_folders.add(rel_dir)
await scan_recursive(entry.path, root_path, visited_paths)
except Exception as entry_error:
logger.error(f"Error processing entry {entry.path}: {entry_error}")
@@ -1727,7 +1880,8 @@ class ModelScanner:
raw_data=raw_data,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=excluded_models
excluded_models=excluded_models,
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
)
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
@@ -1869,6 +2023,20 @@ class ModelScanner:
except Exception as e:
logger.error(f"Error moving metadata file: {e}")
if metadata is not None:
# sub_type is derived from the model's location (e.g. a file
# moved from a checkpoints root into a unet root becomes a
# diffusion_model). Persist the recalculated value into the
# moved metadata file so later metadata-driven cache syncs
# do not revert the cache entry to the stale sub_type.
new_sub_type = self.resolve_sub_type_for_path(target_file)
if new_sub_type and metadata.get('sub_type') != new_sub_type:
metadata['sub_type'] = new_sub_type
try:
await MetadataManager.save_metadata(moved_metadata_path, metadata)
except Exception as e:
logger.error(f"Error persisting sub_type for moved model: {e}")
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
return {
@@ -1970,6 +2138,16 @@ class ModelScanner:
all_folders = set(item['folder'] for item in cache.raw_data)
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
# The move target may live in directories the last scan never saw;
# record the destination folder (and its parents) in the known
# folder list so the folder tree reflects it without a rescan.
if cache.all_folders is not None and folder_value:
parts = folder_value.split("/")
known = set(cache.all_folders)
for i in range(1, len(parts) + 1):
known.add("/".join(parts[:i]))
cache.all_folders = sorted(known, key=lambda x: x.lower())
for tag in cache_entry.get('tags', []):
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
@@ -1977,10 +2155,6 @@ class ModelScanner:
await cache.resort()
# A move may have created new directories; drop the cached live-walk
# result so the next include_empty request sees them.
self.invalidate_all_folders_cache()
if cache_modified:
await self._persist_current_cache()
self.bump_cache_version()
@@ -2064,6 +2238,11 @@ class ModelScanner:
file_path_override=file_path,
)
# Location-derived fields (e.g. the checkpoint sub_type) must be
# re-resolved from the file path rather than trusting the on-disk
# metadata snapshot, which may predate a cross-root move.
desired_entry = self.adjust_cached_entry(desired_entry)
# Ensure sha256 is populated (defensive — metadata should have it)
if (
not desired_entry.get("sha256")
+10 -5
View File
@@ -118,19 +118,24 @@ class ModelServiceFactory:
def register_default_model_types():
"""Register the default model types (LoRA, Checkpoint, and Embedding)"""
"""Register the default model types (LoRA, Checkpoint, Embedding, and Other)"""
from ..services.lora_service import LoraService
from ..services.checkpoint_service import CheckpointService
from ..services.embedding_service import EmbeddingService
from ..services.other_model_service import OtherModelService
from ..routes.lora_routes import LoraRoutes
from ..routes.checkpoint_routes import CheckpointRoutes
from ..routes.embedding_routes import EmbeddingRoutes
from ..routes.other_routes import OtherRoutes
# Register LoRA model type
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
# Register Checkpoint model type
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
# Register Embedding model type
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
# Register Other model type (VAE, upscaler, text encoder, ...)
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
+79
View File
@@ -0,0 +1,79 @@
import os
import logging
from typing import Any, Dict, Optional
from .base_model_service import BaseModelService
from .auto_tag_service import extract_auto_tags
from ..utils.models import OtherModelMetadata
from ..config import config
logger = logging.getLogger(__name__)
class OtherModelService(BaseModelService):
"""Other-model-specific service implementation (VAE, upscaler, text encoder, ...)"""
def __init__(self, scanner, update_service=None):
"""Initialize Other-model service
Args:
scanner: Other-model scanner instance
update_service: Optional service for remote update tracking.
"""
super().__init__("other", scanner, OtherModelMetadata, update_service=update_service)
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Format other-model data for API response.
Returns None when the entry is missing critical fields (corrupted cache
row), so the handler layer can filter it out. See issue #730.
"""
# Guard against corrupted cache entries missing critical fields
file_path = model_data.get("file_path")
if not file_path or not isinstance(file_path, str):
logger.warning(
"Skipping corrupted other-model entry (missing file_path): %s",
model_data.get("file_name", "<unknown>"),
)
return None
# Get sub_type from cache entry (new canonical field)
sub_type = model_data.get("sub_type", "vae")
file_name = model_data.get("file_name") or ""
model_name = model_data.get("model_name") or file_name
folder = model_data.get("folder") or ""
return {
"model_name": model_name,
"file_name": file_name,
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
"base_model": model_data.get("base_model", ""),
"folder": folder,
"sha256": model_data.get("sha256", ""),
"autov3": model_data.get("autov3"),
"file_path": file_path.replace(os.sep, "/"),
"file_size": model_data.get("size", 0),
"modified": model_data.get("modified", ""),
"tags": model_data.get("tags", []),
"from_civitai": model_data.get("from_civitai", True),
"notes": model_data.get("notes", ""),
"sub_type": sub_type,
"favorite": model_data.get("favorite", False),
"exclude": bool(model_data.get("exclude", False)),
"update_available": bool(model_data.get("update_available", False)),
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
"version_count": model_data.get("version_count"),
"hf_url": model_data.get("hf_url", ""),
}
def find_duplicate_hashes(self) -> Dict[str, Any]:
"""Find other models with duplicate SHA256 hashes"""
return self.scanner._hash_index.get_duplicate_hashes()
def find_duplicate_filenames(self) -> Dict[str, Any]:
"""Find other models with conflicting filenames"""
return self.scanner._hash_index.get_duplicate_filenames()
+478
View File
@@ -0,0 +1,478 @@
# pyright: reportImportCycles=false
# 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 asyncio
import json
import logging
import os
from datetime import datetime
from typing import Any, Dict, List, Optional
from ..utils.models import OtherModelMetadata
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
from ..utils.metadata_manager import MetadataManager
from ..config import config
from .model_scanner import ModelScanner, _is_excluded_dir
from .model_hash_index import ModelHashIndex
logger = logging.getLogger(__name__)
class OtherScanner(ModelScanner):
"""Service for scanning and managing "other" model files.
Aggregates every enabled folder_paths category from
OTHER_MODEL_FOLDER_SUBTYPES (VAE, upscalers, text encoders, CLIP vision,
opt-in ControlNet) into one scanner; sub_type is derived from the root
containing the file (mirrors CheckpointScanner's checkpoints/unet split).
Hashing is lazy (checkpoint-style): text encoders can be ~10 GB, so the
initial scan records hash_status="pending" and the SHA256 is computed
on-demand via calculate_hash_for_model (e.g. when fetching CivitAI
metadata).
"""
def __init__(self):
# Same extension set as CheckpointScanner (ComfyUI's
# supported_pt_extensions plus ".gguf").
file_extensions = {
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".safetensors",
".pkl",
".sft",
".gguf",
}
super().__init__(
model_type="other",
model_class=OtherModelMetadata,
file_extensions=file_extensions,
hash_index=ModelHashIndex(),
)
if not hasattr(self, "_hash_calculation_lock"):
self._hash_calculation_lock = asyncio.Lock()
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
async def _create_default_metadata(
self, file_path: str
) -> Optional[OtherModelMetadata]:
"""Create default metadata without calculating hash (lazy hash).
Other models include multi-GB text encoders, so hash calculation is
deferred until on-demand (e.g. CivitAI metadata fetch).
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found: {file_path}")
return None
base_name = os.path.splitext(os.path.basename(file_path))[0]
dir_path = os.path.dirname(file_path)
# Find preview image
preview_url = find_preview_file(base_name, dir_path)
# AutoV3 reads only the safetensors header, so it is cheap even for
# large files; record the checked state at creation time ("" =
# checked but unavailable).
autov3 = calculate_autov3(real_path)
# Create metadata WITHOUT calculating hash
metadata = OtherModelMetadata(
file_name=base_name,
model_name=base_name,
file_path=normalize_path(file_path),
size=os.path.getsize(real_path),
modified=datetime.now().timestamp(),
sha256="", # Empty hash - will be calculated on-demand
base_model="Unknown",
preview_url=normalize_path(preview_url),
tags=[],
modelDescription="",
sub_type=self.resolve_sub_type_for_path(file_path) or "vae",
from_civitai=False, # Mark as local model since no hash yet
hash_status="pending", # Mark hash as pending
autov3=autov3 or "",
)
# Save the created metadata
logger.info(f"Creating other-model metadata (hash pending) for {file_path}")
await MetadataManager.save_metadata(file_path, metadata)
return metadata
except Exception as e:
logger.error(
f"Error creating default other-model metadata for {file_path}: {e}"
)
return None
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
"""Calculate hash for a model on-demand with per-file singleflight.
Args:
file_path: Path to the model file
Returns:
SHA256 hash string, or None if calculation failed
"""
try:
real_path = os.path.realpath(file_path)
if not os.path.exists(real_path):
logger.error(f"File not found for hash calculation: {file_path}")
return None
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if (
metadata is not None
and metadata.hash_status == "completed"
and metadata.sha256
):
# Ensure the in-memory hash index is populated even when
# the hash was already computed and persisted to the metadata
# file. Without this, usage tracking (and any other caller
# that queries get_hash_by_filename first) will miss on every
# lookup and keep calling back into this method, creating a
# tight loop that never populates the index.
self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256
async with self._hash_calculation_lock:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if (
metadata is not None
and metadata.hash_status == "completed"
and metadata.sha256
):
self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256
task = self._hash_calculation_tasks.get(real_path)
if task is None:
task = asyncio.create_task(
self._run_hash_calculation_task(file_path, real_path)
)
self._hash_calculation_tasks[real_path] = task
return await asyncio.shield(task)
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
return None
async def _run_hash_calculation_task(
self, file_path: str, real_path: str
) -> Optional[str]:
"""Run a hash calculation task and remove it from the in-flight map."""
try:
return await self._calculate_hash_for_model_uncached(file_path, real_path)
finally:
task = asyncio.current_task()
async with self._hash_calculation_lock:
if self._hash_calculation_tasks.get(real_path) is task:
del self._hash_calculation_tasks[real_path]
async def _calculate_hash_for_model_uncached(
self, file_path: str, real_path: str
) -> Optional[str]:
"""Calculate hash for a model without checking in-flight tasks."""
from ..utils.file_utils import calculate_sha256
try:
# Load current metadata
metadata, should_skip = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata is None:
if should_skip:
logger.error(f"Invalid metadata found for {file_path}")
return None
created_metadata = await self._create_default_metadata(file_path)
if created_metadata is None:
logger.error(f"No metadata found for {file_path}")
return None
metadata = created_metadata
# Check if hash is already calculated
if metadata.hash_status == "completed" and metadata.sha256:
# Populate the in-memory hash index even for pre-computed
# hashes, mirroring the fix in calculate_hash_for_model.
self._hash_index.add_entry(
metadata.sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
return metadata.sha256
# Update status to calculating
metadata.hash_status = "calculating"
await MetadataManager.save_metadata(file_path, metadata)
# Calculate hash
logger.info(f"Calculating hash for other model: {file_path}")
sha256 = await calculate_sha256(real_path)
# Update metadata with hash
metadata.sha256 = sha256
metadata.hash_status = "completed"
await MetadataManager.save_metadata(file_path, metadata)
# Update hash index
self._hash_index.add_entry(
sha256.lower(),
file_path,
getattr(metadata, "autov3", None) or None,
)
# Update the in-memory cache entry so that subsequent
# _persist_current_cache / _save_persistent_cache calls
# write the hash back to the SQLite models table. Without
# this the hash only lives in the metadata file and the
# in-memory hash index, both of which are lost across
# restarts, causing the same re-computation loop on the
# next session.
if self._cache is not None and self._cache.raw_data:
for entry in self._cache.raw_data:
if entry.get("file_path") == file_path:
entry["sha256"] = sha256.lower()
entry["hash_status"] = "completed"
self.bump_cache_version()
break
logger.info(f"Hash calculated for other model: {file_path}")
return sha256
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
# Update status to failed
try:
metadata, _ = await MetadataManager.load_metadata(
file_path, self.model_class
)
if metadata:
metadata.hash_status = "failed"
await MetadataManager.save_metadata(file_path, metadata)
except Exception:
pass
return None
async def calculate_all_pending_hashes(
self, progress_callback=None
) -> Dict[str, int]:
"""Calculate hashes for all other models with pending hash status.
If cache is not initialized, scans filesystem directly for metadata files
with hash_status != 'completed'.
Args:
progress_callback: Optional callback(progress, total, current_file)
Returns:
Dict with 'completed', 'failed', 'total' counts
"""
# Try to get from cache first
cache = await self.get_cached_data()
if cache and cache.raw_data:
# Use cache if available
pending_models = [
item
for item in cache.raw_data
if item.get("hash_status") != "completed" or not item.get("sha256")
]
else:
# Cache not initialized, scan filesystem directly
pending_models = await self._find_pending_models_from_filesystem()
if not pending_models:
return {"completed": 0, "failed": 0, "total": 0}
total = len(pending_models)
completed = 0
failed = 0
for i, model_data in enumerate(pending_models):
file_path = model_data.get("file_path")
if not file_path:
continue
try:
sha256 = await self.calculate_hash_for_model(file_path)
if sha256:
completed += 1
else:
failed += 1
except Exception as e:
logger.error(f"Error calculating hash for {file_path}: {e}")
failed += 1
if progress_callback:
try:
await progress_callback(i + 1, total, file_path)
except Exception:
pass
return {"completed": completed, "failed": failed, "total": total}
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
"""Scan filesystem for other-model metadata files with pending hash status."""
pending_models = []
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
for dirpath, dirnames, filenames in os.walk(root_path):
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
for filename in filenames:
if not filename.endswith(".metadata.json"):
continue
metadata_path = os.path.join(dirpath, filename)
try:
with open(metadata_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Check if hash is pending
hash_status = data.get("hash_status", "completed")
sha256 = data.get("sha256", "")
if hash_status != "completed" or not sha256:
# Find corresponding model file
model_name = filename.replace(".metadata.json", "")
model_path = None
# Look for model file with matching name
for ext in self.file_extensions:
potential_path = os.path.join(dirpath, model_name + ext)
if os.path.exists(potential_path):
model_path = potential_path
break
if model_path:
pending_models.append(
{
"file_path": model_path.replace(os.sep, "/"),
"hash_status": hash_status,
"sha256": sha256,
**{
k: v
for k, v in data.items()
if k
not in [
"file_path",
"hash_status",
"sha256",
]
},
}
)
except (json.JSONDecodeError, Exception) as e:
logger.debug(
f"Error reading metadata file {metadata_path}: {e}"
)
continue
return pending_models
def _root_sub_type_map(self) -> Dict[str, str]:
"""Return the configured business root -> sub_type map."""
root_map = getattr(config, "other_root_subtypes", None)
return root_map if isinstance(root_map, dict) else {}
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
"""Resolve the sub_type for a configured root path."""
if not root_path:
return None
normalized_root = self._normalize_path_value(root_path)
for root, sub_type in self._root_sub_type_map().items():
if self._normalize_path_value(root) == normalized_root:
return sub_type
return None
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
"""Resolve sub_type from the configured root that contains the file.
Uses the longest-prefix match so nested roots (e.g. a controlnet root
inside a vae root) resolve to the most specific category.
"""
normalized_path = self._normalize_path_value(file_path)
if not normalized_path:
return None
best_length = 0
best_sub_type: Optional[str] = None
for root, sub_type in self._root_sub_type_map().items():
normalized_root = self._normalize_path_value(root)
if not normalized_root:
continue
if (
normalized_path == normalized_root
or normalized_path.startswith(f"{normalized_root}/")
) and len(normalized_root) > best_length:
best_length = len(normalized_root)
best_sub_type = sub_type
return best_sub_type
def adjust_metadata(self, metadata, file_path, root_path):
"""Adjust metadata during scanning to set sub_type."""
sub_type = self._resolve_sub_type(root_path) or self.resolve_sub_type_for_path(
file_path
)
if sub_type:
metadata.sub_type = sub_type
return metadata
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Adjust entries loaded from the persisted cache to ensure sub_type is set.
sub_type is location-derived: it is re-derived on cache load, never
trusted from the persisted snapshot.
"""
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
if sub_type:
entry["sub_type"] = sub_type
return entry
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
"""Drop persisted entries whose folder is no longer a managed root.
sub_type is location-derived and config only maps enabled roots, so a
file under a disabled sub_type - or under any other root while the
feature is off - resolves to None here and is filtered out while the
persisted cache is hydrated.
"""
return self.resolve_sub_type_for_path(entry.get("file_path")) is not None
def get_model_roots(self) -> List[str]:
"""Get other-model root directories"""
roots: List[str] = []
roots.extend(config.other_roots or [])
# Remove duplicates while preserving order
seen: set[str] = set()
unique_roots: List[str] = []
for root in roots:
if root and root not in seen:
seen.add(root)
unique_roots.append(root)
return unique_roots
+2
View File
@@ -59,6 +59,7 @@ _MODEL_TYPE_PAGE_MAP = {
"lora": "loras",
"checkpoint": "checkpoints",
"embedding": "embeddings",
"other": "other",
}
# Module-level alias so tests can spy on timer task creation without patching
@@ -983,6 +984,7 @@ class PendingDeleteService:
"get_lora_scanner",
"get_checkpoint_scanner",
"get_embedding_scanner",
"get_other_scanner",
):
getter = getattr(ServiceRegistry, getter_name, None)
if not callable(getter):
+50 -1
View File
@@ -19,6 +19,9 @@ class PersistedCacheData:
hash_rows: List[Tuple[str, str]]
excluded_models: List[str]
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
# Every directory under the model roots (including empty ones), or None
# when the snapshot predates folder recording.
all_folders: Optional[List[str]] = None
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
@@ -128,6 +131,14 @@ class PersistentModelCache:
"SELECT file_path FROM excluded_models WHERE model_type = ?",
(model_type,),
).fetchall()
folder_rows = conn.execute(
"SELECT path FROM folders WHERE model_type = ?",
(model_type,),
).fetchall()
folders_recorded = conn.execute(
"SELECT value FROM cache_meta WHERE key = ?",
(f"folders_recorded:{model_type}",),
).fetchone()
finally:
conn.close()
except Exception as exc:
@@ -216,14 +227,20 @@ class PersistentModelCache:
]
excluded_paths = [row["file_path"] for row in excluded]
all_folders: Optional[List[str]] = None
if folders_recorded is not None:
all_folders = sorted(
(row["path"] for row in folder_rows), key=lambda x: x.lower()
)
return PersistedCacheData(
raw_data=raw_data,
hash_rows=hash_pairs,
excluded_models=excluded_paths,
autov3_hash_rows=autov3_pairs,
all_folders=all_folders,
)
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
if not self.is_enabled():
return
if not self._schema_initialized:
@@ -469,6 +486,27 @@ class PersistentModelCache:
excluded_inserts,
)
if all_folders is not None:
conn.execute(
"DELETE FROM folders WHERE model_type = ?",
(model_type,),
)
folder_inserts = [
(model_type, path) for path in all_folders if path
]
if folder_inserts:
conn.executemany(
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
folder_inserts,
)
# Mark the snapshot as having folder data even when the
# library has no subfolders, so an empty list is not
# mistaken for "never recorded" on load.
conn.execute(
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
(f"folders_recorded:{model_type}", "1"),
)
conn.commit()
finally:
conn.close()
@@ -554,6 +592,17 @@ class PersistentModelCache:
file_path TEXT NOT NULL,
PRIMARY KEY (model_type, file_path)
);
CREATE TABLE IF NOT EXISTS folders (
model_type TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (model_type, path)
);
CREATE TABLE IF NOT EXISTS cache_meta (
key TEXT PRIMARY KEY,
value TEXT
);
"""
)
self._ensure_additional_model_columns(conn)
+159 -31
View File
@@ -483,32 +483,43 @@ class RecipeScanner:
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
return suggestions[:limit]
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
def _is_rematch_candidate(
self, entry: dict[str, Any], relaxed: bool = False
) -> bool:
"""Return True when a recipe entry is eligible for local re-matching.
An entry counts as unresolved when its identity is known to be
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
identity fields (``hash``/``file_name``). A healthy entry whose
hash is simply not present in the local library is NOT a candidate:
it may be a recipe imported without downloading the model yet, and
its CivitAI-valid hash must never be overwritten by the imprecise
filename fallback.
hash is simply not present in the local library is NOT a candidate
in the default strict mode: it may be a recipe imported without
downloading the model yet, and its CivitAI-valid hash must never be
overwritten by the imprecise filename fallback.
With ``relaxed=True`` any entry carrying an identifier is a
candidate, including healthy ones the caller opted into trying to
reconnect "Not in Library" entries by file name. Entries without
any identifier are never candidates in either mode.
"""
if not isinstance(entry, dict):
return False
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
has_identifier = (
entry.get("hash")
or entry.get("modelVersionId")
or entry.get("id")
or entry.get("file_name")
)
return bool(unresolved and has_identifier)
if not has_identifier:
return False
if relaxed:
return True
unresolved = (
entry.get("isDeleted")
or entry.get("hashInvalid")
or not entry.get("hash")
or not entry.get("file_name")
)
return bool(unresolved)
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
"""Build a version-cached map of computed AutoV3 hashes to local items.
@@ -809,7 +820,9 @@ class RecipeScanner:
"""Check if cancellation has been requested."""
return self._cancel_requested
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
async def rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Logs one INFO summary line for this run and delegates the per-recipe
@@ -817,12 +830,14 @@ class RecipeScanner:
Args:
recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
Raises RecipeNotFoundError when the recipe is missing.
"""
result = await self._rematch_recipe_by_id(recipe_id)
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
logger.info(
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
@@ -835,7 +850,9 @@ class RecipeScanner:
)
return result
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
async def _rematch_recipe_by_id(
self, recipe_id: str, *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache, computed autov3 cache, filename
@@ -846,12 +863,16 @@ class RecipeScanner:
Args:
recipe_id: ID of the recipe to rematch
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch result with unified counters
(matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy rematched/skipped/errors
fields) and a per-entry ``details`` report. The legacy ``skipped``
fields) and a per-entry ``details`` report plus a flattened
``l4_matches`` list (filename-level matches for review/undo,
consistent with the bulk/global paths). The legacy ``skipped``
field means "recipe not updated" and overlaps
``unresolved_recipes`` (a recipe with unmatched candidates counts
as both). Raises RecipeNotFoundError when the recipe is missing.
@@ -872,7 +893,8 @@ class RecipeScanner:
try:
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache
recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
)
except RecipePersistenceError as exc:
logger.error(
@@ -891,12 +913,16 @@ class RecipeScanner:
"unresolved_recipes": 0,
"unresolved_entries": 0,
"details": {"matched": [], "unresolved": []},
"l4_matches": [],
"recipe": recipe,
"error": str(exc),
}
unresolved_entries = len(details["unresolved"])
unresolved_recipes = 1 if unresolved_entries > 0 else 0
# Flattened L4 matches for the results modal, consistent with
# the bulk/global paths.
l4_matches = self._collect_l4_matches(recipe_id, details)
if rematched == 0:
return {
@@ -908,6 +934,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"l4_matches": l4_matches,
"recipe": recipe,
}
@@ -921,6 +948,7 @@ class RecipeScanner:
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"details": details,
"l4_matches": l4_matches,
"recipe": await self.get_recipe_by_id(recipe_id),
}
@@ -930,6 +958,8 @@ class RecipeScanner:
local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
*,
relaxed: bool = False,
) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models.
@@ -945,16 +975,24 @@ class RecipeScanner:
autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback
relaxed: When True, healthy entries ("Not in Library") are also
rematch candidates. Anti-churn rule: an entry that is a
candidate ONLY because of relaxed mode is skipped when its
hash already resolves in the L1 ``local_cache`` it is
already correctly linked and rematching would only add noise
and a pointless snapshot.
Returns:
Tuple of (rematched_entries, errors, details). The errors element
is always 0 on a normal return a persistence failure RAISES
``RecipePersistenceError`` so callers can count it. ``details``
carries the per-entry outcome:
``{"matched": [{type, entry, file_name, match_level}],
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
"unresolved": [{type, entry}]}`` where an unresolved entry is a
rematch candidate that found no local match an expected outcome
(the model may simply not exist locally), not an error.
``lora_index`` is only present for lora entries (the checkpoint
restore endpoint needs no index).
Raises:
RecipePersistenceError: when the recipe changed but
@@ -963,11 +1001,23 @@ class RecipeScanner:
rematched = 0
details: Dict[str, Any] = {"matched": [], "unresolved": []}
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
if self._is_rematch_candidate(entry):
return True
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
return False
# Relaxed-only candidate: skip when the stored hash already
# resolves in the L1 local cache — the entry is already correctly
# linked and rematching would just add noise and a snapshot.
entry_hash = (entry.get("hash") or "").lower()
return local_cache.get(entry_hash) is None
# Lora entries
loras = recipe.get("loras", [])
if isinstance(loras, list):
for entry in loras:
if not self._is_rematch_candidate(entry):
for lora_index, entry in enumerate(loras):
if not is_actionable_candidate(entry):
continue
item, level = await self._match_rematch_entry_with_level(
entry,
@@ -991,6 +1041,7 @@ class RecipeScanner:
"entry": self._entry_identifier(entry),
"file_name": item.get("file_name") or "",
"match_level": level,
"lora_index": lora_index,
}
)
self._write_rematch_lora_entry(entry, item)
@@ -1000,7 +1051,7 @@ class RecipeScanner:
# silently since ``entry.get`` on a str would raise AttributeError).
checkpoint = recipe.get("checkpoint")
if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint):
if is_actionable_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level(
checkpoint,
local_cache,
@@ -1065,8 +1116,36 @@ class RecipeScanner:
self._update_fts_index_for_recipe(recipe, "update")
return (rematched, 0, details)
@staticmethod
def _collect_l4_matches(
recipe_id: Any, details: Dict[str, Any]
) -> List[Dict[str, Any]]:
"""Flatten a recipe's L4 (filename-level) matches for review.
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows
one per matched detail at level L4. ``lora_index`` is only present
for lora entries (checkpoint restore needs no index).
"""
rows: List[Dict[str, Any]] = []
for match in details.get("matched", []):
if match.get("match_level") != "L4":
continue
row: Dict[str, Any] = {
"recipe_id": recipe_id,
"type": match.get("type"),
"entry": match.get("entry"),
"file_name": match.get("file_name"),
}
if "lora_index" in match:
row["lora_index"] = match["lora_index"]
rows.append(row)
return rows
async def rematch_all_recipes(
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
self,
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
*,
relaxed: bool = False,
) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally.
@@ -1080,14 +1159,19 @@ class RecipeScanner:
Args:
progress_callback: Optional callback for progress updates
(started/processing/cancelled/completed events).
(started/processing/cancelled/completed events). The
completed/cancelled payloads carry ``l4_matches``, a
flattened list of filename-level matches for review/undo.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the rematch run with unified counters
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
entries plus the legacy success/status/rematched/skipped/errors/
total fields). ``rematched`` (legacy) counts updated recipes
use ``matched_entries`` for the entry-level total.
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
updated recipes use ``matched_entries`` for the entry-level
total.
"""
start_time = time.perf_counter()
@@ -1109,6 +1193,7 @@ class RecipeScanner:
unresolved_entries = 0
skipped_count = 0
errors_count = 0
l4_matches: List[Dict[str, Any]] = []
for i, recipe in enumerate(all_recipes):
if self.is_cancelled():
@@ -1137,6 +1222,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
)
return {
@@ -1150,6 +1236,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
try:
@@ -1165,11 +1252,15 @@ class RecipeScanner:
)
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache, filename_cache
recipe, local_cache, autov3_cache, filename_cache,
relaxed=relaxed,
)
if rematched > 0:
matched_recipes += 1
matched_entries += rematched
l4_matches.extend(
self._collect_l4_matches(recipe.get("id"), details)
)
else:
skipped_count += 1
@@ -1215,6 +1306,7 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
)
@@ -1228,9 +1320,12 @@ class RecipeScanner:
"matched_entries": matched_entries,
"unresolved_recipes": unresolved_recipes,
"unresolved_entries": unresolved_entries,
"l4_matches": l4_matches,
}
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
async def rematch_recipes_bulk(
self, recipe_ids: List[str], *, relaxed: bool = False
) -> Dict[str, Any]:
"""Rematch a set of recipes by their IDs.
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
@@ -1241,14 +1336,18 @@ class RecipeScanner:
Args:
recipe_ids: List of recipe ids to rematch.
relaxed: When True, healthy entries are rematch candidates too
(see ``_rematch_single_recipe``).
Returns:
Dict summary of the bulk run with unified counters
(matched_recipes, matched_entries, unresolved_recipes,
unresolved_entries plus the legacy total/rematched/skipped/errors
fields) and a per-recipe ``details`` list. The legacy ``rematched``
field is the total entry count (same as ``matched_entries``)
unlike ``rematch_all_recipes`` where it counts updated recipes.
fields), a per-recipe ``details`` list, and ``l4_matches`` a
flattened list of filename-level matches for review/undo. The
legacy ``rematched`` field is the total entry count (same as
``matched_entries``) unlike ``rematch_all_recipes`` where it
counts updated recipes.
"""
total = len(recipe_ids)
matched_recipes = 0
@@ -1259,10 +1358,13 @@ class RecipeScanner:
errors = 0
recipes: List[Dict[str, Any]] = []
details_list: List[Dict[str, Any]] = []
l4_matches: List[Dict[str, Any]] = []
for recipe_id in recipe_ids:
try:
result = await self._rematch_recipe_by_id(recipe_id)
result = await self._rematch_recipe_by_id(
recipe_id, relaxed=relaxed
)
if result.get("success"):
matched_recipes += result.get("matched_recipes", 0)
matched_entries += result.get("matched_entries", 0)
@@ -1275,6 +1377,9 @@ class RecipeScanner:
details_list.append(
{"recipe_id": recipe_id, **result["details"]}
)
l4_matches.extend(
self._collect_l4_matches(recipe_id, result["details"])
)
else:
errors += result.get("errors", 0)
except RecipeNotFoundError:
@@ -1309,12 +1414,22 @@ class RecipeScanner:
"unresolved_entries": unresolved_entries,
"recipes": recipes,
"details": details_list,
"l4_matches": l4_matches,
}
def _write_rematch_lora_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
"""Write back a matched local model to a lora recipe entry."""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow in
# ``update_lora_entry``. Never nest snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False
entry["hashInvalid"] = False
@@ -1338,6 +1453,8 @@ class RecipeScanner:
if civitai.get("name"):
entry["modelVersionName"] = civitai["name"]
entry["reconnectSnapshot"] = snapshot
def _write_rematch_checkpoint_entry(
self, entry: Dict[str, Any], item: Dict[str, Any]
) -> None:
@@ -1349,6 +1466,15 @@ class RecipeScanner:
when they already exist on the entry (or written fresh for the
identifier key when neither identifier form exists).
"""
# Snapshot the pre-rematch state so the association can be restored
# later (undo), mirroring the manual reconnect flow. Never nest
# snapshots.
snapshot = {
key: copy.deepcopy(value)
for key, value in entry.items()
if key != "reconnectSnapshot"
}
entry["isDeleted"] = False
entry["hashInvalid"] = False
@@ -1389,6 +1515,8 @@ class RecipeScanner:
else:
entry["modelVersionId"] = civ_id
entry["reconnectSnapshot"] = snapshot
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
"""Helper to save a recipe to both JSON and EXIF metadata."""
recipe_id = recipe.get("id")
+26 -5
View File
@@ -297,23 +297,44 @@ class ServiceRegistry:
async def get_embedding_scanner(cls):
"""Get or create Embedding scanner instance"""
service_name = "embedding_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .embedding_scanner import EmbeddingScanner
scanner = await EmbeddingScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
async def get_other_scanner(cls):
"""Get or create Other-model scanner instance"""
service_name = "other_scanner"
if service_name in cls._services:
return cls._services[service_name]
async with cls._get_lock(service_name):
# Double-check after acquiring lock
if service_name in cls._services:
return cls._services[service_name]
# Import here to avoid circular imports
from .other_scanner import OtherScanner
scanner = await OtherScanner.get_instance()
cls._services[service_name] = scanner
logger.debug(f"Created and registered {service_name}")
return scanner
@classmethod
def clear_services(cls):
"""Clear all registered services - mainly for testing"""
+174 -15
View File
@@ -25,9 +25,14 @@ from typing import (
from platformdirs import user_config_dir
from ..utils.constants import (
DEFAULT_DOWNLOAD_PATH_TEMPLATES,
DEFAULT_ENABLED_OTHER_SUB_TYPES,
DEFAULT_HASH_CHUNK_SIZE_MB,
DEFAULT_PRIORITY_TAG_CONFIG,
OTHER_SUB_TYPE_FOLDER_KEYS,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_OTHER_SUB_TYPES,
normalize_other_sub_types,
)
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
from ..utils.settings_paths import (
@@ -83,6 +88,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"default_checkpoint_root": "",
"default_unet_root": "",
"default_embedding_root": "",
"default_other_roots": {},
# Other Models management is opt-in: nothing is scanned, shown or offered
# for download until the user turns the feature on.
"enable_other_models": False,
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
"recipes_path": "",
"base_model_path_mappings": {},
"download_path_templates": {},
@@ -309,6 +319,7 @@ class SettingsManager:
default_checkpoint_root=merged.get("default_checkpoint_root"),
default_unet_root=merged.get("default_unet_root"),
default_embedding_root=merged.get("default_embedding_root"),
default_other_roots=merged.get("default_other_roots"),
recipes_path=merged.get("recipes_path"),
)
}
@@ -443,6 +454,7 @@ class SettingsManager:
),
default_unet_root=self.settings.get("default_unet_root", ""),
default_embedding_root=self.settings.get("default_embedding_root", ""),
default_other_roots=self.settings.get("default_other_roots"),
recipes_path=self.settings.get("recipes_path", ""),
)
libraries = {library_name: library_payload}
@@ -494,6 +506,7 @@ class SettingsManager:
default_checkpoint_root=data.get("default_checkpoint_root"),
default_unet_root=data.get("default_unet_root"),
default_embedding_root=data.get("default_embedding_root"),
default_other_roots=data.get("default_other_roots"),
recipes_path=data.get("recipes_path"),
metadata=data.get("metadata"),
base=data,
@@ -541,6 +554,9 @@ class SettingsManager:
self.settings["default_embedding_root"] = active_library.get(
"default_embedding_root", ""
)
self.settings["default_other_roots"] = self._normalize_default_other_roots(
active_library.get("default_other_roots", {})
)
self.settings["recipes_path"] = active_library.get("recipes_path", "")
if save:
@@ -558,6 +574,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
base: Optional[Mapping[str, Any]] = None,
@@ -597,6 +614,15 @@ class SettingsManager:
else:
payload.setdefault("default_embedding_root", "")
if default_other_roots is not None:
payload["default_other_roots"] = self._normalize_default_other_roots(
default_other_roots
)
else:
payload["default_other_roots"] = self._normalize_default_other_roots(
payload.get("default_other_roots", {})
)
if recipes_path is not None:
payload["recipes_path"] = recipes_path
else:
@@ -632,6 +658,71 @@ class SettingsManager:
normalized[key] = cleaned
return normalized
def _normalize_default_other_roots(
self, value: Any, *, strict: bool = False
) -> Dict[str, str]:
"""Normalize a ``default_other_roots`` mapping ({sub_type: root path}).
Unknown sub_type keys and non-string/empty paths are dropped; with
``strict=True`` unknown sub_type keys raise instead (used by ``set()``
so typos in API payloads surface as errors).
"""
if not isinstance(value, Mapping):
if strict and value is not None:
raise ValueError("default_other_roots must be a mapping")
return {}
normalized: Dict[str, str] = {}
for sub_type, path in value.items():
if sub_type not in VALID_OTHER_SUB_TYPES:
if strict:
raise ValueError(
f"Unknown other-model sub-type '{sub_type}'; "
f"expected one of {sorted(VALID_OTHER_SUB_TYPES)}"
)
continue
if not isinstance(path, str):
continue
stripped = path.strip()
if stripped:
normalized[sub_type] = stripped
return normalized
def is_other_models_enabled(self) -> bool:
"""Return True when the opt-in Other Models management is enabled."""
return bool(self.settings.get("enable_other_models", False))
def get_enabled_other_sub_types(self) -> List[str]:
"""Return the enabled other-model sub_types (empty when the feature is off)."""
if not self.is_other_models_enabled():
return []
return normalize_other_sub_types(self.settings.get("enabled_other_sub_types"))
def is_other_sub_type_enabled(self, sub_type: Optional[str]) -> bool:
"""Return True when ``sub_type`` is currently managed."""
if not sub_type:
return False
return sub_type in self.get_enabled_other_sub_types()
def _apply_other_model_settings_change(self) -> None:
"""Rebuild other-model roots and refresh the other scanner after a toggle."""
try:
from ..config import config # Local import to avoid circular dependency
config.refresh_other_roots()
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other-model roots: %s", exc)
try:
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
scanner = ServiceRegistry.get_service_sync("other_scanner")
if scanner is not None and hasattr(scanner, "on_library_changed"):
# reconcile=True lets the scanner pick up newly enabled roots and
# purge rows for folders that are no longer managed.
scanner.on_library_changed(reconcile=True)
except Exception as exc: # pragma: no cover - defensive logging
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
def _has_configured_paths(self, folder_paths: Any) -> bool:
if not isinstance(folder_paths, Mapping):
return False
@@ -744,6 +835,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
) -> bool:
libraries = self.settings.get("libraries", {})
@@ -794,6 +886,14 @@ class SettingsManager:
library["default_embedding_root"] = default_embedding_root
changed = True
if default_other_roots is not None:
normalized_other_roots = self._normalize_default_other_roots(
default_other_roots
)
if library.get("default_other_roots") != normalized_other_roots:
library["default_other_roots"] = normalized_other_roots
changed = True
if recipes_path is not None and library.get("recipes_path") != recipes_path:
library["recipes_path"] = recipes_path
changed = True
@@ -894,12 +994,53 @@ class SettingsManager:
updated = _check_and_auto_set("unet", "default_unet_root") or updated
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
# Other-model default roots: one entry per enabled sub_type; candidates
# are the union of that sub_type's folder_paths keys (text_encoder
# merges the legacy 'clip' key with 'text_encoders'). When the opt-in
# feature is off the existing mapping is left untouched.
other_roots = self._normalize_default_other_roots(
self.settings.get("default_other_roots")
)
if self.is_other_models_enabled():
for sub_type in self.get_enabled_other_sub_types():
candidates: List[str] = []
candidate_identities: set[str] = set()
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
for candidate in self._get_valid_root_candidates(folder_key):
identity = _normalize_root_identity(candidate)
if identity in candidate_identities:
continue
candidate_identities.add(identity)
candidates.append(candidate)
if not candidates:
continue
current = other_roots.get(sub_type, "")
if current and _normalize_root_identity(current) in candidate_identities:
continue
other_roots[sub_type] = candidates[0]
if current:
logger.info(
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
sub_type,
current,
candidates[0],
)
else:
logger.info(
"Auto-set default_other_roots[%s] to '%s'",
sub_type,
candidates[0],
)
updated = True
if updated:
self.settings["default_other_roots"] = other_roots
self._update_active_library_entry(
default_lora_root=self.settings.get("default_lora_root"),
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
default_unet_root=self.settings.get("default_unet_root"),
default_embedding_root=self.settings.get("default_embedding_root"),
default_other_roots=other_roots,
)
if self._bootstrap_reason == "missing":
self._needs_initial_save = True
@@ -1599,6 +1740,12 @@ class SettingsManager:
value = self.normalize_download_skip_base_models(value)
elif key == "mature_blur_level":
value = self.normalize_mature_blur_level(value)
elif key == "default_other_roots":
value = self._normalize_default_other_roots(value, strict=True)
elif key == "enabled_other_sub_types":
value = normalize_other_sub_types(value)
elif key == "enable_other_models":
value = bool(value)
elif key == "recipes_path":
current_recipes_dir = self._get_effective_recipes_dir()
value = self._normalize_recipes_path_value(value)
@@ -1626,6 +1773,8 @@ class SettingsManager:
self._update_active_library_entry(default_unet_root=str(value))
elif key == "default_embedding_root":
self._update_active_library_entry(default_embedding_root=str(value))
elif key == "default_other_roots":
self._update_active_library_entry(default_other_roots=value)
elif key == "recipes_path":
self._update_active_library_entry(recipes_path=str(value))
elif key == "model_name_display":
@@ -1633,6 +1782,8 @@ class SettingsManager:
self._save_settings()
if key == "recipes_path":
self._notify_library_change(self.get_active_library_name())
if key in ("enable_other_models", "enabled_other_sub_types"):
self._apply_other_model_settings_change()
if portable_switch_pending:
self._finalize_portable_switch()
@@ -1796,6 +1947,7 @@ class SettingsManager:
"lora_scanner",
"checkpoint_scanner",
"embedding_scanner",
"other_scanner",
"recipe_scanner",
):
service = ServiceRegistry.get_service_sync(service_name)
@@ -1960,6 +2112,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
@@ -2004,6 +2157,11 @@ class SettingsManager:
if default_embedding_root is not None
else existing.get("default_embedding_root")
),
default_other_roots=(
default_other_roots
if default_other_roots is not None
else existing.get("default_other_roots")
),
recipes_path=(
recipes_path
if recipes_path is not None
@@ -2036,6 +2194,7 @@ class SettingsManager:
default_checkpoint_root: str = "",
default_unet_root: str = "",
default_embedding_root: str = "",
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: str = "",
metadata: Optional[Mapping[str, Any]] = None,
activate: bool = False,
@@ -2054,6 +2213,7 @@ class SettingsManager:
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
default_other_roots=default_other_roots,
recipes_path=recipes_path,
metadata=metadata,
activate=activate,
@@ -2114,6 +2274,7 @@ class SettingsManager:
default_checkpoint_root: Optional[str] = None,
default_unet_root: Optional[str] = None,
default_embedding_root: Optional[str] = None,
default_other_roots: Optional[Mapping[str, str]] = None,
recipes_path: Optional[str] = None,
) -> None:
"""Update folder paths for the active library."""
@@ -2127,6 +2288,7 @@ class SettingsManager:
default_checkpoint_root=default_checkpoint_root,
default_unet_root=default_unet_root,
default_embedding_root=default_embedding_root,
default_other_roots=default_other_roots,
recipes_path=recipes_path,
activate=True,
)
@@ -2151,6 +2313,7 @@ class SettingsManager:
"lora_scanner",
"checkpoint_scanner",
"embedding_scanner",
"other_scanner",
"recipe_scanner",
"model_update_service",
):
@@ -2173,10 +2336,14 @@ class SettingsManager:
"""Get download path template for specific model type
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding')
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
Returns:
Template string for the model type, defaults to '{base_model}/{first_tag}'
Template string for the model type. Falls back to the per-type
default in ``DEFAULT_DOWNLOAD_PATH_TEMPLATES``; unknown model types
resolve to an empty string (flat layout) rather than silently
nesting downloads under an unconfigured subfolder.
"""
templates = self.settings.get("download_path_templates", {})
@@ -2200,27 +2367,19 @@ class SettingsManager:
logger.warning(
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
)
default_template = "{base_model}/{first_tag}"
templates = {
"lora": default_template,
"checkpoint": default_template,
"embedding": default_template,
}
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
self.settings["download_path_templates"] = templates
self._save_settings()
# Ensure templates is a dictionary
if not isinstance(templates, dict):
default_template = "{base_model}/{first_tag}"
templates = {
"lora": default_template,
"checkpoint": default_template,
"embedding": default_template,
}
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
self.settings["download_path_templates"] = templates
self._save_settings()
return templates.get(model_type, "{base_model}/{first_tag}")
return templates.get(
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
)
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
+112 -1
View File
@@ -1,4 +1,4 @@
from typing import Any
from typing import Any, Dict, List
NSFW_LEVELS = {
"PG": 1,
@@ -83,6 +83,103 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
# folder_paths key -> sub_type; single source of truth for extensibility.
# Adding support for a new ComfyUI folder category is a one-line change here.
OTHER_MODEL_FOLDER_SUBTYPES = {
"vae": "vae",
"upscale_models": "upscaler",
"text_encoders": "text_encoder",
"clip": "text_encoder", # legacy ComfyUI key
"clip_vision": "clip_vision",
"controlnet": "controlnet",
}
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
# Sub-types managed when the (opt-in) Other Models feature is switched on.
# The feature itself defaults to off (``enable_other_models`` = False), so
# nothing here is scanned until the user enables it.
#
# The default set is deliberately limited to the dependency-style assets every
# pipeline needs and where "which one am I actually using" is the real problem:
# VAE, upscalers and text encoders. ``clip_vision`` and ``controlnet`` are
# workflow-driven instead (IPAdapter/SVD, per-workflow ControlNet variants) and
# ControlNet libraries routinely run to dozens of files, so both stay opt-in
# and are treated symmetrically.
DEFAULT_ENABLED_OTHER_SUB_TYPES: List[str] = [
"vae",
"upscaler",
"text_encoder",
]
def other_sub_type_folder_keys() -> Dict[str, List[str]]:
"""Invert OTHER_MODEL_FOLDER_SUBTYPES into sub_type -> folder_paths keys.
``text_encoder`` maps to two folder keys (``text_encoders`` and the legacy
``clip``), so every consumer that resolves a sub_type back to folders must
merge both.
"""
mapping: Dict[str, List[str]] = {}
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
mapping.setdefault(sub_type, []).append(folder_key)
return mapping
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
def normalize_other_sub_types(value: Any) -> List[str]:
"""Normalize a stored/requested enabled-sub_type list.
Unknown values and duplicates are dropped; the result follows the
canonical VALID_OTHER_SUB_TYPES order so the stored setting and the UI
stay stable. Non-list input falls back to the defaults.
"""
if isinstance(value, str):
candidates: Any = [value]
elif isinstance(value, (list, tuple, set)):
candidates = value
else:
return list(DEFAULT_ENABLED_OTHER_SUB_TYPES)
allowed = {item for item in candidates if isinstance(item, str)}
return [sub_type for sub_type in VALID_OTHER_SUB_TYPES if sub_type in allowed]
# CivitAI model.type values accepted by the "other" page's fetch-metadata
# validation (lowercased). CLIP/CLIPVision are retired upstream but still
# appear on grandfathered models.
VALID_OTHER_CIVITAI_TYPES = {
"vae",
"upscaler",
"textencoder",
"clip",
"clipvision",
"controlnet",
"other",
}
# CivitAI model.type -> internal sub_type for the "other" model page.
CIVITAI_TYPE_TO_OTHER_SUB_TYPE = {
"vae": "vae",
"upscaler": "upscaler",
"textencoder": "text_encoder",
"clip": "text_encoder",
"clipvision": "clip_vision",
"controlnet": "controlnet",
}
# CivitAI ModelFile.type values -> internal sub_type for the "other" model
# page. Used for download routing only, and strictly as an explicit user file
# pick or a fallback when model.type maps to nothing — checkpoint models
# routinely bundle VAE/Text Encoder component files, so file types must never
# override a mapped model.type.
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
"VAE": "vae",
"Upscaler": "upscaler",
"Text Encoder": "text_encoder",
"Vision Encoder": "clip_vision",
"CLIPVision": "clip_vision",
"ControlNet": "controlnet",
}
# Backward compatibility alias
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
@@ -91,6 +188,7 @@ CIVITAI_USER_MODEL_TYPES = [
*VALID_LORA_TYPES,
"textualinversion",
"checkpoint",
*sorted(VALID_OTHER_CIVITAI_TYPES),
]
# Default chunk size in megabytes used for hashing large files.
@@ -159,6 +257,19 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
}
# Default download path template for each model type. "other" defaults to a
# flat layout (empty template) on purpose: other-model downloads are already
# separated by sub_type roots (default_other_roots), and priority_tags has no
# "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and
# scatter files into unstable folders. Users can still opt in to a template by
# writing "other" into download_path_templates in settings.json.
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
"lora": "{base_model}/{first_tag}",
"checkpoint": "{base_model}/{first_tag}",
"embedding": "{base_model}/{first_tag}",
"other": "",
}
# baseModel values from CivitAI that should be treated as diffusion models (unet)
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
DIFFUSION_MODEL_BASE_MODELS = frozenset(
@@ -420,6 +420,10 @@ class DownloadManager:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
if "other" in model_types:
other_scanner = await ServiceRegistry.get_other_scanner()
scanners.append(("other", other_scanner))
# Load progress file to check processed models (async to avoid blocking)
settings_manager = get_settings_manager()
active_library = settings_manager.get_active_library_name()
@@ -600,6 +604,10 @@ class DownloadManager:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
if "other" in model_types:
other_scanner = await ServiceRegistry.get_other_scanner()
scanners.append(("other", other_scanner))
# Get all models
all_models = []
for scanner_type, scanner in scanners:
@@ -1098,6 +1106,10 @@ class DownloadManager:
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
scanners.append(("embedding", embedding_scanner))
if "other" in model_types:
other_scanner = await ServiceRegistry.get_other_scanner()
scanners.append(("other", other_scanner))
# Find the specified models
models_to_process = []
for scanner_type, scanner in scanners:
+56 -7
View File
@@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict, field
from typing import Callable, Dict, Optional, List, Any
from datetime import datetime
import os
from .constants import INVALID_AUTOV3_EMPTY_HASH
from .constants import CIVITAI_TYPE_TO_OTHER_SUB_TYPE, INVALID_AUTOV3_EMPTY_HASH
from .model_utils import determine_base_model
@@ -77,9 +77,6 @@ class BaseModelMetadata:
last_checked_at: float = 0 # Last checked timestamp
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
trainedWords: List[str] = field(
default_factory=list
) # Trigger words / activation prompts (source-agnostic)
_unknown_fields: Dict[str, Any] = field(
default_factory=dict, repr=False, compare=False
) # Store unknown fields
@@ -92,9 +89,6 @@ class BaseModelMetadata:
if self.tags is None:
self.tags = []
if self.trainedWords is None:
self.trainedWords = []
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
"""Create instance from dictionary"""
@@ -324,6 +318,61 @@ class CheckpointMetadata(BaseModelMetadata):
)
@dataclass
class OtherModelMetadata(BaseModelMetadata):
"""Represents the metadata structure for an "other" model (VAE, upscaler,
text encoder, CLIP vision, ControlNet, ...).
The sub_type is location-derived: the OtherScanner sets it from the
folder_paths category whose root contains the file. The dataclass default
is only a placeholder.
"""
sub_type: str = "vae" # Placeholder; overridden by the scanner hooks
@classmethod
def from_civitai_info(
cls, version_info: Dict[str, Any], file_info: Dict[str, Any], save_path: str
) -> "OtherModelMetadata":
"""Create OtherModelMetadata instance from Civitai version info"""
file_name = file_info.get("name", "")
base_model = determine_base_model(version_info.get("baseModel", ""))
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
# Map the CivitAI model type onto our sub_types; unknown types keep the
# placeholder until the scanner re-derives sub_type from the location.
# The type lives at version["model"]["type"], not version["type"].
civitai_type = str((version_info.get("model") or {}).get("type", "") or "").lower()
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(civitai_type, "vae")
# Extract tags and description if available
tags = []
description = ""
model_data = version_info.get("model") or {}
if "tags" in model_data:
tags = model_data["tags"]
if "description" in model_data:
description = model_data["description"]
return cls(
file_name=os.path.splitext(file_name)[0],
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
file_path=save_path.replace(os.sep, "/"),
size=file_info.get("sizeKB", 0) * 1024,
modified=datetime.now().timestamp(),
sha256=sha256_value,
base_model=base_model,
preview_url="", # Will be updated after preview download
preview_nsfw_level=0,
from_civitai=True,
civitai=version_info,
sub_type=sub_type,
tags=tags,
modelDescription=description,
# Direct read: the downloaded file IS file_info, no SHA256 matching.
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
)
@dataclass
class EmbeddingMetadata(BaseModelMetadata):
"""Represents the metadata structure for an Embedding model"""
+1 -2
View File
@@ -18,6 +18,5 @@
"C:/path/to/your/embeddings_folder",
"C:/path/to/another/embeddings_folder"
]
},
"auto_organize_exclusions": []
}
}
+54 -29
View File
@@ -12,7 +12,7 @@
}
.header-container {
max-width: 1400px;
max-width: none;
margin: 0 auto;
padding: 0 15px;
display: flex;
@@ -38,19 +38,6 @@
flex-shrink: 0;
}
/* Responsive header container for larger screens */
@media (min-width: 2150px) {
.header-container {
max-width: 1800px;
}
}
@media (min-width: 3000px) {
.header-container {
max-width: 2400px;
}
}
/* Logo and title styling */
.header-branding {
display: flex;
@@ -96,6 +83,12 @@
white-space: nowrap;
}
/* Opt-in pages (e.g. Other Models) hide their nav entry until enabled.
A class is used instead of [hidden] because .nav-item sets display: flex. */
.nav-item--hidden {
display: none;
}
.nav-item:hover,
.nav-item:focus-visible {
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
@@ -120,6 +113,9 @@
display: flex;
justify-content: center;
max-width: 600px;
/* No hard floor: the field shrinks with the available space instead of parking
at a fixed width and crowding its own placeholder (see the 1366px query). */
min-width: 0;
margin: 0 auto;
transition: opacity 0.2s ease;
}
@@ -128,6 +124,7 @@
.header-search .search-container {
width: 100%;
max-width: 600px;
min-width: 0;
position: relative;
display: flex;
align-items: center;
@@ -149,7 +146,12 @@
width: 100%;
padding: 0.5rem 0.75rem;
padding-left: 2.25rem !important;
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
/* Reserve exactly the inline chrome so typed text never runs under it:
cue(58) + clear(28) + toggles(28 + 28 + 4 gap) + edges(8 + 8) = 126px.
Below 1366px the cue is hidden and the reservation drops to 68px.
!important is required: search-filter.css loads later and sets its own
right padding at equal specificity (.search-container input). */
padding-right: 7.875rem !important;
border: none;
background: transparent;
color: var(--text-color);
@@ -697,6 +699,20 @@
margin: 0.25rem 0;
}
/* Responsive: the Ctrl+F cue is pure decoration and, above 950px, the widest
thing inside the field. Below 1366px the header (branding + full nav) leaves
too little room for it, so it steps aside and the field reclaims its 58px.
The shortcut itself keeps working - only the visual hint is dropped. */
@media (max-width: 1366px) {
.header-search .search-shortcut-cue {
display: none;
}
.header-search input {
padding-right: 4.25rem !important;
}
}
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
@media (max-width: 1200px) {
.header-container {
@@ -716,11 +732,6 @@
.header-controls {
gap: 6px;
}
.header-controls > div {
width: 30px;
height: 30px;
}
}
/* Responsive: Hide nav icons at 1100px to save space */
@@ -797,13 +808,12 @@
}
}
/* For very small screens - switch nav to icons only */
@media (max-width: 600px) {
.header-container {
padding: 0 8px;
gap: 0.4rem;
}
/* For narrower screens - switch nav to icons only.
A labelled nav needs ~383px and a readable search field needs ~300px, so the
two cannot coexist below ~700px: at 601-700px the search input was previously
squeezed to 200px, leaving only ~96px of text room and overlapping the
placeholder with the inline toggles. Labels therefore collapse here. */
@media (max-width: 700px) {
.main-nav {
display: flex;
gap: 0.15rem;
@@ -811,8 +821,7 @@
}
.nav-item {
padding: 0.25rem;
font-size: 0.75rem;
padding: 0.25rem 0.4rem;
}
.nav-item span {
@@ -821,6 +830,22 @@
.nav-item i {
display: block;
}
}
/* For very small screens - tighten container spacing */
@media (max-width: 600px) {
.header-container {
padding: 0 8px;
gap: 0.4rem;
}
.nav-item {
padding: 0.25rem;
font-size: 0.75rem;
}
.nav-item i {
font-size: 1rem;
}
}
+142
View File
@@ -592,3 +592,145 @@ button:disabled,
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Options Modal */
#rematchOptionsModal .modal-body {
padding: var(--space-3);
}
#rematchOptionsModal .confirmation-message {
color: var(--text-color);
margin-bottom: var(--space-3);
font-size: 1em;
line-height: 1.5;
}
/* Selectable option card click anywhere toggles the checkbox (label wrap).
Checkmark follows the batch-import modal's custom checkbox pattern. */
#rematchOptionsModal .rematch-option-card {
position: relative;
display: flex;
align-items: flex-start;
gap: var(--space-2);
padding: var(--space-3);
background: var(--surface-subtle);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
cursor: pointer;
user-select: none;
transition: var(--transition-base);
}
#rematchOptionsModal .rematch-option-card:hover {
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
border-color: var(--lora-accent);
background: oklch(from var(--lora-accent) l c h / 0.08);
}
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
#rematchOptionsModal .rematch-option-checkmark {
width: 18px;
height: 18px;
margin-top: 1px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: var(--transition-base);
background: var(--bg-color);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free', sans-serif;
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
#rematchOptionsModal .rematch-option-text {
display: flex;
flex-direction: column;
gap: var(--space-1);
color: var(--text-color);
min-width: 0;
}
#rematchOptionsModal .rematch-option-title {
font-weight: 600;
font-size: 0.95em;
}
#rematchOptionsModal .rematch-option-caveat {
display: flex;
align-items: flex-start;
gap: var(--space-2);
font-size: 0.85em;
line-height: 1.4;
color: var(--text-muted);
}
#rematchOptionsModal .rematch-option-caveat i {
color: var(--lora-accent);
margin-top: 2px;
flex-shrink: 0;
}
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
stat cards / failure table / summary header come from
metadata-refresh-result.css and download-batch-summary.css). */
.rematch-summary-modal {
max-width: 700px;
}
.rematch-cancelled-note {
display: flex;
align-items: flex-start;
gap: var(--space-2);
margin: 0 0 var(--space-2) 0;
font-size: var(--text-sm);
color: var(--color-warning);
}
.rematch-cancelled-note i {
margin-top: 2px;
flex-shrink: 0;
}
/* Review section heading uses the accent (review, not failure) instead of
the failure-section error color. */
.rematch-review-section h4 {
color: var(--lora-accent);
}
#rematchSummaryModal .rematch-undo-btn {
padding: var(--space-1) var(--space-2);
font-size: var(--text-xs);
white-space: nowrap;
}
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
text-decoration: line-through;
opacity: 0.6;
}
@@ -1744,3 +1744,39 @@ input:checked + .toggle-slider:before {
font-style: italic;
user-select: none;
}
/* Other Models opt-in: sub_type checkbox row */
.other-subtype-checkboxes {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px 14px;
}
.other-subtype-checkbox {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.9em;
color: var(--text-color);
cursor: pointer;
white-space: nowrap;
}
.other-subtype-checkbox input[type="checkbox"] {
cursor: pointer;
}
.other-subtype-toggles.is-disabled {
opacity: 0.5;
}
.other-subtype-toggles.is-disabled .other-subtype-checkbox {
cursor: default;
}
/* Disabled default-root selects for switched-off sub_types / feature */
.select-control select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
+35 -29
View File
@@ -59,7 +59,10 @@ body.sticky-controls .sticky-topbar {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto; /* Push to the right */
/* Push to the right of the row. Because it is also the flex item that is
allowed to drop to a second row, an auto margin keeps it right-aligned on
either row no width: 100% / viewport breakpoint needed. */
margin-left: auto;
}
.actions {
@@ -67,7 +70,11 @@ body.sticky-controls .sticky-topbar {
align-items: center;
justify-content: space-between;
gap: var(--space-2);
flex-wrap: nowrap;
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
viewport width. Viewport-based wrapping wasted space on high-DPI displays
(e.g. a 2560px monitor at 200% scaling reports a ~1280px CSS viewport even
when the window is maximized). */
flex-wrap: wrap;
width: 100%;
}
@@ -75,7 +82,11 @@ body.sticky-controls .sticky-topbar {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: nowrap;
/* Let the group shrink rather than overflow so .controls-right only wraps
when it really has to. */
flex-wrap: wrap;
flex-shrink: 1;
min-width: 0;
}
/* Action button styling */
@@ -84,7 +95,9 @@ body.sticky-controls .sticky-topbar {
}
.control-group button {
min-width: 100px;
/* Keeps the toolbar visually even without forcing the row to overflow (the
old 100px floor pushed the total past the container on wide screens). */
min-width: 90px;
display: flex;
align-items: center;
justify-content: center;
@@ -627,49 +640,42 @@ body.sticky-controls .sticky-topbar {
text-align: center;
}
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
one row at common laptop/high-DPI widths. The buttons are allowed to shrink to
their content (min-width: 0) here, which is what reclaims the space the old
100px floor plus a forced wrap used to waste. .controls-right is deliberately
NOT forced onto its own row: it stays inline while it fits and only drops to a
second row (staying right-aligned through its auto margin) when it does not. */
@media (max-width: 1500px) {
.actions {
flex-wrap: wrap;
gap: var(--space-2);
}
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
}
.controls-right {
width: 100%;
justify-content: flex-end;
margin-top: 8px;
padding-left: 0;
.control-group button {
min-width: 0;
padding: 4px 8px;
}
/* Reduce button sizes to fit better */
.control-group button {
min-width: 80px;
padding: 4px 8px;
font-size: 0.8em;
.control-group select {
min-width: 0;
}
}
@media (max-width: 768px) {
.actions {
flex-wrap: wrap;
gap: var(--space-1);
gap: var(--space-2);
}
.action-buttons {
flex-wrap: wrap;
gap: var(--space-1);
width: 100%;
}
/* Narrow screens: let the right-hand group wrap below the buttons, still
right-aligned. */
.controls-right {
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
margin-top: 8px;
gap: var(--space-1);
}
.control-group button:hover {
+7
View File
@@ -44,6 +44,13 @@
pointer-events: auto !important;
}
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
.onboarding-target-highlight's position: relative would pull it into
normal flow and it would move away from the spotlight cutout */
.folder-sidebar.onboarding-target-highlight {
position: fixed;
}
.onboarding-popup {
position: absolute;
background: var(--lora-surface);
+16 -1
View File
@@ -9,7 +9,8 @@ import { state } from '../state/index.js';
export const MODEL_TYPES = {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings' // Future model type
EMBEDDING: 'embeddings',
OTHER: 'other'
};
// Base API configuration for each model type
@@ -40,6 +41,15 @@ export const MODEL_CONFIG = {
supportsBulkOperations: true,
supportsMove: true,
templateName: 'embeddings.html'
},
[MODEL_TYPES.OTHER]: {
displayName: 'Other Model',
singularName: 'other',
defaultPageSize: 100,
supportsLetterFilter: false,
supportsBulkOperations: true,
supportsMove: true,
templateName: 'other.html'
}
};
@@ -133,6 +143,10 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
},
[MODEL_TYPES.EMBEDDING]: {
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
},
[MODEL_TYPES.OTHER]: {
metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`,
roots_by_subtype: `/api/lm/${MODEL_TYPES.OTHER}/roots_by_subtype`,
}
};
@@ -184,6 +198,7 @@ export const DOWNLOAD_ENDPOINTS = {
downloadGet: '/api/lm/download-model-get',
cancelGet: '/api/lm/cancel-download-get',
progress: '/api/lm/download-progress',
routing: '/api/lm/download/routing',
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
};
+3
View File
@@ -1,6 +1,7 @@
import { LoraApiClient } from './loraApi.js';
import { CheckpointApiClient } from './checkpointApi.js';
import { EmbeddingApiClient } from './embeddingApi.js';
import { OtherApiClient } from './otherApi.js';
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
import { state } from '../state/index.js';
@@ -12,6 +13,8 @@ export function createModelApiClient(modelType) {
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
case MODEL_TYPES.EMBEDDING:
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
case MODEL_TYPES.OTHER:
return new OtherApiClient(MODEL_TYPES.OTHER);
default:
throw new Error(`Unsupported model type: ${modelType}`);
}
+45
View File
@@ -0,0 +1,45 @@
import { BaseModelApiClient } from './baseModelApi.js';
/**
* Other-models-specific API client (VAE, upscalers, text encoders, etc.)
*/
export class OtherApiClient extends BaseModelApiClient {
/**
* Get other-model roots, optionally narrowed to one sub_type
* (vae/upscaler/text_encoder/clip_vision/controlnet).
*
* Without a sub_type this falls back to the merged roots list
* (GET /api/lm/other/roots); with one it reads the grouped
* roots_by_subtype map and extracts the matching list.
*/
async fetchModelRoots(subType = null) {
if (!subType) {
return super.fetchModelRoots();
}
const data = await this.fetchRootsBySubType();
const groupedRoots = data.roots_by_subtype || {};
return {
success: data.success !== false,
roots: groupedRoots[subType] || [],
};
}
/**
* Get other-model roots grouped by sub_type.
* GET /api/lm/other/roots_by_subtype
* -> { success, roots_by_subtype: {sub_type: [...]} }
*/
async fetchRootsBySubType() {
try {
const response = await fetch(this.apiConfig.endpoints.specific.roots_by_subtype);
if (!response.ok) {
throw new Error('Failed to fetch other-model roots by sub_type');
}
return await response.json();
} catch (error) {
console.error('Error fetching other-model roots by sub_type:', error);
throw error;
}
}
}
+9 -4
View File
@@ -677,7 +677,7 @@ export class RecipeSidebarApiClient {
};
}
async rematchBulkModels(filePaths) {
async rematchBulkModels(filePaths, options = {}) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
@@ -690,14 +690,19 @@ export class RecipeSidebarApiClient {
throw new Error('No recipe IDs could be derived from file paths');
}
const body = { recipe_ids: recipeIds };
// Only sent when opted in — the strict body stays exactly
// {recipe_ids} for backward compatibility.
if (options.relaxed === true) {
body.relaxed = true;
}
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
body: JSON.stringify(body),
});
const result = await response.json();
@@ -139,8 +139,8 @@ export class BulkContextMenu extends BaseContextMenu {
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
if (downloadExampleImagesSubmenu) {
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
// Show on model pages (loras, checkpoints, embeddings, other), hide on recipes
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none';
}
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
@@ -25,6 +25,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
this.updateEnrichMenuItem(card);
// Update the "Move to other root" label based on current model type
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
@@ -4,6 +4,8 @@ import { translate } from '../../utils/i18nHelpers.js';
import { state } from '../../state/index.js';
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
export class GlobalContextMenu extends BaseContextMenu {
constructor() {
@@ -368,6 +370,18 @@ export class GlobalContextMenu extends BaseContextMenu {
return;
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
});
}
async _startRematch(menuItem, relaxed = false) {
if (this._rematchInProgress) {
return;
}
this._rematchInProgress = true;
menuItem?.classList.add('disabled');
@@ -384,6 +398,7 @@ export class GlobalContextMenu extends BaseContextMenu {
const response = await fetch('/api/lm/recipes/rematch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
@@ -411,48 +426,32 @@ export class GlobalContextMenu extends BaseContextMenu {
const recipes = p.matched_recipes ?? p.rematched ?? 0;
const failures = p.errors || 0;
const unresolved = p.unresolved_entries ?? 0;
if (entries > 0) {
const successKey = failures > 0
? 'globalContextMenu.rematchRecipes.successErrors'
: 'globalContextMenu.rematchRecipes.success';
const successText = failures > 0
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
: `Matched ${entries} entries across ${recipes} recipes.`;
progressUI?.complete(translate(
successKey,
{ count: recipes, recipes, entries, failures },
successText
));
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
} else if (failures > 0) {
// Nothing matched and at least one recipe
// errored — "no rematch needed" would be
// actively misleading here.
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.allFailed',
{ total: p.total, recipes, entries, failures },
`Rematch failed for ${failures} of ${p.total} recipes.`
));
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
} else if (unresolved > 0) {
// Entries existed but have no local model —
// expected for models deleted from Civitai;
// informational, not an error.
const unresolvedRecipes = p.unresolved_recipes ?? 0;
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.noMatch',
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
} else {
// Everything was skipped (nothing to do).
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
// Complete no-op (nothing matched, nothing
// unresolved, no errors) keeps the lightweight
// toast; anything else opens the post-run summary
// modal.
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
if (isNoop) {
progressUI?.complete(translate(
'globalContextMenu.rematchRecipes.success',
{ count: recipes, recipes, entries, failures },
`Matched ${entries} entries across ${recipes} recipes.`
));
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
} else {
progressUI?.complete();
showRematchSummary({
scope: 'global',
total: p.total || 0,
matchedRecipes: recipes,
matchedEntries: entries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: unresolved,
skipped: p.skipped || 0,
errors: failures,
l4Matches,
});
}
// Refresh recipes page if active
if (window.recipesPage) {
@@ -469,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
));
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
// A cancelled run still reports partial results
// via the summary modal (marked as cancelled).
showRematchSummary({
scope: 'global',
cancelled: true,
total: p.total || 0,
matchedRecipes: cancelledRecipes,
matchedEntries: cancelledEntries,
unresolvedRecipes: p.unresolved_recipes ?? 0,
unresolvedEntries: p.unresolved_entries ?? 0,
skipped: p.skipped || 0,
errors: p.errors || 0,
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
});
if (window.recipesPage) {
window.recipesPage.refresh();
}
}
} else if (progressResponse.status === 404) {
// Progress might have finished quickly and been cleaned up
@@ -1,8 +1,7 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { state } from '../../state/index.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
import { moveManager } from '../../managers/MoveManager.js';
@@ -27,16 +26,6 @@ export class LoraContextMenu extends BaseContextMenu {
this.updateEnrichMenuItem(card);
}
updateEnrichMenuItem(card) {
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
}
handleMenuAction(action, menuItem) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
@@ -75,9 +64,6 @@ export class LoraContextMenu extends BaseContextMenu {
case 'refresh-metadata':
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'enrich-hf-llm':
this.enrichWithAgent(this.currentCard.dataset.filepath);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
@@ -87,68 +73,6 @@ export class LoraContextMenu extends BaseContextMenu {
}
}
async enrichWithAgent(filePath) {
const { agentManager } = await import('../../managers/AgentManager.js');
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
return;
}
agentManager.connect();
const progressUI = state.loadingManager.showEnhancedProgress(
'Enriching metadata with AI...'
);
function cleanupCallbacks() {
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
const eIdx = agentManager.errorCallbacks.indexOf(onError);
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
}
const onProgress = (data) => {
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
if (state.virtualScroller?.updateSingleItem) {
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
}
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
const name = data.current_path.split('/').pop();
progressUI.updateProgress(pct, name, `Processing ${name}`);
}
};
agentManager.onProgress(onProgress);
const onComplete = (data) => {
cleanupCallbacks();
if (data.status === 'completed') {
progressUI.complete(data.summary || 'Enrich complete');
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
}
};
agentManager.onComplete(onComplete);
const onError = (data) => {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
};
agentManager.onError(onError);
try {
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
} catch (error) {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
}
}
sendLoraToWorkflow(replaceMode) {
const card = this.currentCard;
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
@@ -112,7 +112,8 @@ export const ModelContextMenuMixin = {
const prefixMap = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings'
embedding: 'embeddings',
other: 'other'
};
return prefixMap[this.modelType] || 'loras';
},
@@ -278,6 +279,79 @@ export const ModelContextMenuMixin = {
setTimeout(() => urlInput.focus(), 50);
},
// HF metadata enrichment (AI agent) methods
updateEnrichMenuItem(card) {
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
if (!enrichItem) return;
const hasHfUrl = !!card.dataset.hf_url;
enrichItem.classList.toggle('disabled', !hasHfUrl);
enrichItem.title = hasHfUrl
? ''
: 'Link this model to a HuggingFace repo first (Link Model → Link to HuggingFace)';
},
async enrichWithAgent(filePath) {
const { agentManager } = await import('../../managers/AgentManager.js');
const configured = await agentManager.isLlmConfigured();
if (!configured) {
showToast('toast.agent.llmNotConfigured', {}, 'warning');
return;
}
agentManager.connect();
const progressUI = state.loadingManager.showEnhancedProgress(
'Enriching metadata with AI...'
);
function cleanupCallbacks() {
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
const eIdx = agentManager.errorCallbacks.indexOf(onError);
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
}
const onProgress = (data) => {
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
if (state.virtualScroller?.updateSingleItem) {
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
}
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
const name = data.current_path.split('/').pop();
progressUI.updateProgress(pct, name, `Processing ${name}`);
}
};
agentManager.onProgress(onProgress);
const onComplete = (data) => {
cleanupCallbacks();
if (data.status === 'completed') {
progressUI.complete(data.summary || 'Enrich complete');
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
}
};
agentManager.onComplete(onComplete);
const onError = (data) => {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
};
agentManager.onError(onError);
try {
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
} catch (error) {
cleanupCallbacks();
state.loadingManager.hide();
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
}
},
parseModelId(value) {
if (value === undefined || value === null || value === '') {
return null;
@@ -372,7 +446,10 @@ export const ModelContextMenuMixin = {
this.downloadExampleImages(true);
return true;
case 'civitai':
if (this.currentCard.dataset.from_civitai === 'true') {
// Gate on actual CivitAI data (not the `from_civitai` flag) so
// that linking HuggingFace does not make the model look like it
// has no CivitAI info (#1094).
if (this.currentCard.dataset.has_civitai === 'true') {
if (this.currentCard.querySelector('.fa-globe')) {
this.currentCard.querySelector('.fa-globe').click();
} else {
@@ -388,6 +465,9 @@ export const ModelContextMenuMixin = {
case 'link-hf':
this.showLinkHfModal();
return true;
case 'enrich-hf-llm':
this.enrichWithAgent(this.currentCard.dataset.filepath);
return true;
case 'set-nsfw':
this.showNSFWLevelSelector(null, null, this.currentCard);
return true;
@@ -0,0 +1,72 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { moveManager } from '../../managers/MoveManager.js';
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
export class OtherContextMenu extends BaseContextMenu {
constructor() {
super('otherContextMenu', '.model-card');
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
this.modelType = 'other';
this.resetAndReload = resetAndReload;
this.initNSFWSelector();
}
// Implementation needed by the mixin
async saveModelMetadata(filePath, data) {
return getModelApiClient().saveModelMetadata(filePath, data);
}
showMenu(x, y, card) {
super.showMenu(x, y, card);
this.updateExcludeMenuItem();
}
handleMenuAction(action) {
// First try to handle with common actions
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
return;
}
const apiClient = getModelApiClient();
// Otherwise handle other-models-specific actions
switch(action) {
case 'details':
// Show model details
this.currentCard.click();
break;
case 'replace-preview':
// Add new action for replacing preview images
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
break;
case 'delete':
showDeleteModal(this.currentCard.dataset.filepath);
break;
case 'copyname':
// Copy model name
if (this.currentCard.querySelector('.fa-copy')) {
this.currentCard.querySelector('.fa-copy').click();
}
break;
case 'refresh-metadata':
// Refresh metadata from CivitAI
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'exclude':
showExcludeModal(this.currentCard.dataset.filepath);
break;
case 'restore':
this.restoreExcludedModel(this.currentCard.dataset.filepath);
break;
}
}
}
// Mix in shared methods
Object.assign(OtherContextMenu.prototype, ModelContextMenuMixin);
@@ -6,6 +6,8 @@ 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 { rematchModalManager } from '../../managers/RematchModalManager.js';
import { showRematchSummary } from '../RematchSummaryModal.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
export class RecipeContextMenu extends BaseContextMenu {
@@ -303,26 +305,36 @@ export class RecipeContextMenu extends BaseContextMenu {
// Capture before any await: the menu's click handler nulls currentCard
const filePath = this.currentCard?.dataset?.filepath;
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
scope: 'single',
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
});
}
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
try {
showToast('Rematching recipe to local models...', {}, 'info');
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
method: 'POST'
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ relaxed: !!relaxed }),
});
const result = await response.json();
if (result.success) {
const matchedEntries = result.matched_entries || result.rematched || 0;
const failures = result.errors || 0;
const unresolvedEntries = result.unresolved_entries || 0;
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (matchedEntries > 0) {
const toastKey = failures > 0
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
failures > 0 ? 'warning' : 'success'
);
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
if (detailResponse.ok) {
const updatedRecipe = await detailResponse.json();
@@ -330,16 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
}
}
} else if (result.unresolved_entries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
'info'
);
} else {
}
if (isNoop) {
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
} else {
showRematchSummary({
scope: 'single',
total: 1,
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
matchedEntries,
unresolvedRecipes: result.unresolved_recipes || 0,
unresolvedEntries,
skipped: result.skipped || 0,
errors: failures,
l4Matches,
});
}
} else {
throw new Error(result.error || 'Rematch failed');
@@ -2,6 +2,7 @@ export { LoraContextMenu } from './LoraContextMenu.js';
export { RecipeContextMenu } from './RecipeContextMenu.js';
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
export { OtherContextMenu } from './OtherContextMenu.js';
export { GlobalContextMenu } from './GlobalContextMenu.js';
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
@@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js';
import { RecipeContextMenu } from './RecipeContextMenu.js';
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
import { OtherContextMenu } from './OtherContextMenu.js';
import { GlobalContextMenu } from './GlobalContextMenu.js';
// Factory method to create page-specific context menu instances
@@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) {
return new CheckpointContextMenu();
case 'embeddings':
return new EmbeddingContextMenu();
case 'other':
return new OtherContextMenu();
default:
return null;
}
+13
View File
@@ -32,6 +32,7 @@ export class HeaderManager {
if (path.includes('/loras/recipes')) return 'recipes';
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/other')) return 'other';
if (path.includes('/statistics')) return 'statistics';
if (path.includes('/loras')) return 'loras';
return 'unknown';
@@ -49,6 +50,18 @@ export class HeaderManager {
initializeCommonElements() {
this.initializeThemePopover();
// Header icon buttons are divs with role="button"; make Enter/Space activate them
const headerControls = document.getElementById('headerControls');
if (headerControls) {
headerControls.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target.closest('[role="button"]');
if (!target || !headerControls.contains(target)) return;
e.preventDefault();
target.click();
});
}
const settingsToggle = document.querySelector('.settings-toggle');
if (settingsToggle) {
settingsToggle.addEventListener('click', () => {
+20 -21
View File
@@ -1,5 +1,5 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -1078,8 +1078,9 @@ class RecipeModal {
// Mirror the checkpoint "broken" rule: deleted, an
// unresolvable hash, or a name-only remnant with no CivitAI
// identifiers at all cannot be fixed by downloading
// reconnecting a local LoRA is the only remediation.
// identifiers at all cannot be fixed by downloading, so no
// download button is offered. Reconnect is always available
// for missing entries (see renderLoraItemActions).
const needsReconnect = !existsLocally
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
@@ -1180,7 +1181,7 @@ class RecipeModal {
</div>
${actionsRow}
</div>
${needsReconnect ? `
${!existsLocally ? `
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
<div class="reconnect-instructions">
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
@@ -2853,11 +2854,7 @@ class RecipeModal {
* the model cannot be resolved never for transient transport errors.
*/
_isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
return isUnresolvableDownloadError(message);
}
getResourceCivitaiUrl(resource) {
@@ -2915,19 +2912,9 @@ class RecipeModal {
}
const controls = [];
if (needsReconnect) {
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
} else {
if (!needsReconnect) {
// needsReconnect already implies canDownloadLora() here, so the
// download action is unconditional.
// download action is unconditional in this branch.
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
controls.push(`
@@ -2938,6 +2925,18 @@ class RecipeModal {
</button>
`);
}
// Reconnect is always offered for missing entries — when the LoRA
// already exists locally under a different hash, downloading first
// just to flip the button would be a waste.
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
controls.push(`
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
<i class="fas fa-link" aria-hidden="true"></i>
<span>${escapeHtml(reconnectLabel)}</span>
</button>
`);
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
}
+338
View File
@@ -0,0 +1,338 @@
import { translate } from '../utils/i18nHelpers.js';
import { showToast } from '../utils/uiHelpers.js';
/**
* Escape HTML entities in a string to prevent injection when interpolating
* into innerHTML (same approach as DownloadBatchSummaryModal).
* @param {string} str - The string to escape
* @returns {string} - The escaped string
*/
function _escapeHtml(str) {
if (str === null || str === undefined) return '';
const div = document.createElement('div');
div.textContent = String(str);
return div.innerHTML.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
/**
* Resolve the 3-state summary header (mirrors the batch download/import
* summary semantics).
*
* - error: nothing matched and at least one recipe errored
* - warning: errors, unresolved entries, filename-level (L4) matches to
* review, or a cancelled run
* - success: otherwise
*/
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
if (matchedEntries === 0 && errors > 0) {
return {
state: 'error',
icon: 'fa-times-circle',
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
};
}
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
return {
state: 'warning',
icon: 'fa-exclamation-circle',
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
};
}
return {
state: 'success',
icon: 'fa-check-circle',
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
};
}
/**
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
* L4 rows undone so far, so the report reflects the undo status at copy time.
*/
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
const scopeFallbacks = {
global: 'All recipes',
bulk: 'Selected recipes',
single: 'Single recipe',
};
const scopeLabel = translate(
`modals.rematchSummary.scope_${scope}`,
{},
scopeFallbacks[scope] || scope
);
const lines = [
'=== Recipe Rematch Report ===',
`Date: ${new Date().toLocaleString()}`,
`Scope: ${scopeLabel}`,
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
`Total recipes: ${total}`,
`Matched recipes: ${matchedRecipes}`,
`Matched entries: ${matchedEntries}`,
`Needs review (filename matches): ${l4Matches.length}`,
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
`Skipped: ${skipped}`,
`Errors: ${errors}`,
'',
];
if (l4Matches.length > 0) {
lines.push('--- Filename matches (L4) ---');
l4Matches.forEach((match, i) => {
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
});
lines.push('');
}
lines.push('====================');
return lines.join('\n');
}
/**
* Handle a successful clipboard write: confirm via toast and briefly swap the
* trigger button to a "Copied!" state (mirrors the batch summary modal).
*/
function _onCopyReportSuccess(btn) {
showToast('toast.api.copiedToClipboard', {}, 'success');
if (btn) {
const origHTML = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
}
}
/**
* Fallback for environments without the async Clipboard API (e.g. insecure
* contexts over LAN http): copy via a hidden textarea and execCommand.
*/
function _copyReportWithExecCommand(text) {
const textarea = document.createElement('textarea');
textarea.value = text;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast('toast.api.copiedToClipboard', {}, 'success');
}
function _copyReport(btn, reportArgs) {
const text = _buildReportText(reportArgs);
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
navigator.clipboard.writeText(text)
.then(() => _onCopyReportSuccess(btn))
.catch(() => _copyReportWithExecCommand(text));
} else {
_copyReportWithExecCommand(text);
}
}
/**
* Undo a single L4 match via the existing restore endpoints (moved from
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
* restore additionally takes lora_index.
*/
async function _undoMatch(match) {
const isCheckpoint = match.type === 'checkpoint';
const body = isCheckpoint
? { recipe_id: match.recipe_id }
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
const response = await fetch(
isCheckpoint
? '/api/lm/recipe/checkpoint/restore'
: '/api/lm/recipe/lora/restore',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Restore failed');
}
}
/**
* Show the post-run rematch summary modal. Mirrors the batch download
* summary lifecycle: the modal element is appended directly to
* document.body and removed on close; it is not registered with
* ModalManager.
*
* @param {Object} options
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
* @param {boolean} options.cancelled - Whether the run was cancelled
* @param {number} options.total - Recipes scanned
* @param {number} options.matchedRecipes - Recipes updated
* @param {number} options.matchedEntries - Entries reconnected
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
* @param {number} options.unresolvedEntries - Candidate entries with no local match
* @param {number} options.skipped - Recipes left untouched
* @param {number} options.errors - Per-recipe errors
* @param {Array} options.l4Matches - Filename-level matches for review/undo
* ({ recipe_id, type, entry, file_name, lora_index? })
*/
export function showRematchSummary({
scope = 'global',
cancelled = false,
total = 0,
matchedRecipes = 0,
matchedEntries = 0,
unresolvedRecipes = 0,
unresolvedEntries = 0,
skipped = 0,
errors = 0,
l4Matches = [],
} = {}) {
const matches = Array.isArray(l4Matches) ? l4Matches : [];
const undoneIndexes = new Set();
const header = _resolveHeader({
matchedEntries,
errors,
unresolvedEntries,
l4Count: matches.length,
cancelled,
});
const matchRows = matches.map((match, i) => `
<tr data-l4-index="${i}">
<td class="failure-index">${i + 1}</td>
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
<td class="rematch-undo-cell">
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
${translate('modals.rematchResults.undo', {}, 'Undo')}
</button>
</td>
</tr>`).join('');
const modalHtml = `
<div id="rematchSummaryModal" class="modal" style="display: block;">
<div class="modal-content rematch-summary-modal">
<button class="close" data-action="close-modal">&times;</button>
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
<div class="summary-header ${header.state}">
<i class="fas ${header.icon}"></i>
<span class="summary-title">${header.text}</span>
<span class="summary-hint">${matchedRecipes}/${total}</span>
</div>
${cancelled ? `
<p class="rematch-cancelled-note">
<i class="fas fa-info-circle"></i>
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
</p>` : ''}
<div class="refresh-summary-stats">
<div class="stat-card stat-card-success">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
<span class="stat-card-value">${matchedEntries}</span>
</div>
</div>
<div class="stat-card stat-card-skipped">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
<span class="stat-card-value">${matches.length}</span>
</div>
</div>
<div class="stat-card stat-card-total">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
<span class="stat-card-value">${unresolvedEntries}</span>
</div>
</div>
<div class="stat-card stat-card-failure">
<div class="stat-card-body">
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
<span class="stat-card-value">${errors}</span>
</div>
</div>
</div>
${matches.length > 0 ? `
<div class="refresh-failures-section rematch-review-section">
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
<div class="failure-table-wrapper">
<table class="failure-table">
<thead>
<tr>
<th>#</th>
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
</tr>
</thead>
<tbody>${matchRows}</tbody>
</table>
</div>
</div>
` : ''}
<div class="modal-actions">
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
</div>
</div>
</div>
`;
const existing = document.getElementById('rematchSummaryModal');
if (existing) existing.remove();
const container = document.createElement('div');
container.innerHTML = modalHtml;
const modal = container.firstElementChild;
document.body.appendChild(modal);
const reportArgs = {
scope,
cancelled,
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors,
l4Matches: matches,
undoneIndexes,
};
modal.addEventListener('click', async (e) => {
const actionEl = e.target.closest('[data-action]');
const action = actionEl?.dataset.action;
if (!action) return;
e.preventDefault();
switch (action) {
case 'close-modal':
modal.remove();
break;
case 'copy-report':
_copyReport(actionEl, reportArgs);
break;
case 'undo-match': {
const index = Number(actionEl.dataset.index);
const match = matches[index];
if (!match || actionEl.disabled) break;
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
try {
await _undoMatch(match);
undoneIndexes.add(index);
row?.classList.add('undone');
actionEl.disabled = true;
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
} catch (error) {
console.error('Failed to undo rematch match:', error);
showToast(
'modals.rematchResults.undoFailed',
{ message: error.message },
'error'
);
}
break;
}
}
});
}
+13 -2
View File
@@ -11,6 +11,13 @@ import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
// Pages whose folder sidebar starts hidden. "other" downloads default to a flat
// layout (no subfolders are created), so on a fresh library the tree is empty
// there and the sidebar would only consume horizontal space. The preference is
// still persisted per page once the user toggles it, and the edge indicator
// makes the hidden sidebar discoverable/recoverable.
const SIDEBAR_DEFAULT_HIDDEN_PAGES = new Set(['other']);
export class SidebarManager {
constructor() {
this.pageControls = null;
@@ -1126,6 +1133,7 @@ export class SidebarManager {
recipes: 'Recipes',
checkpoints: 'Checkpoints',
embeddings: 'Embeddings',
other: 'Other Models',
};
return names[this.pageType] || this.pageType;
}
@@ -1784,7 +1792,10 @@ export class SidebarManager {
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
this.isDisabledByPage = getStorageItem(
`${this.pageType}_sidebarDisabled`,
SIDEBAR_DEFAULT_HIDDEN_PAGES.has(this.pageType)
);
this.expandedNodes = new Set(expandedPaths);
this.displayMode = displayMode;
@@ -1804,7 +1815,7 @@ export class SidebarManager {
_migrateOldSettings() {
if (getStorageItem('_sidebar_migration_done')) return;
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
// 1. Migrate global hide setting to per-page
if (state?.global?.settings?.show_folder_sidebar === false) {
@@ -0,0 +1,66 @@
// OtherControls.js - Specific implementation for the Other Models page
import { PageControls } from './PageControls.js';
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
import { showToast } from '../../utils/uiHelpers.js';
import { downloadManager } from '../../managers/DownloadManager.js';
/**
* OtherControls class - Extends PageControls for the Other Models page
* (VAE, upscalers, text encoders, CLIP vision, ControlNet, ...)
*/
export class OtherControls extends PageControls {
constructor() {
// Initialize with 'other' page type
super('other');
// Register API methods specific to the Other Models page
this.registerOtherAPI();
}
/**
* Register Other-models-specific API methods
*/
registerOtherAPI() {
const otherAPI = {
// Core API functions
loadMoreModels: async (resetPage = false, updateFolders = false) => {
return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders);
},
resetAndReload: async (updateFolders = false) => {
return await resetAndReload(updateFolders);
},
refreshModels: async (fullRebuild = false) => {
return await getModelApiClient().refreshModels(fullRebuild);
},
// Add fetch from Civitai functionality for other models
fetchFromCivitai: async () => {
return await getModelApiClient().fetchCivitaiMetadata();
},
// Add show download modal functionality
showDownloadModal: () => {
downloadManager.showDownloadModal();
},
toggleBulkMode: () => {
if (window.bulkManager) {
window.bulkManager.toggleBulkMode();
} else {
console.error('Bulk manager not available');
}
},
// No clearCustomFilter implementation is needed for other models
// as custom filters are currently only used for LoRAs
clearCustomFilter: async () => {
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
}
};
// Register the API
this.registerAPI(otherAPI);
}
}
+6 -3
View File
@@ -3,13 +3,14 @@ import { PageControls } from './PageControls.js';
import { LorasControls } from './LorasControls.js';
import { CheckpointsControls } from './CheckpointsControls.js';
import { EmbeddingsControls } from './EmbeddingsControls.js';
import { OtherControls } from './OtherControls.js';
// Export the classes
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls };
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls };
/**
* Factory function to create the appropriate controls based on page type
* @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings')
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'other')
* @returns {PageControls} - The appropriate controls instance
*/
export function createPageControls(pageType) {
@@ -19,8 +20,10 @@ export function createPageControls(pageType) {
return new CheckpointsControls();
} else if (pageType === 'embeddings') {
return new EmbeddingsControls();
} else if (pageType === 'other') {
return new OtherControls();
} else {
console.error(`Unknown page type: ${pageType}`);
return null;
}
}
}
+3
View File
@@ -58,6 +58,8 @@ class InitializationManager {
this.pageType = 'recipes';
} else if (path.includes('/checkpoints')) {
this.pageType = 'checkpoints';
} else if (path.includes('/other')) {
this.pageType = 'other';
} else if (path.includes('/loras')) {
this.pageType = 'loras';
} else if (path.includes('/embeddings')) {
@@ -221,6 +223,7 @@ class InitializationManager {
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings',
'other': 'other',
'recipe': 'recipes'
};
+16 -3
View File
@@ -1,6 +1,7 @@
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { hasCivitaiSource } from './utils.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
@@ -63,7 +64,10 @@ function handleModelCardEvent_internal(event, modelType) {
if (event.target.closest('.fa-globe')) {
event.stopPropagation();
if (card.dataset.from_civitai === 'true') {
// CivitAI wins when the model actually has CivitAI data; otherwise fall
// back to HuggingFace. Relying on `from_civitai` here made the two
// sources mutually exclusive whenever one of them was (re)linked (#1094).
if (card.dataset.has_civitai === 'true') {
openCivitai(card.dataset.filepath);
} else if (card.dataset.hf_url) {
openHuggingFace(card.dataset.hf_url);
@@ -250,6 +254,11 @@ function handleCopyAction(card, modelType) {
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
copyToClipboard(embeddingCode, message);
} else {
// Other model types (VAE, upscalers, ...) - copy the file name
const fileName = card.dataset.file_name;
const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied');
copyToClipboard(fileName, message);
}
}
@@ -473,6 +482,9 @@ export function createModelCard(model, modelType) {
card.dataset.modified = model.modified;
card.dataset.file_size = model.file_size;
card.dataset.from_civitai = model.from_civitai;
// Independent of `from_civitai`: a model can have both CivitAI data and an
// HF link, and the card globe must keep pointing at CivitAI when it does.
card.dataset.has_civitai = hasCivitaiSource(model.civitai) ? 'true' : 'false';
card.dataset.usage_count = String(model.usage_count);
card.dataset.notes = model.notes || '';
card.dataset.base_model = model.base_model || 'Unknown';
@@ -595,12 +607,13 @@ export function createModelCard(model, modelType) {
const favoriteTitle = isFavorite ?
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
const globeTitle = model.from_civitai ?
const hasCivitai = hasCivitaiSource(model.civitai);
const globeTitle = hasCivitai ?
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
model.hf_url ?
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
const globeEnabled = model.from_civitai || !!model.hf_url;
const globeEnabled = hasCivitai || !!model.hf_url;
let sendTitle;
let copyTitle;
if (modelType === MODEL_TYPES.LORA) {
+6 -2
View File
@@ -14,7 +14,7 @@ import {
} from './ModelMetadata.js';
import { setupTagEditMode } from './ModelTags.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml } from './utils.js';
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml, hasCivitaiSource } from './utils.js';
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js';
@@ -389,7 +389,11 @@ export async function showModelModal(model, modelType) {
const licenseIcons = useNewIcons
? renderNewLicenseIcons(modelWithFullData)
: renderLicenseIcons(modelWithFullData);
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
// Gate the CivitAI link on actual CivitAI data, not the `from_civitai`
// provenance flag: a model can be linked to HuggingFace and to CivitAI at
// the same time, and both links must coexist (#1094).
const hasCivitai = hasCivitaiSource(modelWithFullData.civitai);
const viewOnCivitaiAction = hasCivitai ? `
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
</div>`.trim() : '';
@@ -1390,8 +1390,22 @@ export function initVersionsTab({
try {
const client = ensureClient();
const rootsData = await client.fetchModelRoots();
const roots = rootsData?.roots;
// On the checkpoints page a diffusion model lives under the unet
// roots, so both root sets are needed to locate the current file.
let roots;
if (modelType === 'checkpoints') {
const [checkpointRoots, unetRoots] = await Promise.all([
client.fetchModelRoots(),
client.fetchModelRoots('diffusion_model'),
]);
roots = [
...(checkpointRoots?.roots || []),
...(unetRoots?.roots || []),
];
} else {
const rootsData = await client.fetchModelRoots();
roots = rootsData?.roots;
}
if (!Array.isArray(roots) || roots.length === 0) {
return null;
}
+18
View File
@@ -36,6 +36,24 @@ export function formatFileSize(bytes) {
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
/**
* Whether a model has usable CivitAI metadata to link to.
*
* CivitAI links must be gated on the presence of actual CivitAI data rather
* than the `from_civitai` provenance flag: linking a model to HuggingFace used
* to flip `from_civitai` to false, which hid the CivitAI link even though the
* model still had CivitAI metadata. See issue #1094.
*
* @param {Object} [civitaiData] - The model's `civitai` payload
* @returns {boolean} True when a CivitAI model/version id is available
*/
export function hasCivitaiSource(civitaiData) {
if (!civitaiData || typeof civitaiData !== 'object') return false;
return Boolean(
civitaiData.modelId ?? civitaiData.model_id ?? civitaiData.id
);
}
/**
* Render compact tags
* @param {Array} tags - Array of tags
+3 -1
View File
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
import { rematchModalManager } from './managers/RematchModalManager.js';
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { doctorManager } from './managers/DoctorManager.js';
@@ -68,6 +69,7 @@ export class AppCore {
window.doctorManager = doctorManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
window.rematchModalManager = rematchModalManager;
// Initialize UI components
window.headerManager = new HeaderManager();
@@ -114,7 +116,7 @@ export class AppCore {
initializePageFeatures() {
const pageType = this.getPageType();
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
this.initializeContextMenus(pageType);
initializeInfiniteScroll(pageType);
}
+100 -2
View File
@@ -6,9 +6,11 @@ import {
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { enableOtherModels, openOtherModelsSettings } from '../utils/otherModels.js';
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
@@ -80,6 +82,7 @@ class BannerService {
});
this.prepareCommunitySupportBanner();
this.prepareOtherModelsBanner();
await this.showActiveBanners();
this.initialized = true;
@@ -424,12 +427,13 @@ class BannerService {
/**
* Get the current page type from the URL
* @returns {string} Page type (loras, checkpoints, embeddings, recipes)
* @returns {string} Page type (loras, checkpoints, embeddings, other, recipes)
*/
getCurrentPageType() {
const path = window.location.pathname;
if (path.includes('/checkpoints')) return 'checkpoints';
if (path.includes('/embeddings')) return 'embeddings';
if (path.includes('/other')) return 'other';
if (path.includes('/recipes')) return 'recipes';
return 'loras';
}
@@ -443,7 +447,8 @@ class BannerService {
const endpoints = {
'loras': '/api/lm/loras/reload?rebuild=true',
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
'embeddings': '/api/lm/embeddings/reload?rebuild=true'
'embeddings': '/api/lm/embeddings/reload?rebuild=true',
'other': '/api/lm/other/reload?rebuild=true'
};
return endpoints[pageType] || endpoints['loras'];
}
@@ -539,6 +544,99 @@ class BannerService {
this.updateContainerVisibility();
}
/**
* Announce the opt-in Other Models management to users who have not turned
* it on yet. Dismissal is persisted through the shared dismissed_banners
* setting, so users who are not interested are not nagged again.
*/
prepareOtherModelsBanner() {
if (state.global.settings.enable_other_models) {
return;
}
// Only announce when the host can actually resolve other-model folders.
// Standalone installs only know the folder_paths keys present in
// settings.json, so announcing there would land the user on an empty
// page. `=== false` (not falsy) keeps older payloads working.
if (state.global.settings.other_models_paths_available === false) {
return;
}
if (this.isBannerDismissed(OTHER_MODELS_BANNER_ID)) {
return;
}
this.registerBanner(OTHER_MODELS_BANNER_ID, {
id: OTHER_MODELS_BANNER_ID,
title: translate(
'banners.otherModels.title',
{},
'Other Models Management is available'
),
content: translate(
'banners.otherModels.content',
{},
'Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.'
),
actions: [
{
text: translate(
'banners.otherModels.enable',
{},
'Enable Other Models'
),
icon: 'fas fa-shapes',
type: 'primary',
action: 'enable-other-models'
},
{
text: translate(
'banners.otherModels.openSettings',
{},
'Open Settings'
),
icon: 'fas fa-cog',
type: 'secondary',
action: 'open-other-models-settings'
}
],
dismissible: true,
priority: 0,
onRegister: (bannerElement) => {
const enableButton = bannerElement.querySelector(
'.banner-action[data-action="enable-other-models"]'
);
if (enableButton) {
enableButton.addEventListener('click', (event) => {
event.preventDefault();
enableOtherModels().catch((error) => {
console.error('Failed to enable Other Models:', error);
});
});
}
const settingsButton = bannerElement.querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
if (settingsButton) {
settingsButton.addEventListener('click', (event) => {
event.preventDefault();
openOtherModelsSettings();
});
}
}
});
this.updateContainerVisibility();
}
/**
* Drop the Other Models announcement once the feature is enabled.
* Dismissal is deliberately NOT persisted, so the announcement can come
* back if the user switches the feature off again.
*/
removeOtherModelsAnnouncement() {
this.removeBannerElement(OTHER_MODELS_BANNER_ID);
}
initializeCommunitySupportState() {
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
+23 -16
View File
@@ -18,6 +18,7 @@ export class BatchImportManager {
this.results = null;
this.isCancelled = false;
this.isImporting = false;
this.currentParentPath = null;
}
/**
@@ -718,9 +719,10 @@ export class BatchImportManager {
browser.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// Load initial directory when opening
// Load initial directory when opening. An empty path lets the
// server pick its default (user home); "/" would be POSIX-only.
const currentPath = document.getElementById('batchDirectoryInput').value;
this.loadDirectory(currentPath || '/');
this.loadDirectory(currentPath || '');
}
}
}
@@ -761,6 +763,10 @@ export class BatchImportManager {
const directoryCount = document.getElementById('batchDirectoryCount');
const imageCount = document.getElementById('batchImageCount');
// Remember the server-computed parent path so the "up" navigation
// works with Windows paths too (they cannot be split on "/").
this.currentParentPath = data.parent_path || null;
if (currentPathEl) {
currentPathEl.textContent = data.current_path;
}
@@ -811,11 +817,9 @@ export class BatchImportManager {
`;
item.addEventListener('click', () => {
if (isParent) {
this.navigateToParentDirectory();
} else {
this.loadDirectory(path);
}
// The parent entry uses the server-provided parent_path (or the
// Windows drive-list token) directly — both are plain load targets.
this.loadDirectory(path);
});
return item;
@@ -839,15 +843,12 @@ export class BatchImportManager {
}
/**
* Navigate to parent directory
* Navigate to parent directory using the path reported by the server.
* Deriving it client-side by splitting on "/" breaks Windows paths.
*/
navigateToParentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
if (currentPath) {
// Get parent path using path manipulation
const lastSeparator = currentPath.lastIndexOf('/');
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
this.loadDirectory(parentPath);
if (this.currentParentPath) {
this.loadDirectory(this.currentParentPath);
}
}
@@ -857,8 +858,14 @@ export class BatchImportManager {
selectCurrentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
const directoryInput = document.getElementById('batchDirectoryInput');
if (currentPath && directoryInput) {
if (!currentPath) {
// Virtual levels (e.g. the Windows drive list) have no path.
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
return;
}
if (directoryInput) {
directoryInput.value = currentPath;
this.toggleDirectoryBrowser(); // Close browser
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
+44 -28
View File
@@ -3,6 +3,8 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { rematchModalManager } from './RematchModalManager.js';
import { showRematchSummary } from '../components/RematchSummaryModal.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
@@ -91,6 +93,20 @@ export class BulkManager {
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.OTHER]: {
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
checkUpdates: true,
moveAll: true,
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
recipes: {
addTags: true,
sendToWorkflow: false,
@@ -978,6 +994,15 @@ export class BulkManager {
return;
}
// Collect options (relaxed matching) before starting anything; the
// run only begins when the user confirms the dialog.
rematchModalManager.showOptionsModal({
recipeCount: state.selectedModels.size,
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
});
}
async _startRematchSelectedRecipes(relaxed = false) {
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
@@ -989,7 +1014,7 @@ export class BulkManager {
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
const result = await apiClient.rematchBulkModels(filePaths);
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
if (result.success) {
const total = result.total || filePaths.length;
@@ -1015,38 +1040,29 @@ export class BulkManager {
}
}
if (matchedEntries > 0) {
const hasFailures = failures > 0;
const toastKey = hasFailures
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
hasFailures ? 'warning' : 'success'
);
} else if (failures > 0) {
// Nothing matched and at least one recipe errored —
// "no rematch needed" would be actively misleading here.
showToast(
'toast.recipes.rematchAllFailed',
{ total, failures },
'error'
);
} else if (unresolvedEntries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
'info'
);
} else {
// Complete no-op (nothing matched, nothing unresolved, no
// errors) keeps the lightweight toast; anything else opens
// the post-run summary modal.
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
if (isNoop) {
showToast(
'toast.recipes.rematchSkipped',
{ total },
'info'
);
} else {
showRematchSummary({
scope: 'bulk',
total,
matchedRecipes,
matchedEntries,
unresolvedRecipes,
unresolvedEntries,
skipped,
errors: failures,
l4Matches,
});
}
if (state.bulkMode) this.toggleBulkMode();
@@ -1,7 +1,9 @@
import { showToast } from '../utils/uiHelpers.js';
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { extractRecipeId } from '../api/recipeApi.js';
import { state } from '../state/index.js';
import { modalManager } from './ModalManager.js';
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
this.pendingLoras = [];
this.pendingRecipes = [];
this.pendingMissingByRecipe = null;
}
/**
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
// Execute download
await this.executeDownload(this.pendingLoras);
this.pendingLoras = [];
this.pendingMissingByRecipe = null;
}
/**
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
// Collect missing LoRAs with deduplication
const stats = this.collectMissingLoras(selectedRecipes);
// Kept so executeDownload can mark unresolvable failures back onto
// every recipe occurrence (hashInvalid → reconnect candidacy).
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
if (stats.uniqueCount === 0) {
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
let completedDownloads = 0;
let failedDownloads = 0;
let markedInvalidCount = 0;
let currentLoraProgress = 0;
let cancelled = false;
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
if (!response.success) {
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
failedDownloads++;
// An unresolvable failure (model gone on CivitAI) flips
// every recipe occurrence to reconnect candidacy — same
// rule as the single-LoRA download in RecipeModal.
if (isUnresolvableDownloadError(response.error)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
} else {
completedDownloads++;
updateProgress(100, completedDownloads, '');
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
if (!cancelled) {
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
failedDownloads++;
if (isUnresolvableDownloadError(error?.message)) {
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
}
}
}
}
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
}, 'warning');
}
// Unresolvable failures were marked hash-invalid during the loop;
// tell the user those entries now offer reconnect instead of download.
if (markedInvalidCount > 0) {
showToast('toast.recipes.unresolvableMarkedForReconnect', {
count: markedInvalidCount
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
}
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
if (state.virtualScroller) {
const { extractRecipeId } = await import('../api/recipeApi.js');
for (const recipe of this.pendingRecipes) {
const recipeId = extractRecipeId(recipe.file_path);
if (!recipeId) continue;
@@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager {
}
}
/**
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
*
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
* makes each occurrence an unresolved rematch candidate and swaps its
* action from download to reconnect. Only called for unresolvable
* failures transient errors leave entries untouched.
*
* @param {Object} failedLora - The deduplicated LoRA that failed
* @returns {Promise<number>} - How many recipe entries were marked
*/
async markLoraHashInvalidInRecipes(failedLora) {
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
if (!failedKey || !this.pendingMissingByRecipe) {
return 0;
}
let marked = 0;
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
if (!recipeId || !Array.isArray(recipe.loras)) {
continue;
}
for (const entry of missingLoras) {
const entryKey = entry.hash || entry.id || entry.modelVersionId;
if (entryKey !== failedKey) {
continue;
}
const loraIndex = recipe.loras.indexOf(entry);
if (loraIndex < 0) {
continue;
}
try {
const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
recipe_id: recipeId,
lora_index: loraIndex,
}),
});
if (response.ok) {
entry.hashInvalid = true;
marked++;
}
} catch (error) {
console.warn('Failed to mark LoRA hash invalid:', error);
}
}
}
return marked;
}
/**
* Get LoRA root directory from API
* @returns {Promise<string|null>} - LoRA root directory or null
+142 -19
View File
@@ -1,15 +1,18 @@
import { modalManager } from './ModalManager.js';
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { showToast, showActionToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { DOWNLOAD_ENDPOINTS } from '../api/apiConfig.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
import { formatFileSize } from '../utils/formatters.js';
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
import { openOtherModelsSettings } from '../utils/otherModels.js';
export class DownloadManager {
constructor() {
@@ -489,8 +492,9 @@ export class DownloadManager {
return { type: 'civitai' };
}
// Hugging Face resolve URL → direct file
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
// Hugging Face resolve/blob URL → direct file
// "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
if (hfResolveMatch) {
return {
type: 'hf-resolve',
@@ -953,17 +957,18 @@ export class DownloadManager {
async proceedToLocationContent() {
try {
const _isDiffusionModel = this.selectedFile
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
: (this.currentVersion?.files || []).some(
f => f.type === 'UNet' || f.type === 'Diffusion Model'
);
this._isDiffusionModel = _isDiffusionModel;
this._isDiffusionModel = await this._resolveIsDiffusionModel();
this._otherSubType = await this._resolveOtherSubType();
let rootsData;
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
} else {
// An undecidable other sub_type (null) intentionally lands
// here: fetchModelRoots() lists all other roots so the user
// can pick manually.
rootsData = await this.apiClient.fetchModelRoots();
}
const modelRoot = document.getElementById('modelRoot');
@@ -971,19 +976,29 @@ export class DownloadManager {
`<option value="${root}">${root}</option>`
).join('');
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
const defaultRoot = state.global.settings[defaultRootKey];
console.log(`Default root for ${singularType}:`, defaultRoot);
let defaultRoot;
let subtypeDisplay;
if (this.apiClient.modelType === 'other') {
const otherDefaultRoots = state.global.settings.default_other_roots || {};
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
subtypeDisplay = this._otherSubType
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
: this.apiClient.apiConfig.config.displayName;
} else {
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const defaultRootKey = `default_${singularType}_root`;
defaultRoot = state.global.settings[defaultRootKey];
subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
}
console.log('Default root:', defaultRoot);
console.log('Available roots:', rootsData.roots);
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
console.log(`Setting default root: ${defaultRoot}`);
modelRoot.value = defaultRoot;
}
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
document.getElementById('modelRootLabel').textContent =
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
@@ -1019,6 +1034,109 @@ export class DownloadManager {
}
}
/**
* Decide whether this download routes to the diffusion model (unet)
* roots rather than the checkpoint roots. The backend owns the routing
* rule (file type first, baseModel fallback), so the location step asks
* it; if the endpoint is unavailable we degrade to the local file-type
* signal, which matches the backend for well-annotated models.
*/
async _resolveIsDiffusionModel() {
const localFileTypeCheck = this.selectedFile
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
: (this.currentVersion?.files || []).some(
f => f.type === 'UNet' || f.type === 'Diffusion Model'
);
// Only checkpoint downloads can route to the diffusion model roots;
// without version metadata (e.g. Hugging Face downloads) the local
// signal is all we have.
if (this.apiClient.modelType !== 'checkpoints'
|| (!this.selectedFile && !this.currentVersion)) {
return localFileTypeCheck;
}
try {
const fileTypes = this.selectedFile
? [this.selectedFile.type]
: (this.currentVersion?.files || []).map(f => f.type);
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_type: 'checkpoint',
base_model: this.currentVersion?.baseModel || '',
file_types: fileTypes,
}),
});
if (!response.ok) {
throw new Error(`routing endpoint returned ${response.status}`);
}
const data = await response.json();
if (typeof data.is_diffusion_model === 'boolean') {
return data.is_diffusion_model;
}
} catch (error) {
console.warn('[download] routing endpoint unavailable, '
+ 'falling back to local file-type check:', error);
}
return localFileTypeCheck;
}
/**
* Resolve which other-page sub_type (vae/upscaler/text_encoder/
* clip_vision/controlnet) this download routes to. The backend owns the
* routing rule (explicit file pick first, model.type next, file.type
* fallback), so the location step sends both the picked file's type
* (selected_file_type) and the version's full file-type list and lets
* the backend apply its priority chain. Returns null when the sub_type
* cannot be decided; the location step then lists all other roots for
* manual selection instead of guessing a folder.
*/
async _resolveOtherSubType() {
// Only other-page downloads route by sub_type; without version
// metadata (e.g. Hugging Face downloads) there is nothing to route on.
if (this.apiClient.modelType !== 'other'
|| (!this.selectedFile && !this.currentVersion)) {
return null;
}
try {
const fileTypes = (this.currentVersion?.files || []).map(f => f.type);
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_type: 'other',
base_model: this.currentVersion?.baseModel || '',
file_types: fileTypes,
...(this.selectedFile
? { selected_file_type: this.selectedFile.type }
: {}),
}),
});
if (!response.ok) {
throw new Error(`routing endpoint returned ${response.status}`);
}
const data = await response.json();
if (data.disabled) {
// The matching sub_type (or the whole Other Models feature) is
// switched off: auto-routing is refused, so offer the settings
// shortcut while the user's intent is clear.
showActionToast('other.disabled.downloadBlocked', {}, 'warning', {
actionText: translate('other.disabled.enableAction', {}, 'Enable Other Models'),
onAction: () => openOtherModelsSettings(),
});
return null;
}
return data.sub_type || null;
} catch (error) {
console.warn('[download] other routing endpoint unavailable, '
+ 'falling back to manual root selection:', error);
return null;
}
}
loadDefaultPathSetting() {
const modelType = this.apiClient.modelType;
const storageKey = `use_default_path_${modelType}`;
@@ -2402,9 +2520,14 @@ export class DownloadManager {
const singularType = this._isDiffusionModel
? 'unet'
: this.apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates;
const template = templates[singularType];
fullPath += `/${template}`;
const templates = state.global?.settings?.download_path_templates;
const template = templates?.[singularType];
// An empty or absent template means a flat layout: keep the
// root as-is instead of appending "/undefined" or a
// dangling slash.
if (template) {
fullPath += `/${template}`;
}
} catch (error) {
console.error('Failed to fetch template:', error);
fullPath += '/' + translate('modals.download.autoOrganizedPath');
+2 -2
View File
@@ -805,7 +805,7 @@ export class FilterManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') {
// For models page, reset the page and reload
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
}
@@ -904,7 +904,7 @@ export class FilterManager {
// Reload data using the appropriate method for the current page
if (this.currentPage === 'recipes' && window.recipeManager) {
await window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') {
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
}
+13
View File
@@ -347,6 +347,19 @@ export class ModalManager {
});
}
// Register rematchOptionsModal
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
if (rematchOptionsModal) {
this.registerModal('rematchOptionsModal', {
element: rematchOptionsModal,
onClose: () => {
this.getModal('rematchOptionsModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
document.addEventListener('keydown', this.boundHandleEscape);
this.initialized = true;
}
+29 -20
View File
@@ -60,7 +60,6 @@ class MoveManager {
this.bulkFilePaths = null;
const apiClient = this._getApiClient(modelType);
const currentPageType = state.currentPageType;
const modelConfig = apiClient.apiConfig.config;
// Handle bulk mode
@@ -113,7 +112,7 @@ class MoveManager {
).join('');
// Set default root if available
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
const settingsKey = `default_${modelConfig.singularName}_root`;
const defaultRoot = state.global.settings[settingsKey];
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
modelRootSelect.value = defaultRoot;
@@ -227,15 +226,13 @@ class MoveManager {
if (modelRoot) {
if (this.useDefaultPath) {
// Show actual template path
try {
const singularType = apiClient.modelType.replace(/s$/, '');
const templates = state.global.settings.download_path_templates;
const template = templates[singularType];
// Show actual template path; an empty/absent template means a
// flat layout, so keep the root as-is.
const singularType = config.singularName || apiClient.modelType.replace(/s$/, '');
const templates = state.global?.settings?.download_path_templates;
const template = templates?.[singularType];
if (template) {
fullPath += `/${template}`;
} catch (error) {
console.error('Failed to fetch template:', error);
fullPath += '/' + translate('modals.download.autoOrganizedPath');
}
} else {
// Show manual path selection
@@ -329,7 +326,11 @@ class MoveManager {
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
movedFiles = (results || [])
.filter(r => r.success)
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
.map(r => ({
original_file_path: r.original_file_path,
new_file_path: r.new_file_path,
sub_type: r.cache_entry?.sub_type
}));
// Deselect moving items and exit bulk mode
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
@@ -340,7 +341,11 @@ class MoveManager {
if (result) {
movedFiles.push({
original_file_path: result.original_file_path || this.currentFilePath,
new_file_path: result.new_file_path
new_file_path: result.new_file_path,
// The backend recalculates location-derived fields
// (e.g. checkpoint -> diffusion_model) during the move;
// carry them so the card re-renders with the new type.
sub_type: result.cache_entry?.sub_type
});
}
@@ -379,24 +384,28 @@ class MoveManager {
}
if (stillVisible) {
const newData = {
file_path: moved.new_file_path,
folder: newRelativeFolder
};
if (moved.sub_type) newData.sub_type = moved.sub_type;
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: newRelativeFolder
}
newData
});
} else {
pathsToRemove.push(moved.original_file_path);
}
} else {
// No folder filter active — items remain visible, just update path
const newData = {
file_path: moved.new_file_path,
folder: this._getRelativeFolder(moved.new_file_path)
};
if (moved.sub_type) newData.sub_type = moved.sub_type;
pathsToUpdate.push({
originalPath: moved.original_file_path,
newData: {
file_path: moved.new_file_path,
folder: this._getRelativeFolder(moved.new_file_path)
}
newData
});
}
}
+72
View File
@@ -0,0 +1,72 @@
import { modalManager } from './ModalManager.js';
import { translate } from '../utils/i18nHelpers.js';
/**
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
* and only then invokes the run callback. Post-run reporting lives in
* static/js/components/RematchSummaryModal.js.
*/
export class RematchModalManager {
constructor() {
this._optionsConfirmCallback = null;
}
/**
* Open the options modal. `onConfirm({ relaxed })` fires only when the
* user clicks Rematch Cancel/X runs nothing.
*
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
*/
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
const message = document.getElementById('rematchOptionsMessage');
if (message) {
if (resolvedScope === 'bulk') {
message.textContent = translate(
'modals.rematchOptions.messageBulk',
{ count: recipeCount },
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
);
} else if (resolvedScope === 'single') {
message.textContent = translate(
'modals.rematchOptions.messageSingle',
{},
'This recipe will be scanned against your local model library.'
);
} else {
message.textContent = translate(
'modals.rematchOptions.messageGlobal',
{},
'All recipes will be scanned against your local model library.'
);
}
}
const checkbox = document.getElementById('rematchOptionsRelaxed');
if (checkbox) {
checkbox.checked = false;
}
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
modalManager.showModal('rematchOptionsModal');
}
confirmOptions() {
const checkbox = document.getElementById('rematchOptionsRelaxed');
const relaxed = checkbox ? !!checkbox.checked : false;
const callback = this._optionsConfirmCallback;
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
if (callback) {
// Returned so callers (and tests) can await the started run.
return callback({ relaxed });
}
return undefined;
}
cancelOptions() {
this._optionsConfirmCallback = null;
modalManager.closeModal('rematchOptionsModal');
}
}
export const rematchModalManager = new RematchModalManager();
+2 -2
View File
@@ -298,7 +298,7 @@ export class SearchManager {
pageState.searchOptions.loraName = options.loraName || false;
pageState.searchOptions.loraModel = options.loraModel || false;
pageState.searchOptions.prompt = options.prompt || false;
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') {
// Update only the relevant fields in searchOptions instead of replacing the whole object
pageState.searchOptions.filename = options.filename || false;
pageState.searchOptions.modelname = options.modelname || false;
@@ -311,7 +311,7 @@ export class SearchManager {
// Call the appropriate manager's load method based on page type
if (this.currentPage === 'recipes' && window.recipeManager) {
window.recipeManager.loadRecipes(true);
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') {
// For models page, reset the page and reload
getModelApiClient().loadMoreWithVirtualScroll(true, false);
}
+159
View File
@@ -1153,6 +1153,10 @@ export class SettingsManager {
// Load default unet root
await this.loadUnetRoots();
// Load default other-model roots (per sub_type)
await this.loadOtherRoots();
this.updateOtherModelsControls();
// Load extra folder paths
this.loadExtraFolderPaths();
@@ -1658,6 +1662,51 @@ export class SettingsManager {
}
}
async loadOtherRoots() {
const selects = document.querySelectorAll('select[data-other-root-subtype]');
if (!selects.length) return;
try {
// Fetch other-model roots grouped by sub_type
const response = await fetch('/api/lm/other/roots_by_subtype');
if (!response.ok) {
throw new Error('Failed to fetch other model roots');
}
const data = await response.json();
const groupedRoots = data.roots_by_subtype || {};
const defaultRoots = state.global.settings.default_other_roots || {};
selects.forEach((select) => {
const subType = select.dataset.otherRootSubtype;
const roots = groupedRoots[subType] || [];
if (!roots.length) {
this.showNoRootsPlaceholder(select);
return;
}
select.innerHTML = '';
select.disabled = false;
// Add options for each root
roots.forEach(root => {
const option = document.createElement('option');
option.value = root;
option.textContent = root;
select.appendChild(option);
});
const defaultRoot = defaultRoots[subType] || '';
select.value = roots.includes(defaultRoot) ? defaultRoot : roots[0];
});
} catch (error) {
console.error('Error loading other model roots:', error);
selects.forEach((select) => this.showNoRootsPlaceholder(select));
showToast('toast.settings.otherRootsFailed', { message: error.message }, 'error');
}
}
async loadEmbeddingRoots() {
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
if (!defaultEmbeddingRootSelect) return;
@@ -2256,6 +2305,16 @@ export class SettingsManager {
await this.updateBackupStatus();
}
if (settingKey === 'enable_other_models') {
// Roots only exist while the feature is on, so re-fetch them
// after the backend rebuilt the other-model root set.
this.updateOtherModelsControls();
await this.loadOtherRoots();
this.updateOtherModelsControls();
this.updateOtherModelsNavVisibility(value);
this.removeOtherModelsAnnouncement(value);
}
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
// Apply frontend settings immediately
@@ -2339,6 +2398,103 @@ export class SettingsManager {
}
}
/**
* Save one sub_type entry of the default_other_roots dict setting
* (read-modify-write: the backend stores the whole mapping).
*/
async saveOtherRootSetting(subType, value) {
try {
const defaultRoots = { ...(state.global.settings.default_other_roots || {}) };
if (value) {
defaultRoots[subType] = value;
} else {
delete defaultRoots[subType];
}
await this.saveSetting('default_other_roots', defaultRoots);
showToast('toast.settings.settingsUpdated', { setting: `default ${subType} root` }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
/**
* Reflect the opt-in Other Models state in the settings UI: the master
* toggle gates every sub_type checkbox, and a switched-off sub_type has
* its default-root select disabled. Never force-enables a select (the
* no-roots placeholder owns that state).
*/
updateOtherModelsControls() {
const enableOtherModels = !!state.global.settings.enable_other_models;
const enabledSubTypes = new Set(
state.global.settings.enabled_other_sub_types
|| ['vae', 'upscaler', 'text_encoder']
);
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
input.checked = enabledSubTypes.has(input.value);
input.disabled = !enableOtherModels;
});
const container = document.getElementById('otherSubTypeToggles');
if (container) {
container.classList.toggle('is-disabled', !enableOtherModels);
}
document.querySelectorAll('select[data-other-root-subtype]').forEach((select) => {
const subType = select.dataset.otherRootSubtype;
if (!enableOtherModels || !enabledSubTypes.has(subType)) {
select.disabled = true;
}
});
}
/**
* Persist the whole enabled_other_sub_types list (the backend stores an
* allow-list) and refresh the per-sub_type default-root selects.
*/
async saveEnabledOtherSubTypes() {
const values = Array.from(
document.querySelectorAll('[data-other-subtype-toggle]')
)
.filter((input) => input.checked)
.map((input) => input.value);
try {
await this.saveSetting('enabled_other_sub_types', values);
this.updateOtherModelsControls();
await this.loadOtherRoots();
this.updateOtherModelsControls();
showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success');
} catch (error) {
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
}
}
/**
* Show or hide the Other Models nav entry. The nav is server-rendered, so
* toggling the class here keeps it in sync when the switch is flipped from
* the settings modal (no reload needed).
*/
updateOtherModelsNavVisibility(enabled) {
const navItem = document.getElementById('otherNavItem');
if (navItem) {
navItem.classList.toggle('nav-item--hidden', !enabled);
}
}
/**
* Drop the Other Models announcement banner once the feature is on.
*/
removeOtherModelsAnnouncement(enabled) {
if (!enabled) {
return;
}
bannerService.removeOtherModelsAnnouncement();
}
/**
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
* Shared entry point for the settings modal segmented control and the
@@ -3360,6 +3516,9 @@ export class SettingsManager {
} else if (this.currentPage === 'embeddings') {
// Reload the embeddings without updating folders
await resetAndReload(false);
} else if (this.currentPage === 'other') {
// Reload the other models without updating folders
await resetAndReload(false);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { appCore } from './core.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
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 Other Models page
class OtherPageManager {
constructor() {
// Initialize page controls
this.pageControls = createPageControls(MODEL_TYPES.OTHER);
// Initialize the ModelDuplicatesManager
this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.OTHER);
// Expose only necessary functions to global scope
this._exposeRequiredGlobalFunctions();
}
_exposeRequiredGlobalFunctions() {
// Minimal set of functions that need to remain global
window.confirmDelete = confirmDelete;
window.closeDeleteModal = closeDeleteModal;
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
async initialize() {
// Initialize common page features (including context menus)
appCore.initializePageFeatures();
// Mirror active filters to the backend for the ComfyUI-side autocomplete
initActiveFiltersSync(MODEL_TYPES.OTHER);
console.log('Other Models Manager initialized');
}
}
async function initializeOtherPage() {
// Initialize core application
await appCore.initialize();
// Initialize other models page
const otherPage = new OtherPageManager();
await otherPage.initialize();
return otherPage;
}
// Initialize everything when DOM is ready
document.addEventListener('DOMContentLoaded', initializeOtherPage);
export { OtherPageManager, initializeOtherPage };
+53
View File
@@ -0,0 +1,53 @@
import { appCore } from './core.js';
import { showToast } from './utils/uiHelpers.js';
import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.js';
/**
* Other Models is an opt-in feature. While it is disabled this page renders an
* empty state whose button turns the feature on; the backend then rebuilds the
* other-model roots and starts scanning, so a reload lands on the real page.
*
* The same module backs the "enabled but no folders found" state, where the
* only useful action is jumping to Settings instead of enabling anything.
*/
async function handleEnableClick() {
const button = document.getElementById('enableOtherModelsBtn');
if (!button || button.disabled) return;
button.disabled = true;
try {
await enableOtherModels();
} catch (error) {
button.disabled = false;
showToast('other.disabled.enableFailed', { message: error.message }, 'error');
}
}
/**
* Open Settings on the Library section for the "no folders found" state, so a
* misconfigured install can be fixed without hand-editing unknown keys.
*/
function handleOpenSettingsClick(event) {
event.preventDefault();
openOtherModelsSettings();
}
async function initializeOtherDisabledPage() {
// appCore.initialize() wires the shared header (theme, settings modal,
// language) so this page is not a dead end.
await appCore.initialize();
const button = document.getElementById('enableOtherModelsBtn');
if (button) {
button.addEventListener('click', handleEnableClick);
}
const settingsButton = document.getElementById('openOtherModelsSettingsBtn');
if (settingsButton) {
settingsButton.addEventListener('click', handleOpenSettingsClick);
}
}
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage };
+44
View File
@@ -24,6 +24,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
default_lora_root: '',
default_checkpoint_root: '',
default_embedding_root: '',
default_other_roots: {},
enable_other_models: false,
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
recipes_path: '',
base_model_path_mappings: {},
download_path_templates: {},
@@ -72,6 +75,8 @@ export function createDefaultSettings() {
base_model_path_mappings: {},
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
default_other_roots: {},
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
};
}
@@ -79,6 +84,7 @@ export function createDefaultSettings() {
const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
const otherPreviewVersions = getMapFromStorage('other_preview_versions');
export const state = {
// Global state
@@ -234,6 +240,44 @@ export const state = {
search: '',
},
activeViewSnapshot: null,
},
[MODEL_TYPES.OTHER]: {
currentPage: 1,
isLoading: false,
hasMore: true,
sortBy: 'name',
activeFolder: getStorageItem(`${MODEL_TYPES.OTHER}_activeFolder`),
previewVersions: otherPreviewVersions,
searchManager: null,
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true),
},
filters: {
baseModel: [],
tags: {},
license: {},
modelTypes: [],
search: '',
tagLogic: 'any',
},
bulkMode: false,
selectedModels: new Set(),
metadataCache: new Map(),
showFavoritesOnly: false,
showUpdateAvailableOnly: false,
duplicatesMode: false,
viewMode: 'active',
excludedViewState: {
sortBy: 'name:asc',
search: '',
},
activeViewSnapshot: null,
}
},
+1 -1
View File
@@ -53,7 +53,7 @@ export function syncActiveFilters(pageType) {
* Register the storage listener and push the current (restored) state once.
* The initial push covers server restarts, where the backend store is empty
* until the manager page re-publishes its localStorage-restored filters.
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings'
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' | 'other'
*/
export function initActiveFiltersSync(pageType) {
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
+16 -1
View File
@@ -106,6 +106,12 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = {
diffusion_model: "Diffusion Model",
// Embedding sub-types
embedding: "Embedding",
// Other model sub-types
vae: "VAE",
upscaler: "Upscaler",
text_encoder: "Text Encoder",
clip_vision: "CLIP Vision",
controlnet: "ControlNet",
};
// Backward compatibility alias
@@ -119,6 +125,11 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = {
checkpoint: "CKPT",
diffusion_model: "DM",
embedding: "EMB",
vae: "VAE",
upscaler: "UPS",
text_encoder: "TE",
clip_vision: "CV",
controlnet: "CN",
};
export function getSubTypeAbbreviation(subType) {
@@ -342,7 +353,11 @@ export const DEFAULT_PATH_TEMPLATES = {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}',
unet: '{base_model}',
embedding: '{first_tag}'
embedding: '{first_tag}',
// Other models (VAE/upscaler/...) default to a flat layout: their root is
// already split per sub_type, and priority_tags has no "other" entry, so
// {first_tag} would resolve to an arbitrary CivitAI tag.
other: ''
};
// Model type labels for UI
+1 -1
View File
@@ -66,7 +66,7 @@ async function getCardCreator(pageType) {
// Function to get the appropriate data fetcher based on page type
async function getDataFetcher(pageType) {
if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints') {
if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints' || pageType === 'other') {
return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize);
} else if (pageType === 'recipes') {
// Import the recipeApi module and use the fetchRecipesPage function
+52
View File
@@ -0,0 +1,52 @@
/**
* Shared helpers for the opt-in Other Models feature.
*
* Used by the disabled page, the announcement banner and the download modal so
* that enabling the feature always goes through the same settings API call and
* lands on the same settings section.
*/
/**
* Turn on Other Models management and reload so the server-rendered nav and
* the scanner state pick up the change.
*/
export async function enableOtherModels() {
const response = await fetch('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable_other_models: true }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
window.location.reload();
}
/**
* Open the settings modal on the Library section and scroll the Other Models
* toggle into view. Mirrors DoctorManager's open-settings-syntax-format flow.
*/
export function openOtherModelsSettings() {
const modalManager = window.modalManager;
if (modalManager && typeof modalManager.showModal === 'function') {
modalManager.showModal('settingsModal');
}
window.setTimeout(() => {
document.querySelectorAll('.settings-section').forEach((section) => {
section.classList.remove('active');
});
document.getElementById('section-library')?.classList.add('active');
document.querySelectorAll('.settings-nav-item').forEach((item) => {
item.classList.remove('active');
});
document.querySelector('.settings-nav-item[data-section="library"]')?.classList.add('active');
document.getElementById('enableOtherModels')?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, 100);
}
+1 -1
View File
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = 'lora_manager_';
// Matches keys that carry the manager page's active filter state
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/;
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings|other)_(activeFolder|recursiveSearch|filters)$/;
let activeFiltersListener = null;
+17
View File
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
}
/**
* Decide whether a download failure means the model is unrecoverable.
*
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
* only set when CivitAI explicitly says the model cannot be resolved never
* for transient transport errors (network, 5xx).
* @param {*} message - The error message carried by the failed download
* @returns {boolean}
*/
export function isUnresolvableDownloadError(message) {
if (!message) {
return false;
}
const text = String(message).toLowerCase();
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
}
export function restoreFolderFilter() {
const activeFolder = getStorageItem('activeFolder');
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
+3
View File
@@ -25,6 +25,9 @@
</div>
</div>
</div>
<div class="context-menu-item" data-action="enrich-hf-llm">
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>
</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Workflow -->
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>

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