mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
Compare commits
25 Commits
6d3f82976f
...
84146b62fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 84146b62fd | |||
| adeb40bfff | |||
| 8a21837ca2 | |||
| 3302147a43 | |||
| 4d87ae7637 | |||
| b1a653f18f | |||
| 6fe0543d2e | |||
| 931dfbe1d3 | |||
| b5c1331911 | |||
| 37f2cba72d | |||
| 0e789cb38c | |||
| f3b3393a16 | |||
| 480a3f4ea5 | |||
| 69a62d739c | |||
| 28fbb86dce | |||
| f88fe2665c | |||
| 3592eab48c | |||
| 1dbdf5b00c | |||
| fc3b2d7c13 | |||
| f2a7297cb9 | |||
| 57729375b6 | |||
| fa7ce725c1 | |||
| 27da7b3ca3 | |||
| 3070838a42 | |||
| fe160134d0 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 d’autres 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 l’activation 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 d’autres modèles trouvé",
|
||||
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe 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 n’existe 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "פתח הגדרות"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "設定を開く"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "설정 열기"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "Открыть настройки"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "打开设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"""
|
||||
|
||||
@@ -18,6 +18,5 @@
|
||||
"C:/path/to/your/embeddings_folder",
|
||||
"C:/path/to/another/embeddings_folder"
|
||||
]
|
||||
},
|
||||
"auto_organize_exclusions": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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`,
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() : '';
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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))]
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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]] = []
|
||||
|
||||
|
||||
@@ -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])]
|
||||
@@ -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
Reference in New Issue
Block a user