Compare commits

...

25 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
105 changed files with 8879 additions and 194 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
+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.
+52
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"
},
@@ -233,6 +234,7 @@
"recipes": "Rezepte",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Andere",
"statistics": "Statistiken"
},
"search": {
@@ -533,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",
@@ -1201,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",
@@ -1878,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..."
@@ -2333,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}",
@@ -2595,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"
}
}
}
+52
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"
},
@@ -233,6 +234,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Other",
"statistics": "Stats"
},
"search": {
@@ -533,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",
@@ -1201,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",
@@ -1878,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..."
@@ -2333,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}",
@@ -2595,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"
}
}
}
+52
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"
},
@@ -233,6 +234,7 @@
"recipes": "Recetas",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Otros",
"statistics": "Estadísticas"
},
"search": {
@@ -533,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",
@@ -1201,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",
@@ -1878,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..."
@@ -2333,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}",
@@ -2595,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"
}
}
}
+52
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"
},
@@ -233,6 +234,7 @@
"recipes": "Recipes",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Autres",
"statistics": "Statistiques"
},
"search": {
@@ -533,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",
@@ -1201,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",
@@ -1878,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..."
@@ -2333,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}",
@@ -2595,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"
}
}
}
+52
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "העתק שם Checkpoint",
"copyEmbeddingName": "העתק שם Embedding",
"embeddingNameCopied": "תחביר Embedding הועתק",
"modelNameCopied": "שם המודל הועתק",
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
},
@@ -233,6 +234,7 @@
"recipes": "מתכונים",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "אחרים",
"statistics": "סטטיסטיקה"
},
"search": {
@@ -533,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",
@@ -1201,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": "כווץ את כל התיקיות",
@@ -1878,6 +1919,10 @@
"title": "מאתחל מנהל Embedding",
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
},
"other": {
"title": "מאתחל את מנהל המודלים האחרים",
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
},
"recipes": {
"title": "מאתחל מנהל מתכונים",
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
@@ -2333,6 +2378,7 @@
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
@@ -2595,6 +2641,12 @@
"rebuilding": "בונה מחדש את המטמון...",
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
"retry": "נסה שוב"
},
"otherModels": {
"title": "ניהול המודלים האחרים זמין",
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
"enable": "הפעל מודלים אחרים",
"openSettings": "פתח הגדרות"
}
}
}
+52
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint名をコピー",
"copyEmbeddingName": "embedding名をコピー",
"embeddingNameCopied": "Embedding構文をコピーしました",
"modelNameCopied": "モデル名をコピーしました",
"sendCheckpointToWorkflow": "ComfyUIに送信",
"sendEmbeddingToWorkflow": "ComfyUIに送信"
},
@@ -233,6 +234,7 @@
"recipes": "レシピ",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "その他",
"statistics": "統計"
},
"search": {
@@ -533,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",
@@ -1201,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": "すべてのフォルダを折りたたむ",
@@ -1878,6 +1919,10 @@
"title": "Embedding Managerを初期化中",
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
},
"other": {
"title": "その他のモデルマネージャーを初期化中",
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
},
"recipes": {
"title": "レシピマネージャーを初期化中",
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
@@ -2333,6 +2378,7 @@
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
@@ -2595,6 +2641,12 @@
"rebuilding": "キャッシュを再構築中...",
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
"retry": "再試行"
},
"otherModels": {
"title": "その他のモデル管理が利用可能になりました",
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
"enable": "その他のモデルを有効にする",
"openSettings": "設定を開く"
}
}
}
+52
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Checkpoint 이름 복사",
"copyEmbeddingName": "Embedding 이름 복사",
"embeddingNameCopied": "Embedding 구문 복사됨",
"modelNameCopied": "모델 이름 복사됨",
"sendCheckpointToWorkflow": "ComfyUI로 전송",
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
},
@@ -233,6 +234,7 @@
"recipes": "레시피",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "기타",
"statistics": "통계"
},
"search": {
@@ -533,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",
@@ -1201,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": "모든 폴더 접기",
@@ -1878,6 +1919,10 @@
"title": "Embedding Manager 초기화 중",
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"other": {
"title": "기타 모델 관리자 초기화 중",
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
},
"recipes": {
"title": "레시피 매니저 초기화 중",
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
@@ -2333,6 +2378,7 @@
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
@@ -2595,6 +2641,12 @@
"rebuilding": "캐시 재구축 중...",
"rebuildFailed": "캐시 재구축 실패: {error}",
"retry": "다시 시도"
},
"otherModels": {
"title": "기타 모델 관리를 사용할 수 있습니다",
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
"enable": "기타 모델 활성화",
"openSettings": "설정 열기"
}
}
}
+52
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "Копировать имя checkpoint",
"copyEmbeddingName": "Копировать имя embedding",
"embeddingNameCopied": "Синтаксис embedding скопирован",
"modelNameCopied": "Имя модели скопировано",
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
},
@@ -233,6 +234,7 @@
"recipes": "Рецепты",
"checkpoints": "Checkpoints",
"embeddings": "Embeddings",
"other": "Другое",
"statistics": "Статистика"
},
"search": {
@@ -533,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",
@@ -1201,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": "Свернуть все папки",
@@ -1878,6 +1919,10 @@
"title": "Инициализация Embedding Manager",
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
},
"other": {
"title": "Инициализация менеджера других моделей",
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
},
"recipes": {
"title": "Инициализация менеджера рецептов",
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
@@ -2333,6 +2378,7 @@
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
@@ -2595,6 +2641,12 @@
"rebuilding": "Перестроение кэша...",
"rebuildFailed": "Не удалось перестроить кэш: {error}",
"retry": "Повторить"
},
"otherModels": {
"title": "Управление другими моделями доступно",
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
"enable": "Включить другие модели",
"openSettings": "Открыть настройки"
}
}
}
+52
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "复制 Checkpoint 名称",
"copyEmbeddingName": "复制 Embedding 名称",
"embeddingNameCopied": "已复制 Embedding 语法",
"modelNameCopied": "模型名称已复制",
"sendCheckpointToWorkflow": "发送到 ComfyUI",
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
},
@@ -233,6 +234,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"statistics": "统计"
},
"search": {
@@ -533,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",
@@ -1201,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": "折叠所有文件夹",
@@ -1878,6 +1919,10 @@
"title": "初始化 Embedding 管理器",
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
},
"other": {
"title": "正在初始化其他模型管理器",
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在加载和处理配方。这可能需要几分钟..."
@@ -2333,6 +2378,7 @@
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
"otherRootsFailed": "加载其他模型根目录失败:{message}",
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
"mappingsCleared": "基础模型路径映射已清除",
"mappingSaveFailed": "保存基础模型映射失败:{message}",
@@ -2595,6 +2641,12 @@
"rebuilding": "正在重建缓存...",
"rebuildFailed": "重建缓存失败:{error}",
"retry": "重试"
},
"otherModels": {
"title": "其他模型管理现已可用",
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
"enable": "启用其他模型",
"openSettings": "打开设置"
}
}
}
+52
View File
@@ -149,6 +149,7 @@
"copyCheckpointName": "複製 Checkpoint 名稱",
"copyEmbeddingName": "複製嵌入名稱",
"embeddingNameCopied": "已複製 Embedding 語法",
"modelNameCopied": "模型名稱已複製",
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
},
@@ -233,6 +234,7 @@
"recipes": "配方",
"checkpoints": "Checkpoint",
"embeddings": "Embedding",
"other": "其他",
"statistics": "統計"
},
"search": {
@@ -533,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",
@@ -1201,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": "全部摺疊資料夾",
@@ -1878,6 +1919,10 @@
"title": "初始化 Embedding 管理器",
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
},
"other": {
"title": "正在初始化其他模型管理器",
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
},
"recipes": {
"title": "初始化配方管理器",
"message": "正在載入並處理配方,可能需要幾分鐘..."
@@ -2333,6 +2378,7 @@
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
"mappingsCleared": "基礎模型路徑對應已清除",
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
@@ -2595,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)
@@ -7,7 +7,11 @@ import logging
from aiohttp import web
from ...services.download_routing import is_diffusion_model_download
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__)
@@ -31,6 +35,7 @@ class DownloadRoutingHandler:
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(
@@ -44,6 +49,52 @@ class DownloadRoutingHandler:
},
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,
+4 -1
View File
@@ -240,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)
+126 -7
View File
@@ -53,6 +53,7 @@ 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
@@ -658,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"})
@@ -672,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:
@@ -757,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()
@@ -808,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)
@@ -840,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)
@@ -1072,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
@@ -1157,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
@@ -1537,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
@@ -2066,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:
@@ -2090,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):
@@ -2100,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):
@@ -2191,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:
@@ -2229,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(
@@ -2246,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,
@@ -2268,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
@@ -2276,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 = []
@@ -2307,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,
@@ -2364,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:
@@ -2399,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,
@@ -2787,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()
@@ -2816,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):
@@ -3980,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
+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)
+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] = {}
+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
+115 -5
View File
@@ -17,12 +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,
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
@@ -31,7 +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
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,
@@ -1686,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:
@@ -1725,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)
@@ -1921,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,
@@ -2133,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)
@@ -2629,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)
@@ -2718,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",
+52 -2
View File
@@ -10,9 +10,13 @@ two can never disagree.
from __future__ import annotations
import logging
from typing import Iterable
from typing import Iterable, Optional
from ..utils.constants import DIFFUSION_MODEL_BASE_MODELS
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__)
@@ -51,3 +55,49 @@ def is_diffusion_model_download(
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
+47 -8
View File
@@ -68,6 +68,7 @@ PAGE_TYPE_MAP = {
'lora': 'loras',
'checkpoint': 'checkpoints',
'embedding': 'embeddings',
'other': 'other',
}
@@ -209,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()
@@ -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.
@@ -1434,6 +1464,15 @@ 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.
+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):
+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 -1
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
@@ -318,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;
}
}
@@ -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 {
+15 -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`,
}
};
+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;
}
}
}
@@ -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"]');
@@ -112,7 +112,8 @@ export const ModelContextMenuMixin = {
const prefixMap = {
lora: 'loras',
checkpoint: 'checkpoints',
embedding: 'embeddings'
embedding: 'embeddings',
other: 'other'
};
return prefixMap[this.modelType] || 'loras';
},
@@ -445,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 {
@@ -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);
@@ -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', () => {
+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() : '';
+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
+1 -1
View File
@@ -116,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);
+14
View File
@@ -93,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,
+88 -11
View File
@@ -1,5 +1,5 @@
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';
@@ -8,9 +8,11 @@ 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() {
@@ -956,11 +958,17 @@ export class DownloadManager {
try {
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');
@@ -968,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 });
@@ -1065,6 +1083,60 @@ export class DownloadManager {
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}`;
@@ -2448,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);
}
+7 -10
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
+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;
+1 -1
View File
@@ -36,7 +36,7 @@
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
</optgroup>
{% endif %}
{% if page_id != 'embeddings' and page_id != 'recipes' %}
{% if page_id != 'embeddings' and page_id != 'recipes' and page_id != 'other' %}
<optgroup label="{{ t('loras.controls.sort.usage', default='Usage') }}">
<option value="usage:desc">{{ t('loras.controls.sort.usageDesc', default='Times used (high to low)') }}</option>
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
+18 -7
View File
@@ -15,6 +15,8 @@
{% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %}
{% elif current_path.startswith('/other') %}
{% set current_page = 'other' %}
{% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %}
{% else %}
@@ -36,6 +38,10 @@
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a>
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}{% if not settings.get('enable_other_models') %} nav-item--hidden{% endif %}"
id="otherNavItem">
<i class="fas fa-shapes"></i> <span>{{ t('header.navigation.other') }}</span>
</a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
@@ -68,23 +74,28 @@
<!-- Right section: Controls -->
<div class="header-right">
<div class="header-controls" id="headerControls">
<div class="theme-toggle" title="{{ t('header.theme.toggle') }}">
<div class="theme-toggle" role="button" tabindex="0" title="{{ t('header.theme.toggle') }}"
aria-label="{{ t('header.theme.toggle') }}">
<i class="fas fa-moon dark-icon"></i>
<i class="fas fa-sun light-icon"></i>
<i class="fas fa-adjust auto-icon"></i>
</div>
<div class="settings-toggle" title="{{ t('common.actions.settings') }}">
<div class="settings-toggle" role="button" tabindex="0" title="{{ t('common.actions.settings') }}"
aria-label="{{ t('common.actions.settings') }}">
<i class="fas fa-cog"></i>
</div>
<div class="help-toggle" id="helpToggleBtn" title="{{ t('common.actions.help') }}">
<div class="help-toggle" id="helpToggleBtn" role="button" tabindex="0" title="{{ t('common.actions.help') }}"
aria-label="{{ t('common.actions.help') }}">
<i class="fas fa-question-circle"></i>
<span class="update-badge"></span>
</div>
<div class="update-toggle" id="updateToggleBtn" title="{{ t('header.actions.notifications') }}">
<div class="update-toggle" id="updateToggleBtn" role="button" tabindex="0"
title="{{ t('header.actions.notifications') }}" aria-label="{{ t('header.actions.notifications') }}">
<i class="fas fa-bell"></i>
<span class="update-badge"></span>
</div>
<div class="support-toggle" id="supportToggleBtn" title="{{ t('header.actions.support') }}">
<div class="support-toggle" id="supportToggleBtn" role="button" tabindex="0"
title="{{ t('header.actions.support') }}" aria-label="{{ t('header.actions.support') }}">
<i class="fas fa-heart"></i>
</div>
</div>
@@ -193,7 +204,7 @@
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% elif request.path == '/embeddings' %}
{% elif request.path == '/embeddings' or request.path == '/other' %}
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
@@ -280,7 +291,7 @@
</div>
</div>
{% endif %}
{% if current_page == 'loras' or current_page == 'checkpoints' %}
{% if current_page == 'loras' or current_page == 'checkpoints' or current_page == 'other' %}
<div class="filter-section">
<h4>{{ t('header.filter.modelTypes') }}</h4>
<div class="filter-tags" id="modelTypeTags">
@@ -38,6 +38,64 @@
{{ sm.setting_select('defaultUnetRoot', 'default_unet_root', 'settings.folderSettings.defaultUnetRoot', [], 'settings.folderSettings.defaultUnetRootHelp') }}
{{ sm.setting_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }}
{{ sm.setting_toggle('enableOtherModels', 'enable_other_models', 'settings.folderSettings.enableOtherModels', 'settings.folderSettings.enableOtherModelsHelp') }}
{# 'is none' (not 'or') so an empty allow-list stays empty instead of
falling back to the defaults and re-checking every box. #}
{% set enabled_other_sub_types = settings.get('enabled_other_sub_types') %}
{% if enabled_other_sub_types is none %}{% set enabled_other_sub_types = ['vae', 'upscaler', 'text_encoder'] %}{% endif %}
<div class="setting-item other-subtype-toggles" id="otherSubTypeToggles">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.folderSettings.otherSubTypes') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.otherSubTypesHelp') }}"></i>
</label>
</div>
<div class="setting-control other-subtype-checkboxes">
{% for sub_type, label_key in [
('vae', 'settings.folderSettings.subTypeVae'),
('upscaler', 'settings.folderSettings.subTypeUpscaler'),
('text_encoder', 'settings.folderSettings.subTypeTextEncoder'),
('clip_vision', 'settings.folderSettings.subTypeClipVision'),
('controlnet', 'settings.folderSettings.subTypeControlnet'),
] %}
<label class="other-subtype-checkbox">
<input type="checkbox" value="{{ sub_type }}"
data-other-subtype-toggle="{{ sub_type }}"
{% if sub_type in enabled_other_sub_types %}checked{% endif %}
onchange="settingsManager.saveEnabledOtherSubTypes()">
<span>{{ t(label_key) }}</span>
</label>
{% endfor %}
</div>
</div>
</div>
{% set other_root_selects = [
('vae', 'defaultOtherRootVae', 'settings.folderSettings.defaultVaeRoot', 'settings.folderSettings.defaultVaeRootHelp'),
('upscaler', 'defaultOtherRootUpscaler', 'settings.folderSettings.defaultUpscalerRoot', 'settings.folderSettings.defaultUpscalerRootHelp'),
('text_encoder', 'defaultOtherRootTextEncoder', 'settings.folderSettings.defaultTextEncoderRoot', 'settings.folderSettings.defaultTextEncoderRootHelp'),
('clip_vision', 'defaultOtherRootClipVision', 'settings.folderSettings.defaultClipVisionRoot', 'settings.folderSettings.defaultClipVisionRootHelp'),
('controlnet', 'defaultOtherRootControlnet', 'settings.folderSettings.defaultControlnetRoot', 'settings.folderSettings.defaultControlnetRootHelp'),
] %}
{% for sub_type, select_id, label_key, help_key in other_root_selects %}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="{{ select_id }}">
{{ t(label_key) }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t(help_key) }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="{{ select_id }}" data-other-root-subtype="{{ sub_type }}" {% if sub_type not in enabled_other_sub_types %}disabled{% endif %} onchange="settingsManager.saveOtherRootSetting('{{ sub_type }}', this.value)">
</select>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Recipe Settings -->
+171
View File
@@ -0,0 +1,171 @@
{% extends "base.html" %}
{% block title %}{{ t('other.title') }}{% endblock %}
{% block page_id %}other{% endblock %}
{% block init_title %}{{ t('initialization.other.title') }}{% endblock %}
{% block init_message %}{{ t('initialization.other.message') }}{% endblock %}
{% block init_check_url %}/api/other/list?page=1&page_size=1{% endblock %}
{% block page_css %}
<style>
.other-disabled {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
text-align: center;
padding: 64px 24px;
color: var(--text-color, #e0e0e0);
}
.other-disabled > i {
font-size: 48px;
opacity: 0.6;
}
.other-disabled h2 {
margin: 0;
font-size: 20px;
}
.other-disabled p {
margin: 0;
max-width: 520px;
line-height: 1.6;
opacity: 0.8;
}
.other-disabled .other-disabled-hint {
font-size: 13px;
opacity: 0.6;
}
.other-disabled button {
margin-top: 8px;
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: #fff;
background: var(--primary-color, #4a7dff);
}
.other-disabled button:disabled {
opacity: 0.6;
cursor: default;
}
.other-no-paths-config {
margin: 4px 0 0;
padding: 12px 16px;
max-width: 520px;
overflow-x: auto;
text-align: left;
font-size: 12px;
line-height: 1.5;
border-radius: 6px;
background: rgba(127, 127, 127, 0.15);
border: 1px solid rgba(127, 127, 127, 0.25);
}
</style>
{% endblock %}
{% block additional_components %}
<div id="otherContextMenu" class="context-menu" style="display: none;">
<!-- Metadata -->
<div class="context-menu-item" data-action="refresh-metadata"><i class="fas fa-sync"></i> {{ t('loras.contextMenu.refreshMetadata') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="link-model">
<i class="fas fa-link"></i>
<span>{{ t('loras.contextMenu.linkModel') }}</span>
<i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="relink-civitai">
<i class="fas fa-external-link-alt"></i> <span>{{ t('loras.contextMenu.linkCivitai') }}</span>
</div>
<div class="context-menu-item" data-action="link-hf">
<i class="fas fa-robot"></i> <span>{{ t('loras.contextMenu.linkHuggingFace') }}</span>
</div>
</div>
</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>
<div class="context-menu-separator menu-section-break"></div>
<!-- Media / Preview -->
<div class="context-menu-item" data-action="preview"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.openExamples') }}</div>
<div class="context-menu-item has-submenu" data-has-submenu="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadExamples') }} <i class="fas fa-chevron-right submenu-arrow"></i>
<div class="context-submenu">
<div class="context-menu-item" data-action="download-examples"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingExamples') }}</div>
<div class="context-menu-item" data-action="download-examples-force"><i class="fas fa-redo-alt"></i> {{ t('loras.contextMenu.reprocessExamples') }}</div>
</div>
</div>
<div class="context-menu-item" data-action="replace-preview"><i class="fas fa-image"></i> {{ t('loras.contextMenu.replacePreview') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Attributes -->
<div class="context-menu-item" data-action="set-nsfw"><i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}</div>
<div class="context-menu-separator menu-section-break"></div>
<!-- Organization -->
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{ t('loras.contextMenu.moveToFolder') }}</div>
<div class="context-menu-separator"></div>
<!-- Destructive -->
<div class="context-menu-item" data-action="exclude"><i class="fas fa-eye-slash"></i> {{ t('loras.contextMenu.excludeModel') }}</div>
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteModel') }}</div>
</div>
{% endblock %}
{% block content %}
{% if other_disabled %}
<div class="other-disabled">
<i class="fas fa-shapes"></i>
<h2>{{ t('other.disabled.title') }}</h2>
<p>{{ t('other.disabled.description') }}</p>
<button id="enableOtherModelsBtn" type="button">
<i class="fas fa-toggle-on"></i> {{ t('other.disabled.enableButton') }}
</button>
<p class="other-disabled-hint">{{ t('other.disabled.hint') }}</p>
</div>
{% elif other_no_paths %}
<div class="other-disabled">
<i class="fas fa-folder-open"></i>
<h2>{{ t('other.noPaths.title') }}</h2>
{% if standalone_mode %}
<p>{{ t('other.noPaths.descriptionStandalone') }}</p>
<pre class="other-no-paths-config"><code>"folder_paths": {
"vae": ["/path/to/vae"],
"upscale_models": ["/path/to/upscale_models"],
"text_encoders": ["/path/to/text_encoders"],
"clip_vision": ["/path/to/clip_vision"],
"controlnet": ["/path/to/controlnet"]
}</code></pre>
<p class="other-disabled-hint">{{ t('other.noPaths.hintStandalone') }}</p>
{% else %}
<p>{{ t('other.noPaths.descriptionComfyUI') }}</p>
<p class="other-disabled-hint">{{ t('other.noPaths.hintComfyUI') }}</p>
{% endif %}
<button id="openOtherModelsSettingsBtn" type="button">
<i class="fas fa-cog"></i> {{ t('other.noPaths.openSettings') }}
</button>
</div>
{% else %}
<div class="sticky-topbar">
{% include 'components/controls.html' %}
{% include 'components/breadcrumb.html' %}
</div>
{% include 'components/duplicates_banner.html' %}
{% include 'components/folder_sidebar.html' %}
<!-- Other model cards container -->
<div class="card-grid" id="modelGrid">
<!-- Cards will be dynamically inserted here -->
</div>
{% endif %}
{% endblock %}
{% block overlay %}
<div class="bulk-mode-overlay"></div>
{% endblock %}
{% block main_script %}
{% if other_disabled or other_no_paths %}
<script type="module" src="/loras_static/js/other_disabled.js?v={{ version }}"></script>
{% else %}
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
{% endif %}
{% endblock %}
+487
View File
@@ -0,0 +1,487 @@
"""Tests for other-model path handling in py/config.py."""
import logging
import os
import pytest
from py import config as config_module
from py.services.settings_manager import get_settings_manager
def _normalize(path: str) -> str:
return os.path.normpath(path).replace(os.sep, "/")
def _make_config(**overrides) -> config_module.Config:
"""Create a bare Config instance for _prepare_other_paths tests."""
config = config_module.Config.__new__(config_module.Config)
config._path_mappings = {}
config._preview_root_paths = set()
config._cached_fingerprint = None
config.base_models_roots = []
config.embeddings_roots = []
for key, value in overrides.items():
setattr(config, key, value)
return config
@pytest.fixture(autouse=True)
def enable_other_models():
"""Other Models is opt-in; enable it for the enabled-state tests."""
manager = get_settings_manager()
manager.set("enable_other_models", True)
yield
class TestPrepareOtherPaths:
"""Unit tests for Config._prepare_other_paths."""
def test_maps_each_folder_key_to_sub_type(self, tmp_path):
roots = {
"vae": tmp_path / "vae",
"upscale_models": tmp_path / "upscale_models",
"text_encoders": tmp_path / "text_encoders",
"clip": tmp_path / "clip",
"clip_vision": tmp_path / "clip_vision",
"controlnet": tmp_path / "controlnet",
}
for root in roots.values():
root.mkdir()
config = _make_config()
unique, sub_type_map, per_key = config._prepare_other_paths(
{key: [str(root)] for key, root in roots.items()}
)
assert len(unique) == 6
assert sub_type_map[_normalize(str(roots["vae"]))] == "vae"
assert sub_type_map[_normalize(str(roots["upscale_models"]))] == "upscaler"
assert sub_type_map[_normalize(str(roots["text_encoders"]))] == "text_encoder"
# Legacy ComfyUI 'clip' key maps to text_encoder as well
assert sub_type_map[_normalize(str(roots["clip"]))] == "text_encoder"
assert sub_type_map[_normalize(str(roots["clip_vision"]))] == "clip_vision"
assert sub_type_map[_normalize(str(roots["controlnet"]))] == "controlnet"
assert per_key["vae"] == [_normalize(str(roots["vae"]))]
assert per_key["controlnet"] == [_normalize(str(roots["controlnet"]))]
def test_missing_or_unknown_keys_are_skipped(self, tmp_path):
vae_root = tmp_path / "vae"
vae_root.mkdir()
config = _make_config()
unique, sub_type_map, per_key = config._prepare_other_paths(
{
"vae": [str(vae_root)],
"does_not_exist_key": [str(tmp_path / "nope_dir")],
"upscale_models": [],
}
)
assert unique == [_normalize(str(vae_root))]
assert set(per_key.keys()) == {"vae"}
def test_nonexistent_paths_are_filtered(self, tmp_path):
config = _make_config()
unique, _, _ = config._prepare_other_paths(
{"vae": [str(tmp_path / "missing_vae")]}
)
assert unique == []
def test_cross_category_overlap_warns_and_keeps_first(
self, tmp_path, caplog
):
"""The same physical folder under two categories warns; first wins."""
shared = tmp_path / "shared"
shared.mkdir()
config = _make_config()
with caplog.at_level(logging.WARNING, logger=config_module.logger.name):
unique, sub_type_map, per_key = config._prepare_other_paths(
{
"vae": [str(shared)],
"upscale_models": [str(shared)],
}
)
assert unique == [_normalize(str(shared))]
assert sub_type_map[_normalize(str(shared))] == "vae"
assert "upscale_models" not in per_key
warnings = [
record.message
for record in caplog.records
if record.levelname == "WARNING"
and "multiple other-model categories" in record.message
]
assert len(warnings) == 1
def test_same_sub_type_duplicate_is_debug_not_warning(self, tmp_path, caplog):
"""A sub_type spanning two folder keys legitimately sees a folder twice.
``clip`` and ``text_encoders`` both map to ``text_encoder``, so a folder
reachable through both is expected and must not tell the user to fix a
configuration they cannot fix.
"""
text_encoders_dir = tmp_path / "text_encoders"
legacy_clip_dir = tmp_path / "clip"
text_encoders_dir.mkdir()
legacy_clip_dir.mkdir()
config = _make_config()
with caplog.at_level(logging.DEBUG, logger=config_module.logger.name):
unique, sub_type_map, per_key = config._prepare_other_paths(
{
"text_encoders": [str(text_encoders_dir)],
"clip": [str(legacy_clip_dir), str(text_encoders_dir)],
}
)
assert set(unique) == {
_normalize(str(text_encoders_dir)),
_normalize(str(legacy_clip_dir)),
}
assert sub_type_map[_normalize(str(legacy_clip_dir))] == "text_encoder"
assert per_key["clip"] == [_normalize(str(legacy_clip_dir))]
warnings = [
record.message
for record in caplog.records
if record.levelname == "WARNING"
and "multiple other-model categories" in record.message
]
assert warnings == []
debug_messages = [
record.message
for record in caplog.records
if record.levelname == "DEBUG"
and "Ignoring duplicate folder" in record.message
]
assert len(debug_messages) == 1
def test_cross_scanner_overlap_warns_but_keeps_path(self, tmp_path, caplog):
"""An other root overlapping a checkpoint root warns but stays managed."""
shared = tmp_path / "shared_models"
shared.mkdir()
config = _make_config(base_models_roots=[_normalize(str(shared))])
with caplog.at_level(logging.WARNING, logger=config_module.logger.name):
unique, sub_type_map, _ = config._prepare_other_paths(
{"vae": [str(shared)]}
)
# Kept on purpose: dropping would silently unmanage the files
assert unique == [_normalize(str(shared))]
assert sub_type_map[_normalize(str(shared))] == "vae"
warnings = [
record.message
for record in caplog.records
if record.levelname == "WARNING"
and "overlaps an existing checkpoints/embeddings root" in record.message
]
assert len(warnings) == 1
def test_no_warning_for_disjoint_roots(self, tmp_path, caplog):
checkpoints_root = tmp_path / "checkpoints"
checkpoints_root.mkdir()
vae_root = tmp_path / "vae"
vae_root.mkdir()
config = _make_config(base_models_roots=[_normalize(str(checkpoints_root))])
with caplog.at_level(logging.WARNING, logger=config_module.logger.name):
unique, _, _ = config._prepare_other_paths({"vae": [str(vae_root)]})
assert unique == [_normalize(str(vae_root))]
warnings = [
record.message
for record in caplog.records
if record.levelname == "WARNING" and "overlap" in record.message.lower()
]
assert warnings == []
class TestInitOtherPaths:
"""Config._init_other_paths with mocked folder_paths (plugin + standalone modes).
Config only depends on ``folder_paths.get_folder_paths(key)``: ComfyUI in
plugin mode, or MockFolderPaths serving ``settings.json.folder_paths`` in
standalone mode. A dict-backed stub therefore covers both.
"""
def _stub_folder_paths(self, monkeypatch, mapping):
def get_folder_paths(key):
value = mapping.get(key, [])
return [value] if isinstance(value, str) else list(value)
monkeypatch.setattr(
config_module.folder_paths, "get_folder_paths", get_folder_paths
)
def test_default_enabled_keys_exclude_opt_in_types(self, monkeypatch, tmp_path):
dirs = {}
for key in (
"vae",
"upscale_models",
"text_encoders",
"clip",
"clip_vision",
"controlnet",
):
path = tmp_path / key
path.mkdir()
dirs[key] = str(path)
self._stub_folder_paths(monkeypatch, dirs)
config = _make_config()
roots = config._init_other_paths()
# clip_vision and controlnet are workflow-driven categories and stay
# opt-in; only VAE / upscaler / text encoder are managed by default.
for key in ("clip_vision", "controlnet"):
assert _normalize(dirs[key]) not in roots
assert _normalize(dirs[key]) not in config.other_root_subtypes
# This stub has no map_legacy (standalone-shaped), so the legacy clip
# key is queried on its own and its folder lands under text_encoder.
for key in ("vae", "upscale_models", "text_encoders", "clip"):
assert _normalize(dirs[key]) in roots
def test_legacy_key_is_not_queried_when_host_aliases_it(
self, monkeypatch, tmp_path, caplog
):
"""ComfyUI resolves clip -> text_encoders, so only the canonical key is
queried: its folder list already contains the legacy directory."""
canonical_dir = tmp_path / "text_encoders"
legacy_dir = tmp_path / "clip"
canonical_dir.mkdir()
legacy_dir.mkdir()
queried = []
def get_folder_paths(key):
queried.append(key)
if key == "text_encoders":
# Mirrors ComfyUI folder_paths: both directories are registered
# under the canonical key.
return [str(canonical_dir), str(legacy_dir)]
return []
monkeypatch.setattr(
config_module.folder_paths, "get_folder_paths", get_folder_paths
)
monkeypatch.setattr(
config_module.folder_paths,
"map_legacy",
lambda key: {"clip": "text_encoders"}.get(key, key),
raising=False,
)
config = _make_config()
with caplog.at_level(logging.DEBUG, logger=config_module.logger.name):
roots = config._init_other_paths()
assert "clip" not in queried
assert _normalize(str(canonical_dir)) in roots
assert _normalize(str(legacy_dir)) in roots
assert (
config.other_root_subtypes[_normalize(str(legacy_dir))]
== "text_encoder"
)
# The reported bug: this layout used to log "please fix your path
# configuration" twice for aliased keys the user cannot separate.
assert [
record.message
for record in caplog.records
if record.levelname == "WARNING"
] == []
@pytest.mark.parametrize("opt_in_key", ["controlnet", "clip_vision"])
def test_opt_in_sub_type_via_setting(self, monkeypatch, tmp_path, opt_in_key):
opt_in_dir = tmp_path / opt_in_key
opt_in_dir.mkdir()
self._stub_folder_paths(monkeypatch, {opt_in_key: str(opt_in_dir)})
get_settings_manager().set("enabled_other_sub_types", [opt_in_key])
config = _make_config()
roots = config._init_other_paths()
assert _normalize(str(opt_in_dir)) in roots
assert (
config.other_root_subtypes[_normalize(str(opt_in_dir))] == opt_in_key
)
def test_disabled_sub_type_is_not_scanned(self, monkeypatch, tmp_path):
vae_dir = tmp_path / "vae"
upscaler_dir = tmp_path / "upscale_models"
vae_dir.mkdir()
upscaler_dir.mkdir()
self._stub_folder_paths(
monkeypatch, {"vae": str(vae_dir), "upscale_models": str(upscaler_dir)}
)
get_settings_manager().set("enabled_other_sub_types", ["vae"])
config = _make_config()
roots = config._init_other_paths()
assert roots == [_normalize(str(vae_dir))]
assert _normalize(str(upscaler_dir)) not in config.other_root_subtypes
def test_feature_disabled_scans_nothing(self, monkeypatch, tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
self._stub_folder_paths(monkeypatch, {"vae": str(vae_dir)})
get_settings_manager().set("enable_other_models", False)
config = _make_config()
roots = config._init_other_paths()
assert roots == []
assert config.other_root_subtypes == {}
assert config.other_folder_roots == {}
def test_unknown_opt_in_keys_are_ignored(self, monkeypatch, tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
self._stub_folder_paths(monkeypatch, {"vae": str(vae_dir)})
get_settings_manager().set(
"enabled_other_sub_types", ["vae", "not_a_real_key", 42]
)
config = _make_config()
roots = config._init_other_paths()
assert roots == [_normalize(str(vae_dir))]
def test_apply_library_paths_picks_up_other_keys(self, monkeypatch, tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
config = _make_config()
monkeypatch.setattr(config, "_initialize_symlink_mappings", lambda: None)
config._apply_library_paths(
{
"loras": [],
"checkpoints": [],
"unet": [],
"embeddings": [],
"vae": [str(vae_dir)],
}
)
assert config.other_roots == [_normalize(str(vae_dir))]
assert config.other_root_subtypes == {
_normalize(str(vae_dir)): "vae"
}
assert config.other_folder_roots == {"vae": [_normalize(str(vae_dir))]}
class TestOtherRootsWiring:
"""other_roots participates in symlink and preview root bookkeeping."""
def test_symlink_roots_include_other_roots(self):
config = _make_config()
config.loras_roots = ["/loras"]
config.embeddings_roots = ["/embeddings"]
config.other_roots = ["/vae"]
config.extra_loras_roots = []
config.extra_checkpoints_roots = []
config.extra_unet_roots = []
config.extra_embeddings_roots = []
assert "/vae" in config._symlink_roots()
def test_preview_roots_include_other_roots(self, tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
config = _make_config()
config.loras_roots = []
config.embeddings_roots = []
config.other_roots = [_normalize(str(vae_dir))]
config.extra_loras_roots = []
config.extra_checkpoints_roots = []
config.extra_unet_roots = []
config.extra_embeddings_roots = []
config.recipes_path = ""
config._rebuild_preview_roots()
assert config.is_preview_path_allowed(str(vae_dir / "model.preview.png"))
class TestOtherModelsAvailability:
"""Config.get_other_models_availability ignores the opt-in toggle.
It answers "could Other Models work here at all?", which the settings
payload and the announcement banner use to avoid promising a page that
cannot list anything.
"""
def _stub_folder_paths(self, monkeypatch, mapping):
def get_folder_paths(key):
value = mapping.get(key, [])
return [value] if isinstance(value, str) else list(value)
monkeypatch.setattr(
config_module.folder_paths, "get_folder_paths", get_folder_paths
)
# No host alias rewriting: every key stays independently queryable,
# which is what the standalone mock does.
monkeypatch.delattr(config_module.folder_paths, "map_legacy", raising=False)
def test_reports_available_when_a_folder_exists(self, monkeypatch, tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
self._stub_folder_paths(monkeypatch, {"vae": str(vae_dir)})
# The feature stays off on purpose: availability must not depend on it.
get_settings_manager().set("enable_other_models", False)
availability = _make_config().get_other_models_availability()
assert availability["available"] is True
assert availability["sub_types"] == {"vae": [_normalize(str(vae_dir))]}
def test_counts_an_empty_but_existing_folder(self, monkeypatch, tmp_path):
vae_dir = tmp_path / "vae"
vae_dir.mkdir()
self._stub_folder_paths(monkeypatch, {"vae": str(vae_dir)})
availability = _make_config().get_other_models_availability()
assert availability["available"] is True
def test_ignores_missing_folders(self, monkeypatch, tmp_path):
self._stub_folder_paths(
monkeypatch, {"vae": str(tmp_path / "does-not-exist")}
)
availability = _make_config().get_other_models_availability()
assert availability == {"available": False, "sub_types": {}}
def test_reports_unavailable_without_any_configuration(self, monkeypatch):
self._stub_folder_paths(monkeypatch, {})
availability = _make_config().get_other_models_availability()
assert availability == {"available": False, "sub_types": {}}
def test_merges_legacy_clip_key_into_text_encoder(self, monkeypatch, tmp_path):
clip_dir = tmp_path / "clip"
clip_dir.mkdir()
self._stub_folder_paths(monkeypatch, {"clip": [str(clip_dir)]})
availability = _make_config().get_other_models_availability()
assert availability["available"] is True
assert availability["sub_types"] == {
"text_encoder": [_normalize(str(clip_dir))]
}
+85
View File
@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
// Import order matters: api modules are circularly dependent
// (modelApiFactory -> loraApi -> baseModelApi -> modelApiFactory/state).
// Loading the factory first lets baseModelApi fully evaluate before the
// client subclasses extend it.
import { createModelApiClient, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
import {
MODEL_TYPES,
MODEL_CONFIG,
getApiEndpoints,
getCompleteApiConfig,
isValidModelType,
} from '../../../static/js/api/apiConfig.js';
import { OtherApiClient } from '../../../static/js/api/otherApi.js';
describe('apiConfig - other model type', () => {
it('exposes OTHER model type', () => {
expect(MODEL_TYPES.OTHER).toBe('other');
expect(isValidModelType('other')).toBe(true);
});
it('has a complete MODEL_CONFIG entry', () => {
const config = MODEL_CONFIG[MODEL_TYPES.OTHER];
expect(config).toBeDefined();
expect(config.singularName).toBe('other');
expect(config.supportsLetterFilter).toBe(false);
expect(config.supportsBulkOperations).toBe(true);
expect(config.supportsMove).toBe(true);
expect(config.templateName).toBe('other.html');
});
it('generates /api/lm/other/* endpoints', () => {
const endpoints = getApiEndpoints('other');
expect(endpoints.list).toBe('/api/lm/other/list');
expect(endpoints.delete).toBe('/api/lm/other/delete');
expect(endpoints.exclude).toBe('/api/lm/other/exclude');
expect(endpoints.unexclude).toBe('/api/lm/other/unexclude');
expect(endpoints.rename).toBe('/api/lm/other/rename');
expect(endpoints.save).toBe('/api/lm/other/save-metadata');
expect(endpoints.bulkDelete).toBe('/api/lm/other/bulk-delete');
expect(endpoints.moveModel).toBe('/api/lm/other/move_model');
expect(endpoints.moveBulk).toBe('/api/lm/other/move_models_bulk');
expect(endpoints.fetchCivitai).toBe('/api/lm/other/fetch-civitai');
expect(endpoints.fetchAllCivitai).toBe('/api/lm/other/fetch-all-civitai');
expect(endpoints.scan).toBe('/api/lm/other/scan');
expect(endpoints.topTags).toBe('/api/lm/other/top-tags');
expect(endpoints.baseModels).toBe('/api/lm/other/base-models');
expect(endpoints.roots).toBe('/api/lm/other/roots');
expect(endpoints.folders).toBe('/api/lm/other/folders');
expect(endpoints.duplicates).toBe('/api/lm/other/find-duplicates');
expect(endpoints.replacePreview).toBe('/api/lm/other/replace-preview');
});
it('merges other-specific endpoints into the complete config', () => {
const config = getCompleteApiConfig('other');
expect(config.modelType).toBe('other');
expect(config.config).toBe(MODEL_CONFIG.other);
expect(config.endpoints.specific.metadata).toBe('/api/lm/other/metadata');
});
});
describe('modelApiFactory - other model type', () => {
it('creates an OtherApiClient for the other model type', () => {
const client = createModelApiClient(MODEL_TYPES.OTHER);
expect(client).toBeInstanceOf(OtherApiClient);
expect(client.modelType).toBe('other');
expect(client.apiConfig.endpoints.list).toBe('/api/lm/other/list');
});
it('returns a cached singleton from getModelApiClient', () => {
const first = getModelApiClient(MODEL_TYPES.OTHER);
const second = getModelApiClient(MODEL_TYPES.OTHER);
expect(first).toBeInstanceOf(OtherApiClient);
expect(second).toBe(first);
});
it('still rejects unsupported model types', () => {
expect(() => createModelApiClient('bogus')).toThrow('Unsupported model type: bogus');
});
});
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
MODEL_CARD_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
} = vi.hoisted(() => ({
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
}));
vi.mock(STATE_MODULE, () => ({
state: {
settings: {
blur_mature_content: false,
model_name_display: 'model_name',
},
global: {
settings: {
model_name_display: 'model_name',
group_by_model: false,
display_density: 'default',
model_card_footer_action: 'example_images',
},
},
pages: {
other: {
previewVersions: new Map(),
sortBy: 'name',
},
},
bulkMode: false,
selectedModels: new Set(),
selectedLoras: new Set(),
},
getCurrentPageState: vi.fn(() => ({
sortBy: 'name',
previewVersions: new Map(),
})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitai: vi.fn(),
openHuggingFace: vi.fn(),
copyToClipboard: vi.fn(),
copyLoraSyntax: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendEmbeddingToWorkflow: vi.fn(),
openExampleImagesFolder: vi.fn(),
buildLoraSyntax: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
}));
vi.mock(API_CONFIG_MODULE, () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', OTHER: 'other' },
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({})),
}));
function makeModel(overrides = {}) {
return {
sha256: 'abc123',
file_path: '/models/loras/linked.safetensors',
model_name: 'Linked LoRA',
file_name: 'linked',
folder: '',
modified: 1234567890,
file_size: 1024,
notes: '',
base_model: '',
favorite: false,
exclude: false,
from_civitai: true,
hf_url: '',
update_available: false,
skip_metadata_refresh: false,
preview_url: '',
preview_nsfw_level: 0,
tags: [],
civitai: {},
...overrides,
};
}
function mountCard(createModelCard, model) {
document.body.innerHTML = '<div id="modelGrid"></div>';
const card = createModelCard(model, 'loras');
document.getElementById('modelGrid').appendChild(card);
return card;
}
describe('ModelCard source globe (#1094)', () => {
let createModelCard;
let setupModelCardEventDelegation;
let openCivitai;
let openHuggingFace;
beforeEach(async () => {
document.body.innerHTML = '';
({ createModelCard, setupModelCardEventDelegation } = await import(MODEL_CARD_MODULE));
({ openCivitai, openHuggingFace } = await import(UI_HELPERS_MODULE));
openCivitai.mockReset();
openHuggingFace.mockReset();
});
it('points the globe at CivitAI when CivitAI data is present alongside an HF link', () => {
const card = mountCard(
createModelCard,
makeModel({
civitai: { id: 111, modelId: 222, name: 'v1' },
hf_url: 'https://huggingface.co/user/repo',
})
);
expect(card.dataset.has_civitai).toBe('true');
expect(card.querySelector('.fa-globe').getAttribute('title')).toBe('View on Civitai');
});
it('keeps the CivitAI globe target when from_civitai is false but CivitAI data exists', () => {
// Regression for case 1: linking HF no longer hides CivitAI.
const card = mountCard(
createModelCard,
makeModel({
from_civitai: false,
civitai: { id: 111, modelId: 222 },
hf_url: 'https://huggingface.co/user/repo',
})
);
expect(card.dataset.has_civitai).toBe('true');
expect(card.querySelector('.fa-globe').getAttribute('title')).toBe('View on Civitai');
});
it('points the globe at HuggingFace for an HF-only model', () => {
const card = mountCard(
createModelCard,
makeModel({ from_civitai: false, civitai: {}, hf_url: 'https://huggingface.co/user/repo' })
);
expect(card.dataset.has_civitai).toBe('false');
expect(card.querySelector('.fa-globe').getAttribute('title')).toBe('View on Hugging Face');
});
it('disables the globe when there is no CivitAI data and no HF link', () => {
const card = mountCard(createModelCard, makeModel({ civitai: {} }));
const globe = card.querySelector('.fa-globe');
expect(card.dataset.has_civitai).toBe('false');
expect(globe.getAttribute('style')).toContain('cursor: not-allowed');
});
it('opens CivitAI when the globe is clicked on a dual-source model', () => {
const model = makeModel({
civitai: { id: 111, modelId: 222 },
hf_url: 'https://huggingface.co/user/repo',
});
const card = mountCard(createModelCard, model);
setupModelCardEventDelegation('loras');
card.querySelector('.fa-globe').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openCivitai).toHaveBeenCalledWith(model.file_path);
expect(openHuggingFace).not.toHaveBeenCalled();
});
it('opens HuggingFace when the globe is clicked on an HF-only model', () => {
const model = makeModel({
from_civitai: false,
civitai: {},
hf_url: 'https://huggingface.co/user/repo',
});
const card = mountCard(createModelCard, model);
setupModelCardEventDelegation('loras');
card.querySelector('.fa-globe').dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openHuggingFace).toHaveBeenCalledWith('https://huggingface.co/user/repo');
expect(openCivitai).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
MODEL_CARD_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
} = vi.hoisted(() => ({
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
}));
vi.mock(STATE_MODULE, () => ({
state: {
settings: {
blur_mature_content: false,
model_name_display: 'model_name',
},
global: {
settings: {
model_name_display: 'model_name',
group_by_model: false,
display_density: 'default',
model_card_footer_action: 'example_images',
},
},
pages: {
other: {
previewVersions: new Map(),
sortBy: 'name',
},
},
bulkMode: false,
selectedModels: new Set(),
selectedLoras: new Set(),
},
getCurrentPageState: vi.fn(() => ({
sortBy: 'name',
previewVersions: new Map(),
})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitai: vi.fn(),
openHuggingFace: vi.fn(),
copyToClipboard: vi.fn(),
copyLoraSyntax: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendEmbeddingToWorkflow: vi.fn(),
openExampleImagesFolder: vi.fn(),
buildLoraSyntax: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key)),
}));
vi.mock(API_CONFIG_MODULE, () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings', OTHER: 'other' },
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({})),
}));
function createOtherModel(overrides = {}) {
return {
sha256: 'abc123',
file_path: '/models/vae/test_vae.safetensors',
model_name: 'Test VAE',
file_name: 'test_vae',
folder: 'vae',
modified: 1234567890,
file_size: 1024,
notes: '',
base_model: '',
favorite: false,
exclude: false,
hf_url: '',
update_available: false,
skip_metadata_refresh: false,
preview_url: '',
preview_nsfw_level: 0,
tags: [],
civitai: {},
sub_type: 'vae',
...overrides,
};
}
describe('ModelCard sub-type badges for other model types', () => {
let createModelCard;
beforeEach(async () => {
({ createModelCard } = await import(MODEL_CARD_MODULE));
});
it.each([
['vae', 'VAE', 'VAE'],
['upscaler', 'UPS', 'Upscaler'],
['text_encoder', 'TE', 'Text Encoder'],
['clip_vision', 'CV', 'CLIP Vision'],
['controlnet', 'CN', 'ControlNet'],
])('renders the %s badge abbreviation and tooltip', (subType, abbreviation, displayName) => {
const card = createModelCard(createOtherModel({ sub_type: subType }), 'other');
const badge = card.querySelector('.model-sub-type');
expect(badge).not.toBeNull();
expect(badge.textContent).toBe(abbreviation);
const label = card.querySelector('.base-model-label');
expect(label.getAttribute('title')).toContain(displayName);
});
it('stores sub_type on the card dataset', () => {
const card = createModelCard(createOtherModel({ sub_type: 'text_encoder' }), 'other');
expect(card.dataset.sub_type).toBe('text_encoder');
});
});
@@ -0,0 +1,191 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
MODAL_MODULE,
API_FACTORY,
UI_HELPERS_MODULE,
MODAL_MANAGER_MODULE,
SHOWCASE_MODULE,
MODEL_TAGS_MODULE,
UTILS_MODULE,
TRIGGER_WORDS_MODULE,
PRESET_TAGS_MODULE,
MODEL_VERSIONS_MODULE,
RECIPE_TAB_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
MODAL_MODULE: new URL('../../../static/js/components/shared/ModelModal.js', import.meta.url).pathname,
API_FACTORY: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
MODEL_TAGS_MODULE: new URL('../../../static/js/components/shared/ModelTags.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
TRIGGER_WORDS_MODULE: new URL('../../../static/js/components/shared/TriggerWords.js', import.meta.url).pathname,
PRESET_TAGS_MODULE: new URL('../../../static/js/components/shared/PresetTags.js', import.meta.url).pathname,
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
RECIPE_TAB_MODULE: new URL('../../../static/js/components/shared/RecipeTab.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitai: vi.fn(),
copyToClipboard: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn((id, html) => {
document.body.innerHTML = `<div id="${id}">${html}</div>`;
}),
closeModal: vi.fn(),
},
}));
vi.mock(SHOWCASE_MODULE, () => ({
scrollToTop: vi.fn(),
loadExampleImages: vi.fn(),
}));
vi.mock(MODEL_TAGS_MODULE, () => ({
setupTagEditMode: vi.fn(),
}));
vi.mock(UTILS_MODULE, async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
renderCompactTags: vi.fn(() => ''),
setupTagTooltip: vi.fn(),
formatFileSize: vi.fn(() => '1 MB'),
};
});
vi.mock(TRIGGER_WORDS_MODULE, () => ({
renderTriggerWords: vi.fn(() => ''),
setupTriggerWordsEditMode: vi.fn(),
}));
vi.mock(PRESET_TAGS_MODULE, () => ({
parsePresets: vi.fn(() => ({})),
renderPresetTags: vi.fn(() => ''),
}));
vi.mock(MODEL_VERSIONS_MODULE, () => ({
initVersionsTab: vi.fn(() => ({
load: vi.fn().mockResolvedValue(undefined),
})),
}));
vi.mock(RECIPE_TAB_MODULE, () => ({
loadRecipesForModel: vi.fn(),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback || ''),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
vi.mock(API_FACTORY, () => ({
getModelApiClient: vi.fn(),
}));
function makeModel(overrides = {}) {
return {
model_name: 'Linked Model',
file_path: 'models/linked.safetensors',
file_name: 'linked.safetensors',
sha256: 'a'.repeat(64),
from_civitai: true,
civitai: {},
...overrides,
};
}
describe('Model modal source links (#1094)', () => {
beforeEach(async () => {
document.body.innerHTML = '';
const { getModelApiClient } = await import(API_FACTORY);
getModelApiClient.mockReset();
getModelApiClient.mockReturnValue({
fetchModelMetadata: vi.fn().mockResolvedValue(null),
saveModelMetadata: vi.fn(),
});
});
async function renderModal(model) {
const { showModelModal } = await import(MODAL_MODULE);
await showModelModal(model, 'loras');
}
const civitaiLink = () => document.querySelector('[data-action="view-civitai"]');
const hfLink = () => document.querySelector('[data-action="view-huggingface"]');
it('renders both links when the model has CivitAI data and an HF link', async () => {
await renderModal(
makeModel({
civitai: { id: 111, modelId: 222, name: 'v1' },
hf_url: 'https://huggingface.co/user/repo',
})
);
expect(civitaiLink()).not.toBeNull();
expect(hfLink()).not.toBeNull();
expect(hfLink().dataset.hfUrl).toBe('https://huggingface.co/user/repo');
});
it('keeps the CivitAI link after linking HF even when from_civitai is false', async () => {
// Regression for case 1: set_hf_url used to flip from_civitai to false,
// which hid the CivitAI link despite the model still having CivitAI data.
await renderModal(
makeModel({
from_civitai: false,
civitai: { id: 111, modelId: 222, name: 'v1' },
hf_url: 'https://huggingface.co/user/repo',
})
);
expect(civitaiLink()).not.toBeNull();
expect(hfLink()).not.toBeNull();
});
it('renders only the HF link for an HF-only model', async () => {
await renderModal(
makeModel({
from_civitai: false,
civitai: {},
hf_url: 'https://huggingface.co/user/repo',
})
);
expect(civitaiLink()).toBeNull();
expect(hfLink()).not.toBeNull();
});
it('renders the CivitAI link from civitai.model_id when modelId is absent', async () => {
await renderModal(
makeModel({
from_civitai: false,
civitai: { id: 111, model_id: 222 },
})
);
expect(civitaiLink()).not.toBeNull();
expect(hfLink()).toBeNull();
});
it('renders neither link when there is no CivitAI data and no HF link', async () => {
await renderModal(makeModel({ civitai: {} }));
expect(civitaiLink()).toBeNull();
expect(hfLink()).toBeNull();
});
});
@@ -0,0 +1,159 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
getModelApiClientMock,
resetAndReloadMock,
showToastMock,
sidebarManagerMock,
moveManagerMock,
showDeleteModalMock,
showExcludeModalMock,
} = vi.hoisted(() => ({
getModelApiClientMock: vi.fn(),
resetAndReloadMock: vi.fn(async () => {}),
showToastMock: vi.fn(),
sidebarManagerMock: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async function () {
sidebarManagerMock.isInitialized = true;
}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
isInitialized: false,
},
moveManagerMock: {
showMoveModal: vi.fn(),
},
showDeleteModalMock: vi.fn(),
showExcludeModalMock: vi.fn(),
}));
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
getModelApiClient: getModelApiClientMock,
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
openCivitaiByMetadata: vi.fn(),
isTypingContext: () => false,
getNSFWLevelName: vi.fn(() => 'Unknown'),
openExampleImagesFolder: vi.fn(),
}));
vi.mock('../../../static/js/managers/DownloadManager.js', () => ({
downloadManager: { showDownloadModal: vi.fn() },
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: sidebarManagerMock,
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
moveManager: moveManagerMock,
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showDeleteModal: showDeleteModalMock,
showExcludeModal: showExcludeModalMock,
}));
vi.mock('../../../static/js/components/alphabet/index.js', () => ({
createAlphabetBar: vi.fn(() => ({ destroy: vi.fn() })),
}));
vi.mock('../../../static/js/utils/updateCheckHelpers.js', () => ({
performModelUpdateCheck: vi.fn(async () => ({ status: 'success', displayName: 'Model', records: [] })),
}));
import { createPageControls } from '../../../static/js/components/controls/index.js';
import { OtherControls } from '../../../static/js/components/controls/OtherControls.js';
import { createPageContextMenu } from '../../../static/js/components/ContextMenu/index.js';
import { OtherContextMenu } from '../../../static/js/components/ContextMenu/OtherContextMenu.js';
describe('createPageControls', () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
sessionStorage.clear();
document.body.innerHTML = '';
document.body.dataset.page = 'other';
sidebarManagerMock.isInitialized = false;
});
afterEach(() => {
delete window.pageControls;
delete window.bulkManager;
});
it('creates OtherControls for the other page type', () => {
const controls = createPageControls('other');
expect(controls).toBeInstanceOf(OtherControls);
expect(controls.pageType).toBe('other');
// OtherControls registers its API with the base class
expect(typeof controls.api.loadMoreModels).toBe('function');
expect(typeof controls.api.refreshModels).toBe('function');
expect(typeof controls.api.fetchFromCivitai).toBe('function');
expect(typeof controls.api.toggleBulkMode).toBe('function');
});
it('returns null for an unknown page type', () => {
expect(createPageControls('not-a-page')).toBeNull();
});
});
describe('createPageContextMenu', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = '<div id="otherContextMenu" class="context-menu" style="display: none;"></div>';
});
function createMenuWithCard() {
const menu = createPageContextMenu('other');
const card = document.createElement('div');
card.className = 'model-card';
card.dataset.filepath = '/models/vae/test.safetensors';
document.body.appendChild(card);
menu.currentCard = card;
return { menu, card };
}
it('creates OtherContextMenu for the other page type', () => {
const menu = createPageContextMenu('other');
expect(menu).toBeInstanceOf(OtherContextMenu);
expect(menu.modelType).toBe('other');
expect(menu.menu).toBe(document.getElementById('otherContextMenu'));
});
it('returns null for an unknown page type', () => {
expect(createPageContextMenu('not-a-page')).toBeNull();
});
it('delegates refresh-metadata to the model API client', () => {
const refreshSingleModelMetadata = vi.fn();
getModelApiClientMock.mockReturnValue({ refreshSingleModelMetadata });
const { menu } = createMenuWithCard();
menu.handleMenuAction('refresh-metadata');
expect(refreshSingleModelMetadata).toHaveBeenCalledWith('/models/vae/test.safetensors');
});
it('opens the move modal for the move action', () => {
const { menu } = createMenuWithCard();
menu.handleMenuAction('move');
expect(moveManagerMock.showMoveModal).toHaveBeenCalledWith('/models/vae/test.safetensors');
});
it('shows the exclude modal for the exclude action', () => {
const { menu } = createMenuWithCard();
menu.handleMenuAction('exclude');
expect(showExcludeModalMock).toHaveBeenCalledWith('/models/vae/test.safetensors');
});
});
@@ -0,0 +1,71 @@
import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
SIDEBAR_MANAGER_MODULE,
STORAGE_HELPERS_MODULE,
MODEL_API_FACTORY_MODULE,
I18N_MODULE,
BULK_MANAGER_MODULE,
UI_HELPERS_MODULE,
UPDATE_CHECK_MODULE,
} = vi.hoisted(() => ({
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
MODEL_API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
UPDATE_CHECK_MODULE: new URL('../../../static/js/utils/updateCheckHelpers.js', import.meta.url).pathname,
}));
vi.mock(MODEL_API_FACTORY_MODULE, () => ({ getModelApiClient: vi.fn() }));
vi.mock(I18N_MODULE, () => ({ translate: (key, _args, fallback) => fallback || key }));
vi.mock(BULK_MANAGER_MODULE, () => ({ bulkManager: {} }));
vi.mock(UI_HELPERS_MODULE, () => ({ showToast: vi.fn() }));
vi.mock(UPDATE_CHECK_MODULE, () => ({ performFolderUpdateCheck: vi.fn() }));
const { SidebarManager } = await import(SIDEBAR_MANAGER_MODULE);
const { setStorageItem } = await import(STORAGE_HELPERS_MODULE);
function createManager(pageType) {
const manager = new SidebarManager();
manager.pageType = pageType;
manager.pageControls = { pageState: { searchOptions: {} } };
return manager;
}
describe('SidebarManager default visibility', () => {
beforeEach(() => {
localStorage.clear();
});
it('hides the folder sidebar by default on the other page', () => {
const manager = createManager('other');
manager.restoreSidebarState();
expect(manager.isDisabledByPage).toBe(true);
});
it('keeps the folder sidebar visible by default on the primary pages', () => {
for (const pageType of ['loras', 'checkpoints', 'embeddings', 'recipes']) {
const manager = createManager(pageType);
manager.restoreSidebarState();
expect(manager.isDisabledByPage, pageType).toBe(false);
}
});
it('lets an explicit stored preference override the other-page default', () => {
setStorageItem('other_sidebarDisabled', false);
const shown = createManager('other');
shown.restoreSidebarState();
expect(shown.isDisabledByPage).toBe(false);
setStorageItem('other_sidebarDisabled', true);
const hidden = createManager('other');
hidden.restoreSidebarState();
expect(hidden.isDisabledByPage).toBe(true);
});
});
+1 -1
View File
@@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem
import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js';
import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.js';
const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
describe('AppCore page orchestration', () => {
beforeEach(() => {
@@ -27,6 +27,14 @@ vi.mock('../../../static/js/state/index.js', () => ({
}
}));
// Mock the shared Other Models helpers (exercised by their own tests)
vi.mock('../../../static/js/utils/otherModels.js', () => ({
enableOtherModels: vi.fn().mockResolvedValue(),
openOtherModelsSettings: vi.fn(),
}));
import { enableOtherModels, openOtherModelsSettings } from '../../../static/js/utils/otherModels.js';
describe('BannerService', () => {
beforeEach(() => {
// Clear all mocks
@@ -186,6 +194,111 @@ describe('BannerService', () => {
});
});
describe('Other Models announcement', () => {
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
const prepareBanner = (dismissed = []) => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
if (key === 'dismissed_banners') {
return dismissed;
}
return defaultValue;
});
bannerService.container = document.getElementById('banner-container');
bannerService.initialized = true;
bannerService.prepareOtherModelsBanner();
};
const bannerElement = () =>
document.querySelector(`[data-banner-id="${OTHER_MODELS_BANNER_ID}"]`);
beforeEach(() => {
state.global.settings.enable_other_models = false;
state.global.settings.other_models_paths_available = true;
});
it('announces the feature while it is switched off', () => {
prepareBanner();
const element = bannerElement();
expect(element).not.toBeNull();
expect(element.querySelector('.banner-title').textContent)
.toContain('Other Models Management is available');
});
it('stays silent when the host exposes no other-model folders', () => {
// Standalone installs without the folder_paths keys in
// settings.json would land on an empty page, so do not announce.
state.global.settings.other_models_paths_available = false;
prepareBanner();
expect(bannerElement()).toBeNull();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(false);
});
it('still announces when availability is unknown (older payload)', () => {
delete state.global.settings.other_models_paths_available;
prepareBanner();
expect(bannerElement()).not.toBeNull();
});
it('stays silent once the feature is enabled', () => {
state.global.settings.enable_other_models = true;
prepareBanner();
expect(bannerElement()).toBeNull();
});
it('stays silent when it was dismissed before', () => {
prepareBanner([OTHER_MODELS_BANNER_ID]);
expect(bannerElement()).toBeNull();
});
it('enables the feature from the primary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="enable-other-models"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(enableOtherModels).toHaveBeenCalledTimes(1);
});
it('opens the settings section from the secondary action', () => {
prepareBanner();
const button = bannerElement().querySelector(
'.banner-action[data-action="open-other-models-settings"]'
);
expect(button).not.toBeNull();
button.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('can drop the announcement without dismissing it', () => {
prepareBanner();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(true);
bannerService.removeOtherModelsAnnouncement();
expect(bannerService.banners.has(OTHER_MODELS_BANNER_ID)).toBe(false);
expect(storageHelpers.setStorageItem).not.toHaveBeenCalledWith(
'dismissed_banners',
expect.arrayContaining([OTHER_MODELS_BANNER_ID])
);
});
});
describe('Banner Dismissal', () => {
it('should add banner to dismissed_banners array when dismissed', () => {
storageHelpers.getStorageItem.mockImplementation((key, defaultValue) => {
@@ -11,6 +11,7 @@ const {
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
OTHER_MODELS_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
@@ -22,6 +23,7 @@ const {
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
OTHER_MODELS_MODULE: new URL('../../../static/js/utils/otherModels.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
@@ -29,6 +31,7 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
@@ -54,8 +57,15 @@ vi.mock(I18N_HELPERS_MODULE, () => ({
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: vi.fn(),
}));
vi.mock(OTHER_MODELS_MODULE, () => ({
enableOtherModels: vi.fn(),
openOtherModelsSettings: vi.fn(),
}));
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
const { state } = await import(STATE_MODULE);
const { showActionToast } = await import(UI_HELPERS_MODULE);
const { openOtherModelsSettings } = await import(OTHER_MODELS_MODULE);
describe('DownloadManager._resolveIsDiffusionModel', () => {
let manager;
@@ -144,3 +154,214 @@ describe('DownloadManager._resolveIsDiffusionModel', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('DownloadManager._resolveOtherSubType', () => {
let manager;
let fetchMock;
beforeEach(() => {
manager = new DownloadManager();
manager.apiClient = { modelType: 'other' };
manager.selectedFile = null;
manager.selectedFiles = [];
manager.currentVersion = null;
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
function mockRoutingResponse(data, ok = true) {
fetchMock.mockResolvedValue({
ok,
status: ok ? 200 : 500,
json: async () => data,
});
}
it('returns the backend sub_type for other downloads', async () => {
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] };
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' });
expect(await manager._resolveOtherSubType()).toBe('vae');
expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model_type: 'other',
base_model: 'Flux.1 D',
file_types: ['VAE'],
}),
});
});
it('sends selected_file_type plus all version file types when a file is selected', async () => {
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] };
manager.selectedFile = { type: 'VAE' };
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' });
expect(await manager._resolveOtherSubType()).toBe('vae');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.selected_file_type).toBe('VAE');
expect(body.file_types).toEqual(['Model', 'VAE']);
});
it('omits selected_file_type when no file is selected', async () => {
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] };
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' });
await manager._resolveOtherSubType();
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect('selected_file_type' in body).toBe(false);
expect(body.file_types).toEqual(['Model', 'VAE']);
});
it('returns null when the backend cannot decide a sub_type', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] };
mockRoutingResponse({ success: true, root_kind: 'other', sub_type: null });
expect(await manager._resolveOtherSubType()).toBeNull();
});
it('offers the settings shortcut when the feature is disabled for this type', async () => {
showActionToast.mockClear();
openOtherModelsSettings.mockClear();
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
mockRoutingResponse({
success: true,
root_kind: 'other',
sub_type: null,
disabled: true,
reason: 'other_sub_type_disabled',
});
expect(await manager._resolveOtherSubType()).toBeNull();
expect(showActionToast).toHaveBeenCalledWith(
'other.disabled.downloadBlocked',
{},
'warning',
expect.objectContaining({
actionText: expect.any(String),
onAction: expect.any(Function),
}),
);
showActionToast.mock.calls.at(-1)[3].onAction();
expect(openOtherModelsSettings).toHaveBeenCalledTimes(1);
});
it('returns null when the endpoint fails', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
fetchMock.mockRejectedValue(new Error('network down'));
expect(await manager._resolveOtherSubType()).toBeNull();
});
it('returns null on a non-ok response', async () => {
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
mockRoutingResponse({}, false);
expect(await manager._resolveOtherSubType()).toBeNull();
});
it('never calls the endpoint for non-other pages', async () => {
manager.apiClient = { modelType: 'loras' };
manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'VAE' }] };
expect(await manager._resolveOtherSubType()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it('never calls the endpoint without version metadata (e.g. Hugging Face)', async () => {
expect(await manager._resolveOtherSubType()).toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('DownloadManager.proceedToLocationContent (other page)', () => {
let manager;
beforeEach(() => {
document.body.innerHTML = `
<select id="modelRoot"></select>
<label id="modelRootLabel"></label>
<input id="folderPath" />
`;
state.global.settings = {};
manager = new DownloadManager();
manager.apiClient = {
modelType: 'other',
apiConfig: { config: { displayName: 'Other Model' } },
fetchModelRoots: vi.fn(),
};
manager.selectedFile = null;
manager.selectedFiles = [];
manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] };
manager.initializeFolderTree = vi.fn().mockResolvedValue();
manager.folderTreeManager = { init: vi.fn() };
manager.loadDefaultPathSetting = vi.fn();
manager.updateTargetPath = vi.fn();
vi.spyOn(manager, '_resolveIsDiffusionModel').mockResolvedValue(false);
});
it('fetches sub_type roots and preselects the configured default root', async () => {
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue('vae');
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/vae-a', '/models/vae-b'],
});
state.global.settings.default_other_roots = { vae: '/models/vae-b' };
await manager.proceedToLocationContent();
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('vae');
const modelRoot = document.getElementById('modelRoot');
expect(Array.from(modelRoot.options).map(o => o.value)).toEqual([
'/models/vae-a',
'/models/vae-b',
]);
expect(modelRoot.value).toBe('/models/vae-b');
});
it('lists all other roots for manual selection when the sub_type is undecidable', async () => {
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue(null);
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/vae', '/models/upscale'],
});
state.global.settings.default_other_roots = { vae: '/models/upscale' };
await manager.proceedToLocationContent();
// No argument: the merged /api/lm/other/roots list
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith();
const modelRoot = document.getElementById('modelRoot');
expect(Array.from(modelRoot.options).map(o => o.value)).toEqual([
'/models/vae',
'/models/upscale',
]);
// Without a resolved sub_type no default_other_roots entry applies,
// so the first option stays selected even though a vae default exists.
expect(modelRoot.value).toBe('/models/vae');
});
it('leaves the first root selected when no default is configured for the sub_type', async () => {
vi.spyOn(manager, '_resolveOtherSubType').mockResolvedValue('upscaler');
manager.apiClient.fetchModelRoots.mockResolvedValue({
success: true,
roots: ['/models/upscale'],
});
state.global.settings.default_other_roots = { vae: '/models/vae-a' };
await manager.proceedToLocationContent();
expect(manager.apiClient.fetchModelRoots).toHaveBeenCalledWith('upscaler');
expect(document.getElementById('modelRoot').value).toBe('/models/upscale');
});
});
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
mockFolderTreeManager,
mockState,
} = vi.hoisted(() => {
const mockApiClient = {
modelType: 'loras',
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
};
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
};
const mockFolderTreeManager = {
getSelectedPath: vi.fn(() => ''),
};
const mockState = {
global: {
settings: {
download_path_templates: {},
},
},
loadingManager: mockLoadingManager,
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
mockFolderTreeManager,
mockState,
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: mockState,
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => mockFolderTreeManager),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: vi.fn(),
}));
describe('DownloadManager default-path preview', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
document.body.innerHTML = `
<select id="modelRoot"><option value="/models/vae">/models/vae</option></select>
<div id="targetPathDisplay"></div>
`;
document.getElementById('modelRoot').value = '/models/vae';
mockState.global.settings.download_path_templates = {};
});
afterEach(() => {
document.body.innerHTML = '';
});
function useOtherClient(manager) {
mockApiClient.modelType = 'other';
mockApiClient.apiConfig.config = { displayName: 'Other Model', singularName: 'other' };
manager.apiClient = mockApiClient;
}
it('renders the bare root for a flat (empty) other template', () => {
mockState.global.settings.download_path_templates = { other: '' };
const manager = new DownloadManager();
useOtherClient(manager);
manager.useDefaultPath = true;
manager.updateTargetPath();
const text = document.getElementById('targetPathDisplay').textContent;
expect(text).toBe('/models/vae');
expect(text).not.toContain('undefined');
});
it('renders the bare root when the other template key is absent', () => {
mockState.global.settings.download_path_templates = {};
const manager = new DownloadManager();
useOtherClient(manager);
manager.useDefaultPath = true;
manager.updateTargetPath();
const text = document.getElementById('targetPathDisplay').textContent;
expect(text).toBe('/models/vae');
expect(text).not.toContain('undefined');
});
it('appends a configured template to the root', () => {
mockState.global.settings.download_path_templates = { other: '{base_model}' };
const manager = new DownloadManager();
useOtherClient(manager);
manager.useDefaultPath = true;
manager.updateTargetPath();
expect(document.getElementById('targetPathDisplay').textContent).toBe('/models/vae/{base_model}');
});
it('renders the manual selection when default paths are off', () => {
mockFolderTreeManager.getSelectedPath.mockReturnValue('nested/folder');
const manager = new DownloadManager();
useOtherClient(manager);
manager.useDefaultPath = false;
manager.updateTargetPath();
expect(document.getElementById('targetPathDisplay').textContent).toBe('/models/vae/nested/folder');
});
});
@@ -24,6 +24,7 @@ vi.mock('../../../static/js/state/index.js', () => {
},
createDefaultSettings: () => ({
language: 'en',
default_other_roots: {},
}),
};
});
@@ -61,6 +62,7 @@ vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { bannerService } from '../../../static/js/managers/BannerService.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { state } from '../../../static/js/state/index.js';
@@ -502,6 +504,299 @@ describe('SettingsManager library controls', () => {
});
});
describe('SettingsManager other-model root selects', () => {
const appendOtherRootSelects = (...subTypes) => {
const selects = {};
subTypes.forEach((subType) => {
const select = document.createElement('select');
select.dataset.otherRootSubtype = subType;
document.body.appendChild(select);
selects[subType] = select;
});
return selects;
};
describe('loadOtherRoots', () => {
it('populates each sub_type select from the grouped roots and preselects defaults', async () => {
const manager = createManager();
const selects = appendOtherRootSelects('vae', 'upscaler');
selects.vae.disabled = true;
state.global.settings = {
default_other_roots: { vae: '/models/vae-b' },
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
roots_by_subtype: {
vae: ['/models/vae-a', '/models/vae-b'],
upscaler: ['/models/upscale'],
},
}),
});
await manager.loadOtherRoots();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/other/roots_by_subtype');
expect(Array.from(selects.vae.options).map(o => o.value)).toEqual([
'/models/vae-a',
'/models/vae-b',
]);
expect(selects.vae.value).toBe('/models/vae-b');
expect(selects.vae.disabled).toBe(false);
expect(Array.from(selects.upscaler.options).map(o => o.value)).toEqual([
'/models/upscale',
]);
// No configured default: first root wins
expect(selects.upscaler.value).toBe('/models/upscale');
expect(showToast).not.toHaveBeenCalled();
});
it('shows a placeholder on selects whose sub_type has no roots', async () => {
const manager = createManager();
const selects = appendOtherRootSelects('vae', 'controlnet');
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
roots_by_subtype: { vae: ['/models/vae-a'] },
}),
});
await manager.loadOtherRoots();
expect(selects.controlnet.options).toHaveLength(1);
expect(selects.controlnet.options[0].value).toBe('');
expect(selects.controlnet.options[0].textContent).toBe('No Default');
expect(selects.controlnet.disabled).toBe(true);
expect(selects.vae.disabled).toBe(false);
expect(showToast).not.toHaveBeenCalled();
});
it('shows an error toast and placeholders when the request fails', async () => {
const manager = createManager();
const selects = appendOtherRootSelects('vae', 'upscaler');
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await manager.loadOtherRoots();
expect(selects.vae.disabled).toBe(true);
expect(selects.upscaler.disabled).toBe(true);
expect(showToast).toHaveBeenCalledWith(
'toast.settings.otherRootsFailed',
expect.objectContaining({ message: expect.any(String) }),
'error',
);
});
it('does not call the API when no sub_type selects exist', async () => {
const manager = createManager();
global.fetch = vi.fn();
await manager.loadOtherRoots();
expect(global.fetch).not.toHaveBeenCalled();
});
});
describe('updateOtherModelsControls', () => {
const appendToggles = (...subTypes) => {
const container = document.createElement('div');
container.id = 'otherSubTypeToggles';
document.body.appendChild(container);
subTypes.forEach((subType) => {
const input = document.createElement('input');
input.type = 'checkbox';
input.value = subType;
input.dataset.otherSubtypeToggle = subType;
container.appendChild(input);
});
return container;
};
it('disables every toggle and select while the feature is off', () => {
const manager = createManager();
const container = appendToggles('vae', 'upscaler');
const selects = appendOtherRootSelects('vae', 'upscaler');
state.global.settings = {
enable_other_models: false,
enabled_other_sub_types: ['vae'],
};
manager.updateOtherModelsControls();
const vaeToggle = document.querySelector('[data-other-subtype-toggle="vae"]');
const upscalerToggle = document.querySelector('[data-other-subtype-toggle="upscaler"]');
expect(vaeToggle.checked).toBe(true);
expect(upscalerToggle.checked).toBe(false);
expect(vaeToggle.disabled).toBe(true);
expect(upscalerToggle.disabled).toBe(true);
expect(selects.vae.disabled).toBe(true);
expect(selects.upscaler.disabled).toBe(true);
expect(container.classList.contains('is-disabled')).toBe(true);
});
it('leaves enabled sub_types interactive and disables the rest', () => {
const manager = createManager();
const container = appendToggles('vae', 'upscaler');
const selects = appendOtherRootSelects('vae', 'upscaler');
state.global.settings = {
enable_other_models: true,
enabled_other_sub_types: ['vae'],
};
manager.updateOtherModelsControls();
const vaeToggle = document.querySelector('[data-other-subtype-toggle="vae"]');
const upscalerToggle = document.querySelector('[data-other-subtype-toggle="upscaler"]');
expect(vaeToggle.disabled).toBe(false);
expect(upscalerToggle.disabled).toBe(false);
expect(selects.vae.disabled).toBe(false);
expect(selects.upscaler.disabled).toBe(true);
expect(container.classList.contains('is-disabled')).toBe(false);
});
it('persists the checked sub_types as the whole allow-list', async () => {
const manager = createManager();
appendToggles('vae', 'upscaler', 'controlnet');
document.querySelector('[data-other-subtype-toggle="vae"]').checked = true;
document.querySelector('[data-other-subtype-toggle="controlnet"]').checked = true;
state.global.settings = {
enable_other_models: true,
enabled_other_sub_types: [],
};
const saveSpy = vi.spyOn(manager, 'saveSetting').mockResolvedValue();
const loadSpy = vi.spyOn(manager, 'loadOtherRoots').mockResolvedValue();
await manager.saveEnabledOtherSubTypes();
expect(saveSpy).toHaveBeenCalledWith('enabled_other_sub_types', [
'vae',
'controlnet',
]);
expect(loadSpy).toHaveBeenCalled();
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
expect.objectContaining({ setting: 'other model types' }),
'success',
);
});
});
describe('saveOtherRootSetting', () => {
it('read-modify-writes the default_other_roots dict and posts it whole', async () => {
const manager = createManager();
state.global.settings = {
default_other_roots: { vae: '/models/vae-a' },
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveOtherRootSetting('upscaler', '/models/upscale');
expect(state.global.settings.default_other_roots).toEqual({
vae: '/models/vae-a',
upscaler: '/models/upscale',
});
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
default_other_roots: {
vae: '/models/vae-a',
upscaler: '/models/upscale',
},
}),
});
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
expect.objectContaining({ setting: expect.any(String) }),
'success',
);
});
it('removes the sub_type key when the value is empty', async () => {
const manager = createManager();
state.global.settings = {
default_other_roots: { vae: '/models/vae-a' },
};
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveOtherRootSetting('vae', '');
expect(state.global.settings.default_other_roots).toEqual({});
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
default_other_roots: {},
});
});
it('shows an error toast when the backend save fails', async () => {
const manager = createManager();
state.global.settings = { default_other_roots: {} };
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await manager.saveOtherRootSetting('vae', '/models/vae-a');
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingSaveFailed',
expect.objectContaining({ message: expect.any(String) }),
'error',
);
});
});
});
describe('SettingsManager Other Models nav and banner sync', () => {
it('shows or hides the Other Models nav entry', () => {
const manager = createManager();
const navItem = document.createElement('a');
navItem.id = 'otherNavItem';
document.body.appendChild(navItem);
manager.updateOtherModelsNavVisibility(false);
expect(navItem.classList.contains('nav-item--hidden')).toBe(true);
manager.updateOtherModelsNavVisibility(true);
expect(navItem.classList.contains('nav-item--hidden')).toBe(false);
});
it('drops the announcement banner only when enabling', () => {
const manager = createManager();
const spy = vi
.spyOn(bannerService, 'removeOtherModelsAnnouncement')
.mockImplementation(() => {});
manager.removeOtherModelsAnnouncement(false);
expect(spy).not.toHaveBeenCalled();
manager.removeOtherModelsAnnouncement(true);
expect(spy).toHaveBeenCalledTimes(1);
spy.mockRestore();
});
});
describe('SettingsManager recipes layout switch', () => {
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
const manager = createManager();
@@ -0,0 +1,103 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
const initializeAppMock = vi.fn();
const showToastMock = vi.fn();
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
}));
describe('Other Models disabled page', () => {
const originalLocation = window.location;
let enableOtherModels;
let initializeOtherDisabledPage;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
initializeAppMock.mockResolvedValue(undefined);
document.body.innerHTML = [
'<button id="enableOtherModelsBtn"></button>',
'<button id="openOtherModelsSettingsBtn"></button>',
].join('');
Object.defineProperty(window, 'location', {
value: { ...originalLocation, reload: vi.fn() },
configurable: true,
writable: true,
});
({ enableOtherModels, initializeOtherDisabledPage } = await import(
'../../../static/js/other_disabled.js'
));
await initializeOtherDisabledPage();
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
writable: true,
});
delete global.fetch;
delete window.modalManager;
});
it('boots the shared app core so the header stays usable', () => {
expect(initializeAppMock).toHaveBeenCalledTimes(1);
});
it('opens the Library settings from the no-folders state', () => {
const showModal = vi.fn();
window.modalManager = { showModal };
document.getElementById('openOtherModelsSettingsBtn').dispatchEvent(
new MouseEvent('click', { bubbles: true }),
);
expect(showModal).toHaveBeenCalledWith('settingsModal');
});
it('enables Other Models through the settings API and reloads', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await enableOtherModels();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/settings',
expect.objectContaining({ method: 'POST' }),
);
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({
enable_other_models: true,
});
expect(window.location.reload).toHaveBeenCalledTimes(1);
expect(showToastMock).not.toHaveBeenCalled();
});
it('re-enables the button and toasts when enabling fails', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
json: async () => ({ success: false, error: 'boom' }),
});
await enableOtherModels();
expect(window.location.reload).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'other.disabled.enableFailed',
expect.objectContaining({ message: 'boom' }),
'error',
);
expect(document.getElementById('enableOtherModelsBtn').disabled).toBe(false);
});
});
+105
View File
@@ -0,0 +1,105 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderOtherPage } from '../utils/pageFixtures.js';
const initializeAppMock = vi.fn();
const initializePageFeaturesMock = vi.fn();
const createPageControlsMock = vi.fn();
const confirmDeleteMock = vi.fn();
const closeDeleteModalMock = vi.fn();
const confirmExcludeMock = vi.fn();
const closeExcludeModalMock = vi.fn();
const duplicatesManagerMock = vi.fn();
const initActiveFiltersSyncMock = vi.fn();
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
initializePageFeatures: initializePageFeaturesMock,
},
}));
vi.mock('../../../static/js/components/controls/index.js', () => ({
createPageControls: createPageControlsMock,
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
confirmDelete: confirmDeleteMock,
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
OTHER: 'other',
},
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
ModelDuplicatesManager: duplicatesManagerMock,
}));
vi.mock('../../../static/js/utils/activeFiltersSync.js', () => ({
initActiveFiltersSync: initActiveFiltersSyncMock,
}));
describe('OtherPageManager', () => {
let OtherPageManager;
let initializeOtherPage;
let duplicatesManagerInstance;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
duplicatesManagerInstance = {
checkDuplicatesCount: vi.fn(),
};
duplicatesManagerMock.mockReturnValue(duplicatesManagerInstance);
createPageControlsMock.mockReturnValue({ destroy: vi.fn() });
initializeAppMock.mockResolvedValue(undefined);
renderOtherPage();
({ OtherPageManager, initializeOtherPage } = await import('../../../static/js/other.js'));
});
afterEach(() => {
delete window.confirmDelete;
delete window.closeDeleteModal;
delete window.confirmExclude;
delete window.closeExcludeModal;
delete window.modelDuplicatesManager;
});
it('wires page controls and exposes modal helpers during construction', () => {
const manager = new OtherPageManager();
expect(createPageControlsMock).toHaveBeenCalledWith('other');
expect(duplicatesManagerMock).toHaveBeenCalledWith(manager, 'other');
expect(window.confirmDelete).toBe(confirmDeleteMock);
expect(window.closeDeleteModal).toBe(closeDeleteModalMock);
expect(window.confirmExclude).toBe(confirmExcludeMock);
expect(window.closeExcludeModal).toBe(closeExcludeModalMock);
expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
});
it('initializes shared page features and syncs active filters', async () => {
const manager = new OtherPageManager();
await manager.initialize();
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
expect(initActiveFiltersSyncMock).toHaveBeenCalledWith('other');
});
it('boots the other models page through the initializer', async () => {
const manager = await initializeOtherPage();
expect(initializeAppMock).toHaveBeenCalledTimes(1);
expect(manager).toBeInstanceOf(OtherPageManager);
expect(window.modelDuplicatesManager).toBe(duplicatesManagerInstance);
});
});
+12
View File
@@ -36,6 +36,18 @@ export function renderEmbeddingsPage() {
});
}
/**
* Renders the Other Models page template with expected dataset attributes.
* @returns {Element}
*/
export function renderOtherPage() {
return renderTemplate('other.html', {
dataset: {
page: 'other',
},
});
}
/**
* Renders the Recipes page template with expected dataset attributes.
* @returns {Element}
@@ -29,6 +29,7 @@
'civitai_api_key_set': True,
'language': 'en',
'llm_api_key_set': False,
'other_models_paths_available': False,
'theme': 'dark',
}),
'success': True,
@@ -5,6 +5,22 @@ import json
import pytest
from py.routes.handlers.download_routing_handlers import DownloadRoutingHandler
from py.services.settings_manager import get_settings_manager
@pytest.fixture(autouse=True)
def enable_other_models():
"""Other Models is opt-in; enable every sub_type for the routing tests."""
manager = get_settings_manager()
manager.settings["enable_other_models"] = True
manager.settings["enabled_other_sub_types"] = [
"vae",
"upscaler",
"text_encoder",
"clip_vision",
"controlnet",
]
yield
class FakeRequest:
@@ -91,3 +107,90 @@ async def test_invalid_json_rejected():
FakeRequest(json.JSONDecodeError("bad", "", 0))
)
assert response.status == 400
@pytest.mark.asyncio
async def test_other_model_type_returns_sub_type():
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest({"model_type": "TextEncoder", "file_types": ["Model"]})
)
payload = json.loads(response.text)
assert response.status == 200
assert payload == {"success": True, "root_kind": "other", "sub_type": "text_encoder"}
@pytest.mark.asyncio
async def test_other_explicit_file_pick_wins():
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest(
{
"model_type": "Other",
"file_types": ["Model"],
"selected_file_type": "VAE",
}
)
)
payload = json.loads(response.text)
assert payload["root_kind"] == "other"
assert payload["sub_type"] == "vae"
@pytest.mark.asyncio
async def test_other_file_type_fallback_when_model_type_unmapped():
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest({"model_type": "Other", "file_types": ["Model", "Upscaler"]})
)
payload = json.loads(response.text)
assert payload["sub_type"] == "upscaler"
@pytest.mark.asyncio
async def test_other_undecidable_sub_type_is_none():
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest({"model_type": "Other", "file_types": ["Model"]})
)
payload = json.loads(response.text)
assert response.status == 200
assert payload == {"success": True, "root_kind": "other", "sub_type": None}
@pytest.mark.asyncio
async def test_other_invalid_selected_file_type_rejected():
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest({"model_type": "VAE", "selected_file_type": 123})
)
assert response.status == 400
@pytest.mark.asyncio
async def test_other_routing_disabled_when_feature_off():
get_settings_manager().settings["enable_other_models"] = False
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest({"model_type": "VAE", "file_types": ["Model"]})
)
payload = json.loads(response.text)
assert payload["sub_type"] is None
assert payload["disabled"] is True
assert payload["reason"] == "other_models_disabled"
@pytest.mark.asyncio
async def test_other_routing_disabled_for_switched_off_sub_type():
get_settings_manager().settings["enabled_other_sub_types"] = ["vae"]
handler = DownloadRoutingHandler()
response = await handler.get_download_routing(
FakeRequest({"model_type": "Upscaler", "file_types": ["Model"]})
)
payload = json.loads(response.text)
assert payload["sub_type"] is None
assert payload["disabled"] is True
assert payload["reason"] == "other_sub_type_disabled"
assert payload["requested_sub_type"] == "upscaler"
+156
View File
@@ -0,0 +1,156 @@
"""Tests for the HuggingFace link handler (``set_hf_url``).
Regression coverage for issue #1094: linking a model to HuggingFace must not
clear its CivitAI provenance or metadata, so both "View on CivitAI" and
"View on Hugging Face" can coexist.
"""
from __future__ import annotations
import json
import os
from typing import Any
from unittest.mock import AsyncMock
import pytest
from py.routes.handlers import hf_handlers
from py.routes.handlers.hf_handlers import HfHandler
from py.utils.metadata_manager import MetadataManager
def _json_payload(response) -> dict[str, Any]:
assert response.text is not None
return json.loads(response.text)
class FakeRequest:
def __init__(self, *, json_data=None):
self._json_data = json_data or {}
async def json(self):
return self._json_data
def _sidecar_path(model_path) -> str:
return f"{os.path.splitext(str(model_path))[0]}.metadata.json"
@pytest.fixture
def hf_env(tmp_path, monkeypatch):
"""Point HF linking at *tmp_path* and stub the scanner cache write."""
monkeypatch.setattr(hf_handlers, "_find_matching_root", lambda _dir: str(tmp_path))
cache_write = AsyncMock()
monkeypatch.setattr(hf_handlers, "_add_to_scanner_cache", cache_write)
return {"root": tmp_path, "cache_write": cache_write}
async def _write_model(model_path, payload: dict[str, Any]) -> None:
model_path.write_bytes(b"x" * 32)
await MetadataManager.save_metadata(str(model_path), payload)
@pytest.mark.asyncio
async def test_set_hf_url_keeps_civitai_metadata_and_provenance(tmp_path, hf_env):
model_path = tmp_path / "civitai_model.safetensors"
await _write_model(
model_path,
{
"file_name": "civitai_model",
"model_name": "CivitAI Model",
"file_path": str(model_path),
"size": 32,
"modified": 1.0,
"sha256": "a" * 64,
"base_model": "SDXL 1.0",
"preview_url": "",
"from_civitai": True,
"civitai": {"id": 111, "modelId": 222, "name": "v1", "trainedWords": []},
},
)
response = await HfHandler().set_hf_url(
FakeRequest(
json_data={
"file_path": str(model_path),
"hf_url": "https://huggingface.co/user/repo",
}
)
)
assert response.status == 200
assert _json_payload(response)["success"] is True
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["hf_url"] == "https://huggingface.co/user/repo"
# Linking HF must not erase the model's CivitAI provenance or data.
assert saved["from_civitai"] is True
assert saved["civitai"]["modelId"] == 222
assert saved["civitai"]["id"] == 111
hf_env["cache_write"].assert_awaited_once()
cached_metadata = hf_env["cache_write"].await_args.args[1]
assert cached_metadata["hf_url"] == "https://huggingface.co/user/repo"
assert cached_metadata["from_civitai"] is True
assert cached_metadata["civitai"]["modelId"] == 222
@pytest.mark.asyncio
async def test_set_hf_url_does_not_force_from_civitai_false(tmp_path, hf_env):
"""A model without CivitAI data keeps its existing provenance flag."""
model_path = tmp_path / "hf_only.safetensors"
await _write_model(
model_path,
{
"file_name": "hf_only",
"model_name": "HF Only",
"file_path": str(model_path),
"size": 32,
"modified": 1.0,
"sha256": "b" * 64,
"base_model": "Unknown",
"preview_url": "",
"from_civitai": True,
},
)
response = await HfHandler().set_hf_url(
FakeRequest(
json_data={
"file_path": str(model_path),
"hf_url": "https://huggingface.co/user/repo",
}
)
)
assert response.status == 200
saved = json.loads(open(_sidecar_path(model_path), encoding="utf-8").read())
assert saved["hf_url"] == "https://huggingface.co/user/repo"
assert saved["from_civitai"] is True
@pytest.mark.asyncio
async def test_set_hf_url_rejects_non_repo_url(tmp_path, hf_env):
model_path = tmp_path / "model.safetensors"
await _write_model(
model_path,
{
"file_name": "model",
"model_name": "model",
"file_path": str(model_path),
"size": 32,
"modified": 1.0,
"sha256": "c" * 64,
"base_model": "Unknown",
"preview_url": "",
},
)
response = await HfHandler().set_hf_url(
FakeRequest(json_data={"file_path": str(model_path), "hf_url": "https://example.com/x"})
)
assert response.status == 400
payload = _json_payload(response)
assert payload["success"] is False
hf_env["cache_write"].assert_not_awaited()
+4 -2
View File
@@ -132,6 +132,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
"lora": _DummyScanner("lora"),
"checkpoint": _DummyScanner("checkpoint"),
"embedding": _DummyScanner("embedding"),
"other": _DummyScanner("other"),
"recipe": _DummyScanner("recipe"),
}
@@ -147,6 +148,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_lora_scanner", lambda: _stub("lora_scanner", scanners["lora"]))
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_checkpoint_scanner", lambda: _stub("checkpoint_scanner", scanners["checkpoint"]))
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_embedding_scanner", lambda: _stub("embedding_scanner", scanners["embedding"]))
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_other_scanner", lambda: _stub("other_scanner", scanners["other"]))
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_recipe_scanner", lambda: _stub("recipe_scanner", scanners["recipe"]))
migration_calls: list[bool] = []
@@ -205,7 +207,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
await asyncio.gather(*pending)
task_names = {task.get_name() for task in scheduled_tasks}
assert {"lora_cache_init", "checkpoint_cache_init", "embedding_cache_init", "recipe_cache_init", "post_init_tasks", "cleanup_bak_files"}.issubset(task_names)
assert {"lora_cache_init", "checkpoint_cache_init", "embedding_cache_init", "other_cache_init", "recipe_cache_init", "post_init_tasks", "cleanup_bak_files"}.issubset(task_names)
# Startup sweep: an expired pending-delete purge task is spawned during
# service initialization (covers both plugin and standalone modes).
@@ -219,4 +221,4 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
for root in (loras_root, checkpoints_root, embeddings_root):
assert not any(path.suffix == ".bak" for path in root.rglob("*")), f"Backup files remain in {root}"
assert {"civitai_client", "download_manager", "websocket_manager", "lora_scanner", "checkpoint_scanner", "embedding_scanner", "recipe_scanner"}.issubset(registry_calls)
assert {"civitai_client", "download_manager", "websocket_manager", "lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner", "recipe_scanner"}.issubset(registry_calls)
+310 -3
View File
@@ -61,6 +61,12 @@ class DummySettings:
def get(self, key, default=None):
return self.data.get(key, default)
def is_other_models_enabled(self):
return bool(self.data.get("enable_other_models", False))
def get_enabled_other_sub_types(self):
return list(self.data.get("enabled_other_sub_types") or [])
def set(self, key, value):
self.data[key] = value
@@ -1133,8 +1139,8 @@ async def test_get_civitai_user_models_marks_library_versions():
},
{
"id": 4,
"name": "Unsupported",
"type": "Other",
"name": "VAE Model",
"type": "VAE",
"modelVersions": [
{
"id": 400,
@@ -1142,6 +1148,17 @@ async def test_get_civitai_user_models_marks_library_versions():
}
],
},
{
"id": 5,
"name": "Unsupported",
"type": "Wildcard",
"modelVersions": [
{
"id": 500,
"name": "v1",
}
],
},
]
provider = FakeUserModelsProvider(models)
@@ -1152,6 +1169,7 @@ async def test_get_civitai_user_models_marks_library_versions():
lora_scanner = FakeExistenceScanner({101})
checkpoint_scanner = FakeExistenceScanner()
embedding_scanner = FakeExistenceScanner({202})
other_scanner = FakeExistenceScanner({400})
async def lora_factory():
return lora_scanner
@@ -1162,11 +1180,15 @@ async def test_get_civitai_user_models_marks_library_versions():
async def embedding_factory():
return embedding_scanner
async def other_factory():
return other_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=lora_factory,
get_checkpoint_scanner=checkpoint_factory,
get_embedding_scanner=embedding_factory,
get_other_scanner=other_factory,
get_downloaded_version_history_service=lambda: fake_download_history_service_factory(),
),
metadata_provider_factory=provider_factory,
@@ -1240,6 +1262,18 @@ async def test_get_civitai_user_models_marks_library_versions():
"inLibrary": False,
"hasBeenDownloaded": False,
},
{
"modelId": 4,
"versionId": 400,
"modelName": "VAE Model",
"versionName": "v1",
"type": "VAE",
"tags": [],
"baseModel": None,
"thumbnailUrl": None,
"inLibrary": True,
"hasBeenDownloaded": False,
},
]
assert provider.received_usernames == ["pixel"]
@@ -1351,7 +1385,7 @@ async def test_get_civitai_user_models_returns_pagination_fields():
{
"id": 2,
"name": "Unsupported",
"type": "Other",
"type": "Wildcard",
"modelVersions": [{"id": 200, "name": "v1"}],
},
]
@@ -1741,6 +1775,279 @@ async def test_model_version_download_status_endpoints():
}
class OtherRecordingScanner:
"""Other-scanner stub recording both probe kinds."""
def __init__(self, versions_by_model_id=None, version_ids=()):
self.versions_by_model_id = versions_by_model_id or {}
self.version_ids = set(version_ids)
self.version_calls: list[int] = []
async def get_model_versions_by_id(self, model_id):
self.version_calls.append(model_id)
return list(self.versions_by_model_id.get(model_id, []))
async def check_model_version_exists(self, version_id):
return version_id in self.version_ids
def _set_other_models_enabled(enabled: bool) -> None:
from py.services.settings_manager import get_settings_manager
get_settings_manager().set("enable_other_models", enabled)
@pytest.mark.asyncio
async def test_check_model_exists_with_other_models_enabled():
"""An other-type version resolves through the other scanner when opted in."""
_set_other_models_enabled(True)
other_scanner = OtherRecordingScanner(version_ids={400})
async def other_factory():
return other_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=other_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.check_model_exists(
FakeRequest(query={"modelId": "5", "modelVersionId": "400"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert payload == {
"success": True,
"exists": True,
"modelType": "other",
"hasBeenDownloaded": False,
"downloadedFiles": [],
}
@pytest.mark.asyncio
async def test_check_model_exists_skips_other_scanner_when_disabled():
"""Opt-out stays byte-identical: no other probe, modelType stays null."""
_set_other_models_enabled(False)
other_scanner = OtherRecordingScanner(versions_by_model_id={5: [{"versionId": 400}]})
async def other_factory():
return other_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=other_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.check_model_exists(
FakeRequest(query={"modelId": "5"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert payload == {
"success": True,
"modelType": None,
"versions": [],
"downloadedVersionIds": [],
}
assert other_scanner.version_calls == []
@pytest.mark.asyncio
async def test_check_models_exist_resolves_other_ids():
"""Mixed lora + vae ids resolve independently in the batch endpoint."""
_set_other_models_enabled(True)
lora_scanner = OtherRecordingScanner(
versions_by_model_id={5: [{"versionId": 11, "name": "v1"}]}
)
other_scanner = OtherRecordingScanner(
versions_by_model_id={6: [{"versionId": 400, "name": "vae-v1"}]}
)
async def lora_factory():
return lora_scanner
async def other_factory():
return other_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=lora_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=other_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.check_models_exist(
FakeRequest(query={"modelIds": "5,6"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert payload["success"] is True
results = {item["modelId"]: item for item in payload["results"]}
assert results[5]["modelType"] == "lora"
assert results[5]["versions"] == [
{"versionId": 11, "name": "v1", "hasBeenDownloaded": True}
]
assert results[6]["modelType"] == "other"
assert results[6]["versions"] == [
{"versionId": 400, "name": "vae-v1", "hasBeenDownloaded": True}
]
@pytest.mark.asyncio
async def test_check_models_exist_ignores_other_scanner_when_disabled():
_set_other_models_enabled(False)
other_scanner = OtherRecordingScanner(versions_by_model_id={5: [{"versionId": 400}]})
async def other_factory():
return other_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=other_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.check_models_exist(
FakeRequest(query={"modelIds": "6"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert payload["results"] == [
{
"modelId": 6,
"modelType": None,
"versions": [],
"downloadedVersionIds": [],
}
]
assert other_scanner.version_calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("model_type", ["vae", "textencoder", "clip", "other"])
async def test_model_version_download_status_accepts_other_types_when_enabled(
model_type,
):
_set_other_models_enabled(True)
history_service = FakeDownloadHistoryService()
async def history_factory():
return history_service
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=fake_scanner_factory,
get_downloaded_version_history_service=history_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.get_model_version_download_status(
FakeRequest( # pyright: ignore[reportArgumentType]
query={"modelType": model_type, "modelVersionId": "400"}
)
)
payload = _json_payload(response)
assert response.status == 200
assert payload == {
"success": True,
"modelType": "other",
"modelVersionId": 400,
"hasBeenDownloaded": False,
}
@pytest.mark.asyncio
@pytest.mark.parametrize("model_type", ["vae", "textencoder", "clip", "other"])
async def test_model_version_download_status_rejects_other_types_when_disabled(
model_type,
):
_set_other_models_enabled(False)
async def history_factory():
return FakeDownloadHistoryService()
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=fake_scanner_factory,
get_downloaded_version_history_service=history_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.get_model_version_download_status(
FakeRequest( # pyright: ignore[reportArgumentType]
query={"modelType": model_type, "modelVersionId": "400"}
)
)
payload = _json_payload(response)
assert response.status == 400
assert payload == {
"success": False,
"error": "Parameter modelType is required",
}
@pytest.mark.asyncio
async def test_model_version_download_status_rejects_unknown_type():
"""Regression: garbage modelType keeps the legacy 400 error."""
_set_other_models_enabled(True)
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=fake_scanner_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_other_scanner=fake_scanner_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.get_model_version_download_status(
FakeRequest( # pyright: ignore[reportArgumentType]
query={"modelType": "garbage", "modelVersionId": "400"}
)
)
payload = _json_payload(response)
assert response.status == 400
assert payload == {
"success": False,
"error": "Parameter modelType is required",
}
def test_create_handler_set_uses_provided_dependencies():
recorded_handlers: list[dict[str, Any]] = []
+222
View File
@@ -0,0 +1,222 @@
import json
import pytest
from aiohttp import web
from py.routes.other_routes import OtherRoutes
from py.services.other_model_service import OtherModelService
class DummyRequest:
def __init__(self, *, match_info=None):
self.match_info = match_info or {}
class StubOtherModelService:
def __init__(self):
self.info = {}
async def get_model_info_by_name(self, name):
value = self.info.get(name)
if isinstance(value, Exception):
raise value
return value
@pytest.fixture
def routes():
handler = OtherRoutes()
handler.service = StubOtherModelService() # pyright: ignore[reportAttributeAccessIssue]
return handler
@pytest.fixture(autouse=True)
def enable_other_models():
"""Other Models is opt-in; these tests exercise the enabled state."""
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
manager.set("enable_other_models", True)
manager.set(
"enabled_other_sub_types",
["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"],
)
yield
def test_common_and_specific_routes_registered():
"""Registration smoke test: /api/lm/other/* surface plus the /other page."""
app = web.Application()
OtherRoutes().setup_routes(app)
registered = {(route.method, route.resource.canonical) for route in app.router.routes()}
assert ("GET", "/other") in registered
assert ("GET", "/api/lm/other/list") in registered
assert ("GET", "/api/lm/other/model-types") in registered
assert ("GET", "/api/lm/other/roots") in registered
assert ("POST", "/api/lm/other/fetch-civitai") in registered
assert ("POST", "/api/lm/other/delete") in registered
assert ("POST", "/api/lm/other/move_model") in registered
assert ("GET", "/api/lm/other/info/{name}") in registered
def test_template_name_is_other_page():
assert OtherRoutes().template_name == "other.html"
@pytest.mark.parametrize(
"model_type",
["VAE", "Upscaler", "TextEncoder", "CLIP", "CLIPVision", "Controlnet", "Other"],
)
def test_validate_civitai_model_type_accepts_other_types(model_type):
assert OtherRoutes()._validate_civitai_model_type(model_type) is True
@pytest.mark.parametrize("model_type", ["Lora", "Checkpoint", "TextualInversion"])
def test_validate_civitai_model_type_rejects_foreign_types(model_type):
assert OtherRoutes()._validate_civitai_model_type(model_type) is False
def test_validate_rejects_everything_when_feature_disabled():
from py.services.settings_manager import get_settings_manager
get_settings_manager().set("enable_other_models", False)
handler = OtherRoutes()
for model_type in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Other"):
assert handler._validate_civitai_model_type(model_type) is False
def test_validate_rejects_switched_off_sub_type():
from py.services.settings_manager import get_settings_manager
get_settings_manager().set("enabled_other_sub_types", ["vae"])
handler = OtherRoutes()
assert handler._validate_civitai_model_type("VAE") is True
assert handler._validate_civitai_model_type("Upscaler") is False
def test_page_context_reports_feature_state(monkeypatch):
from py.config import config
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
handler = OtherRoutes()
provider = handler._get_page_context_provider()
monkeypatch.setattr(config, "other_roots", ["/models/vae"], raising=False)
context = provider(None)
assert context["other_disabled"] is False
assert context["other_no_paths"] is False
# Enabled but nothing resolved: the page must explain how to fix it.
monkeypatch.setattr(config, "other_roots", [], raising=False)
context = provider(None)
assert context["other_disabled"] is False
assert context["other_no_paths"] is True
manager.set("enable_other_models", False)
assert provider(None) == {"other_disabled": True, "other_no_paths": False}
def test_get_expected_model_types_mentions_supported_types():
expected = OtherRoutes()._get_expected_model_types()
for name in ("VAE", "Upscaler", "TextEncoder", "CLIPVision", "Controlnet"):
assert name in expected
async def test_get_other_model_info_success(routes):
routes.service.info["demo"] = {"name": "demo"}
response = await routes.get_other_model_info(DummyRequest(match_info={"name": "demo"}))
payload = json.loads(response.text)
assert payload == {"name": "demo"}
async def test_get_other_model_info_missing(routes):
response = await routes.get_other_model_info(DummyRequest(match_info={"name": "missing"}))
payload = json.loads(response.text)
assert response.status == 404
assert payload == {"error": "Model not found"}
async def test_get_other_model_info_error(routes):
routes.service.info["demo"] = RuntimeError("boom")
response = await routes.get_other_model_info(DummyRequest(match_info={"name": "demo"}))
payload = json.loads(response.text)
assert response.status == 500
assert payload == {"error": "boom"}
@pytest.mark.asyncio
async def test_initialize_services_builds_other_model_service(monkeypatch):
from py.services.service_registry import ServiceRegistry
sentinel_scanner = object()
sentinel_update_service = object()
async def fake_scanner():
return sentinel_scanner
async def fake_update_service():
return sentinel_update_service
monkeypatch.setattr(ServiceRegistry, "get_other_scanner", staticmethod(fake_scanner))
monkeypatch.setattr(
ServiceRegistry, "get_model_update_service", staticmethod(fake_update_service)
)
handler = OtherRoutes()
await handler.initialize_services()
assert isinstance(handler.service, OtherModelService)
assert handler.service.model_type == "other"
assert handler.service.scanner is sentinel_scanner
def test_roots_by_subtype_route_registered():
app = web.Application()
OtherRoutes().setup_routes(app)
registered = {(route.method, route.resource.canonical) for route in app.router.routes()}
assert ("GET", "/api/lm/other/roots_by_subtype") in registered
async def test_get_roots_by_subtype_aggregates_folder_keys(monkeypatch):
"""text_encoders and the legacy clip key both land under text_encoder."""
from py.config import config
monkeypatch.setattr(
config,
"other_folder_roots",
{
"vae": ["/models/vae", "/models/vae2"],
"text_encoders": ["/models/text_encoders"],
"clip": ["/models/clip_legacy"],
"upscale_models": ["/models/upscale"],
"unknown_key": ["/models/ignored"],
},
)
response = await OtherRoutes().get_roots_by_subtype(DummyRequest())
payload = json.loads(response.text)
assert payload["success"] is True
assert payload["roots_by_subtype"] == {
"vae": ["/models/vae", "/models/vae2"],
"text_encoder": ["/models/text_encoders", "/models/clip_legacy"],
"upscaler": ["/models/upscale"],
}
async def test_get_roots_by_subtype_empty_config(monkeypatch):
from py.config import config
monkeypatch.setattr(config, "other_folder_roots", {})
response = await OtherRoutes().get_roots_by_subtype(DummyRequest())
payload = json.loads(response.text)
assert payload == {"success": True, "roots_by_subtype": {}}
@@ -0,0 +1,700 @@
"""DownloadManager support for the "other" model type (VAE, upscaler, ...).
Covers the Phase-2 scatter points from docs/plans/other-models-page.md §9.1:
type map acceptance, existence gates consulting the other scanner (never
falling through to the lora scanner), per-sub_type default roots, resume
metadata and the archive extension set.
"""
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from py.services import aria2_transfer_state
from py.services import download_manager
from py.services.download_manager import DownloadManager
from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import SettingsManager, get_settings_manager
@pytest.fixture(autouse=True)
def reset_download_manager():
"""Ensure each test operates on a fresh singleton."""
DownloadManager._instance = None
yield
DownloadManager._instance = None
@pytest.fixture(autouse=True)
def isolate_settings(monkeypatch, tmp_path):
"""Point settings writes at a temporary directory to avoid touching real files."""
manager = get_settings_manager()
default_settings = manager._get_default_settings()
default_settings.update(
{
"default_lora_root": str(tmp_path / "loras"),
"default_checkpoint_root": str(tmp_path / "checkpoints"),
"default_embedding_root": str(tmp_path / "embeddings"),
"default_other_roots": {
"vae": str(tmp_path / "vae"),
"upscaler": str(tmp_path / "upscale_models"),
"text_encoder": str(tmp_path / "text_encoders"),
"clip_vision": str(tmp_path / "clip_vision"),
},
"enable_other_models": True,
"enabled_other_sub_types": [
"vae",
"upscaler",
"text_encoder",
"clip_vision",
"controlnet",
],
"download_path_templates": {
"lora": "{base_model}/{first_tag}",
"checkpoint": "{base_model}/{first_tag}",
"embedding": "{base_model}/{first_tag}",
"other": "",
},
"skip_previously_downloaded_model_versions": False,
"download_skip_base_models": [],
}
)
monkeypatch.setattr(manager, "settings", default_settings)
monkeypatch.setattr(SettingsManager, "_save_settings", lambda self: None)
@pytest.fixture(autouse=True)
def isolate_aria2_state(monkeypatch, tmp_path):
state_path = tmp_path / "cache" / "aria2" / "downloads.json"
monkeypatch.setattr(
aria2_transfer_state,
"get_aria2_state_path",
lambda: str(state_path),
)
@pytest.fixture(autouse=True)
def stub_metadata(monkeypatch):
class _StubMetadata:
def __init__(self, save_path: str):
self.file_path = save_path
self.sha256 = "sha256"
self.file_name = Path(save_path).stem
def _make_class(name):
@staticmethod
def from_civitai_info(_version_info, _file_info, save_path):
metadata = _StubMetadata(save_path)
metadata.metadata_class = name
return metadata
return type(name, (), {"from_civitai_info": from_civitai_info})
monkeypatch.setattr(download_manager, "LoraMetadata", _make_class("LoraMetadata"))
monkeypatch.setattr(
download_manager, "CheckpointMetadata", _make_class("CheckpointMetadata")
)
monkeypatch.setattr(
download_manager, "EmbeddingMetadata", _make_class("EmbeddingMetadata")
)
monkeypatch.setattr(
download_manager, "OtherModelMetadata", _make_class("OtherModelMetadata")
)
class DummyScanner:
def __init__(self, exists: bool = False, raw_data=None):
self.exists = exists
self.calls = []
self._cache = SimpleNamespace(raw_data=list(raw_data or []))
async def check_model_version_exists(self, version_id):
self.calls.append(version_id)
return self.exists
async def get_cached_data(self):
return self._cache
@pytest.fixture
def scanners(monkeypatch):
lora_scanner = DummyScanner()
checkpoint_scanner = DummyScanner()
embedding_scanner = DummyScanner()
other_scanner = DummyScanner()
monkeypatch.setattr(
ServiceRegistry, "get_lora_scanner", AsyncMock(return_value=lora_scanner)
)
monkeypatch.setattr(
ServiceRegistry,
"get_checkpoint_scanner",
AsyncMock(return_value=checkpoint_scanner),
)
monkeypatch.setattr(
ServiceRegistry,
"get_embedding_scanner",
AsyncMock(return_value=embedding_scanner),
)
monkeypatch.setattr(
ServiceRegistry,
"get_other_scanner",
AsyncMock(return_value=other_scanner),
)
return SimpleNamespace(
lora=lora_scanner,
checkpoint=checkpoint_scanner,
embedding=embedding_scanner,
other=other_scanner,
)
def _other_payload(civitai_type: str, *, files=None) -> dict:
return {
"id": 42,
"model": {"type": civitai_type, "tags": ["utility"]},
"baseModel": "SDXL 1.0",
"creator": {"username": "Author"},
"files": files
or [
{
"type": "Model",
"primary": True,
"downloadUrl": "https://example.invalid/file.safetensors",
"name": "file.safetensors",
}
],
}
@pytest.fixture
def metadata_provider(monkeypatch):
class DummyProvider:
def __init__(self):
self.calls = []
self.payload = _other_payload("VAE")
async def get_model_version(self, model_id, model_version_id):
self.calls.append((model_id, model_version_id))
return self.payload
provider = DummyProvider()
monkeypatch.setattr(
download_manager,
"get_default_metadata_provider",
AsyncMock(return_value=provider),
)
return provider
def _capture_execute(monkeypatch, captured):
async def fake_execute_download(self, **kwargs):
captured.update(kwargs)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"civitai_type",
["VAE", "Upscaler", "TextEncoder", "CLIP", "CLIPVision", "Controlnet", "Other"],
)
async def test_download_accepts_other_model_types(
monkeypatch, scanners, metadata_provider, tmp_path, civitai_type
):
"""All VALID_OTHER_CIVITAI_TYPES route to model_type 'other'."""
metadata_provider.payload = _other_payload(civitai_type)
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is True
assert captured["model_type"] == "other"
assert captured["metadata"].metadata_class == "OtherModelMetadata"
@pytest.mark.asyncio
async def test_download_rejects_unknown_model_type(
monkeypatch, scanners, metadata_provider, tmp_path
):
metadata_provider.payload = _other_payload("Workflow")
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is False
assert result["error"].startswith("Model type")
@pytest.mark.asyncio
async def test_download_rejects_other_when_feature_disabled(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""The opt-in feature is off: no other-type download is accepted."""
metadata_provider.payload = _other_payload("VAE")
get_settings_manager().settings["enable_other_models"] = False
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
assert result["success"] is False
assert "disabled" in result["error"].lower()
assert result["reason"] == "other_models_disabled"
@pytest.mark.asyncio
async def test_default_paths_reject_switched_off_sub_type(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A disabled sub_type refuses default-path routing (manual pick still works)."""
metadata_provider.payload = _other_payload("VAE")
get_settings_manager().settings["enabled_other_sub_types"] = ["upscaler"]
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is False
assert "disabled" in result["error"].lower()
assert result["reason"] == "other_sub_type_disabled"
@pytest.mark.asyncio
async def test_early_gate_checks_other_scanner(
monkeypatch, scanners, metadata_provider, tmp_path
):
scanners.other.exists = True
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=101, save_dir=str(tmp_path)
)
assert result["success"] is False
assert result["error"] == "Model version already exists in other library"
assert scanners.other.calls == [101]
@pytest.mark.asyncio
async def test_scanner_dispatch_has_no_lora_fall_through(scanners):
"""The Phase-2 trap: 'other' must reach the other scanner explicitly, and
unknown types must raise instead of silently deduping against loras."""
manager = DownloadManager()
scanner = await manager._get_scanner_for_model_type("other")
assert scanner is scanners.other
scanner = await manager._get_scanner_for_model_type("lora")
assert scanner is scanners.lora
with pytest.raises(ValueError):
await manager._get_scanner_for_model_type("bogus")
@pytest.mark.asyncio
async def test_explicit_file_gate_uses_other_scanner_not_lora(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A matching local entry in the LORA library must not block an 'other'
download; only the other scanner's library is consulted."""
local_entry = {
"file_name": "file",
"sha256": "deadbeef",
"civitai": {"id": 42},
}
scanners.lora._cache = SimpleNamespace(raw_data=[dict(local_entry)])
scanners.other._cache = SimpleNamespace(raw_data=[])
metadata_provider.payload = _other_payload(
"VAE",
files=[
{
"id": 7,
"type": "Model",
"primary": True,
"name": "file.safetensors",
"hashes": {"SHA256": "deadbeef"},
"downloadUrl": "https://example.invalid/file.safetensors",
}
],
)
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir=str(tmp_path),
file_params={"id": 7, "type": "Model"},
)
assert result["success"] is True
assert captured["model_type"] == "other"
@pytest.mark.asyncio
async def test_explicit_file_gate_blocks_when_other_scanner_has_file(
monkeypatch, scanners, metadata_provider, tmp_path
):
local_entry = {
"file_name": "file",
"sha256": "deadbeef",
"civitai": {"id": 42},
}
scanners.other._cache = SimpleNamespace(raw_data=[dict(local_entry)])
metadata_provider.payload = _other_payload(
"VAE",
files=[
{
"id": 7,
"type": "Model",
"primary": True,
"name": "file.safetensors",
"hashes": {"SHA256": "deadbeef"},
"downloadUrl": "https://example.invalid/file.safetensors",
}
],
)
execute_mock = AsyncMock(return_value={"success": True})
monkeypatch.setattr(DownloadManager, "_execute_download", execute_mock)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir=str(tmp_path),
file_params={"id": 7, "type": "Model"},
)
assert result["success"] is False
assert "already exists in other library" in result["error"]
assert execute_mock.await_count == 0
@pytest.mark.asyncio
async def test_version_level_fallback_gate_checks_other_scanner(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""file_params that resolve to nothing fall back to the version-level
gate, which must consult the other scanner."""
scanners.other.exists = True
execute_mock = AsyncMock(return_value={"success": True})
monkeypatch.setattr(DownloadManager, "_execute_download", execute_mock)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=101,
save_dir=str(tmp_path),
file_params={"id": 999999, "type": "Model"},
)
assert result["success"] is False
assert result["error"] == "Model version already exists in other library"
assert scanners.other.calls == [101]
assert execute_mock.await_count == 0
@pytest.mark.asyncio
async def test_default_paths_use_per_sub_type_root(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""model.type VAE -> default_other_roots['vae']."""
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
assert str(tmp_path / "vae") in str(captured["save_dir"])
assert captured["relative_path"] == ""
@pytest.mark.asyncio
async def test_default_paths_other_is_flat_without_configured_template(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""Regression: an unconfigured 'other' template must resolve to a flat
layout at the sub_type root instead of the {base_model}/{first_tag}
fallback (which scattered files into arbitrary CivitAI-tag folders)."""
get_settings_manager().settings["download_path_templates"].pop("other", None)
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
assert captured["relative_path"] == ""
assert str(tmp_path / "vae") in str(captured["save_dir"])
@pytest.mark.asyncio
async def test_default_paths_file_type_fallback_for_unmapped_model_type(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""model.type 'Other' maps to nothing; a 'Upscaler' file type decides."""
metadata_provider.payload = _other_payload(
"Other",
files=[
{
"type": "Upscaler",
"primary": True,
"downloadUrl": "https://example.invalid/upscaler.safetensors",
"name": "upscaler.safetensors",
}
],
)
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is True
assert str(tmp_path / "upscale_models") in str(captured["save_dir"])
@pytest.mark.asyncio
async def test_default_paths_explicit_file_pick_wins(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""An explicit pick of a bundled VAE component file routes to the vae
root even though model.type maps to upscaler."""
metadata_provider.payload = _other_payload(
"Upscaler",
files=[
{
"id": 1,
"type": "Model",
"primary": True,
"downloadUrl": "https://example.invalid/model.safetensors",
"name": "model.safetensors",
},
{
"id": 2,
"type": "VAE",
"downloadUrl": "https://example.invalid/bundled-vae.safetensors",
"name": "bundled-vae.safetensors",
},
],
)
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99,
use_default_paths=True,
file_params={"id": 2, "type": "VAE"},
)
assert result["success"] is True
assert str(tmp_path / "vae") in str(captured["save_dir"])
assert captured["download_urls"] == [
"https://example.invalid/bundled-vae.safetensors"
]
@pytest.mark.asyncio
async def test_default_paths_errors_when_sub_type_root_unconfigured(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""controlnet has no configured default root in the fixture settings."""
metadata_provider.payload = _other_payload("Controlnet")
execute_mock = AsyncMock(return_value={"success": True})
monkeypatch.setattr(DownloadManager, "_execute_download", execute_mock)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is False
assert "controlnet" in result["error"]
assert result["reason"] == "other_no_default_root"
assert execute_mock.await_count == 0
@pytest.mark.asyncio
async def test_default_paths_errors_when_sub_type_undecidable(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""model.type 'Other' with only plain 'Model' files: never silently
default to the vae folder error and ask for an explicit folder."""
metadata_provider.payload = _other_payload("Other")
execute_mock = AsyncMock(return_value={"success": True})
monkeypatch.setattr(DownloadManager, "_execute_download", execute_mock)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
assert result["success"] is False
assert "sub-type" in result["error"]
assert result["reason"] == "other_sub_type_undecidable"
assert execute_mock.await_count == 0
@pytest.mark.asyncio
async def test_civarchive_source_same_payload_shape(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""CivArchive downloads walk the same path with the same payload shape."""
metadata_provider.payload = _other_payload("TextEncoder")
captured = {}
_capture_execute(monkeypatch, captured)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path), source="civarchive"
)
assert result["success"] is True
assert captured["model_type"] == "other"
@pytest.mark.asyncio
async def test_other_failure_reasons_are_machine_readable(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""Contract C4: every other-type default-path failure carries a ``reason``.
The companion browser extension binds to ``reason`` and only falls back to
substring matching for backends that predate the field, so the exact values
below must not drift.
"""
expected_reasons = {
"disabled": "other_models_disabled",
"sub_type_disabled": "other_sub_type_disabled",
"no_default_root": "other_no_default_root",
"undecidable": "other_sub_type_undecidable",
}
reasons: dict[str, str] = {}
manager = DownloadManager()
# 1. Master switch off.
metadata_provider.payload = _other_payload("VAE")
get_settings_manager().settings["enable_other_models"] = False
disabled = await manager.download_from_civitai(
model_version_id=99, save_dir=str(tmp_path)
)
reasons["disabled"] = disabled["reason"]
assert disabled["error"].strip()
get_settings_manager().settings["enable_other_models"] = True
# 2. Resolved sub_type not enabled.
get_settings_manager().settings["enabled_other_sub_types"] = ["upscaler"]
sub_type_disabled = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
reasons["sub_type_disabled"] = sub_type_disabled["reason"]
assert sub_type_disabled["error"].strip()
get_settings_manager().settings["enabled_other_sub_types"] = [
"vae",
"upscaler",
"text_encoder",
"clip_vision",
"controlnet",
]
# 3. Sub_type resolved but no default root configured.
metadata_provider.payload = _other_payload("Controlnet")
no_default_root = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
reasons["no_default_root"] = no_default_root["reason"]
assert no_default_root["error"].strip()
# 4. Neither model.type nor file types map to a sub_type.
metadata_provider.payload = _other_payload("Other")
undecidable = await manager.download_from_civitai(
model_version_id=99, use_default_paths=True
)
reasons["undecidable"] = undecidable["reason"]
assert undecidable["error"].strip()
assert reasons == expected_reasons
def test_build_metadata_for_resume_uses_other_metadata():
manager = DownloadManager()
metadata = manager._build_metadata_for_resume(
model_type="other",
version_info={"model": {"type": "VAE"}},
file_info={"name": "file.safetensors"},
save_path="/tmp/file.safetensors",
)
assert metadata.metadata_class == "OtherModelMetadata"
def test_other_extension_set_matches_checkpoint():
manager = DownloadManager()
extensions = manager._get_supported_extensions_for_type("other")
assert extensions == manager._get_supported_extensions_for_type("checkpoint")
assert ".gguf" in extensions
assert ".safetensors" in extensions
@pytest.mark.asyncio
async def test_sync_downloaded_version_uses_other_scanner(monkeypatch, scanners):
"""Update tracking for a downloaded other-model version consults the
other scanner for local versions."""
class FakeUpdateService:
def __init__(self):
self.calls = []
async def update_in_library_versions(
self, model_type, model_id, version_ids, version_info=None
):
self.calls.append((model_type, model_id, version_ids))
update_service = FakeUpdateService()
monkeypatch.setattr(
ServiceRegistry,
"get_model_update_service",
AsyncMock(return_value=update_service),
)
manager = DownloadManager()
await manager._sync_downloaded_version(
"other", 7, {"id": 42, "model": {"id": 7}}
)
assert update_service.calls == [("other", 7, [42])]
+102
View File
@@ -38,3 +38,105 @@ def test_non_checkpoint_types_never_route_to_unet():
def test_empty_inputs_stay_on_checkpoint_roots():
assert not is_diffusion_model_download("checkpoint")
assert not is_diffusion_model_download("checkpoint", file_types=[], base_model="")
from py.services.download_routing import resolve_other_download_sub_type
class TestResolveOtherDownloadSubType:
"""Fixed priority: explicit file pick > model.type > file.type fallback."""
def test_explicit_file_pick_wins_over_model_type(self):
"""User explicitly picked a VAE component file of a Checkpoint model —
the picked file type wins."""
assert (
resolve_other_download_sub_type(
"Checkpoint", file_types=["Model", "VAE"], selected_file_type="VAE"
)
== "vae"
)
@pytest.mark.parametrize(
"selected,expected",
[
("VAE", "vae"),
("Upscaler", "upscaler"),
("Text Encoder", "text_encoder"),
("Vision Encoder", "clip_vision"),
("CLIPVision", "clip_vision"),
("ControlNet", "controlnet"),
],
)
def test_explicit_file_pick_maps_all_known_types(self, selected, expected):
assert (
resolve_other_download_sub_type("Other", selected_file_type=selected)
== expected
)
@pytest.mark.parametrize(
"model_type,expected",
[
("VAE", "vae"),
("Upscaler", "upscaler"),
("TextEncoder", "text_encoder"),
("CLIP", "text_encoder"),
("CLIPVision", "clip_vision"),
("Controlnet", "controlnet"),
],
)
def test_model_type_mapping(self, model_type, expected):
assert resolve_other_download_sub_type(model_type) == expected
def test_model_type_beats_unmappable_file_pick(self):
"""An explicit pick whose file type does not map (e.g. plain 'Model')
falls through to model.type."""
assert (
resolve_other_download_sub_type(
"TextEncoder", selected_file_type="Model"
)
== "text_encoder"
)
def test_bundled_component_files_never_override_model_type(self):
"""Anti-misrouting: a TextEncoder model bundling a VAE component file
must stay text_encoder file types are a fallback, not an override."""
assert (
resolve_other_download_sub_type(
"TextEncoder", file_types=["Model", "VAE"]
)
== "text_encoder"
)
assert (
resolve_other_download_sub_type(
"Controlnet", file_types=["Model", "Text Encoder"]
)
== "controlnet"
)
def test_file_type_fallback_when_model_type_unmapped(self):
"""model.type 'Other' (or retired values) maps to nothing, so the
first mappable file type decides."""
assert (
resolve_other_download_sub_type("Other", file_types=["Model", "Upscaler"])
== "upscaler"
)
def test_file_type_fallback_for_civarchive_payload(self):
"""CivArchive-shaped payload: same fields, same decision path."""
assert (
resolve_other_download_sub_type(
"Other",
file_types=["Config", "Text Encoder"],
)
== "text_encoder"
)
@pytest.mark.parametrize("model_type", ["Other", "", "SomethingNew"])
def test_undecidable_returns_none(self, model_type):
assert (
resolve_other_download_sub_type(model_type, file_types=["Model"]) is None
)
assert resolve_other_download_sub_type(model_type) is None
def test_model_type_matching_is_case_insensitive(self):
assert resolve_other_download_sub_type("vAe") == "vae"
@@ -0,0 +1,168 @@
"""Example-images download dispatch accepts the "other" model type.
Covers the three scanner-dispatch sites from docs/plans/other-models-page.md
§9.1: check_pending_models, _download_all_example_images and
_download_specific_models_example_images_sync.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
import pytest
from py.services.settings_manager import get_settings_manager
from py.utils import example_images_download_manager as download_module
class StubScanner:
"""Scanner double returning predetermined cache contents."""
def __init__(self, models: list[dict[str, Any]]) -> None:
self._cache = SimpleNamespace(raw_data=models)
async def get_cached_data(self):
return self._cache
class RecordingWebSocketManager:
def __init__(self) -> None:
self.payloads: list[dict[str, Any]] = []
async def broadcast(self, payload: dict[str, Any]) -> None:
self.payloads.append(payload)
def _patch_all_scanners(monkeypatch: pytest.MonkeyPatch, **scanners) -> None:
for name, getter in (
("lora", "get_lora_scanner"),
("checkpoint", "get_checkpoint_scanner"),
("embedding", "get_embedding_scanner"),
("other", "get_other_scanner"),
):
scanner = scanners.get(name) or StubScanner([])
async def _get_scanner(cls, _scanner=scanner):
return _scanner
monkeypatch.setattr(
download_module.ServiceRegistry,
getter,
classmethod(_get_scanner),
)
@pytest.mark.asyncio
async def test_check_pending_models_includes_other_scanner(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
other_models = [{"sha256": "d" * 64, "model_name": "VAE Model"}]
_patch_all_scanners(monkeypatch, other=StubScanner(other_models))
result = await manager.check_pending_models(["other"])
assert result["success"] is True
assert result["total_models"] == 1
assert result["pending_count"] == 1
@pytest.mark.asyncio
async def test_download_all_example_images_processes_other_models(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
other_models = [{"sha256": "e" * 64, "model_name": "Upscaler Model"}]
_patch_all_scanners(monkeypatch, other=StubScanner(other_models))
async def fake_get_downloader():
return object()
processed: list[tuple[str, dict[str, Any]]] = []
async def fake_process_model(self, scanner_type, model, scanner, *_args, **_kwargs):
processed.append((scanner_type, model))
return False
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
monkeypatch.setattr(
download_module.DownloadManager, "_process_model", fake_process_model
)
# Simulate the running state that start_download establishes.
manager._progress["status"] = "running"
await manager._download_all_example_images(
str(tmp_path),
optimize=False,
model_types=["other"],
delay=0,
library_name="default",
)
assert processed == [("other", other_models[0])]
@pytest.mark.asyncio
async def test_download_specific_models_example_images_processes_other_models(
monkeypatch: pytest.MonkeyPatch,
tmp_path,
settings_manager,
):
ws_manager = RecordingWebSocketManager()
manager = download_module.DownloadManager(ws_manager=ws_manager)
monkeypatch.setitem(settings_manager.settings, "example_images_path", str(tmp_path))
model_hash = "f" * 64
other_models = [{"sha256": model_hash, "model_name": "Text Encoder Model"}]
_patch_all_scanners(monkeypatch, other=StubScanner(other_models))
async def fake_get_downloader():
return object()
processed: list[tuple[str, dict[str, Any]]] = []
async def fake_process_specific_model(
self, scanner_type, model, scanner, *_args, **_kwargs
):
processed.append((scanner_type, model))
return True
monkeypatch.setattr(download_module, "get_downloader", fake_get_downloader)
monkeypatch.setattr(
download_module.DownloadManager,
"_process_specific_model",
fake_process_specific_model,
)
# Simulate the running state that start_force_download establishes.
manager._progress["status"] = "running"
await manager._download_specific_models_example_images_sync(
[model_hash],
str(tmp_path),
optimize=False,
model_types=["other"],
delay=0,
library_name="default",
)
assert processed == [("other", other_models[0])]
@pytest.fixture
def settings_manager():
return get_settings_manager()

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