mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Merge branch 'feature/other-models-page': Other Models page (VAE/upscaler/text encoder management + CivitAI downloads)
This commit is contained in:
@@ -166,7 +166,7 @@ The system runs in two modes:
|
|||||||
|
|
||||||
### Model Types & Routes
|
### 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.
|
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||||
- Request handlers in `py/routes/handlers/` implement route logic
|
- Request handlers in `py/routes/handlers/` implement route logic
|
||||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# 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).
|
||||||
|
**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` | yes |
|
||||||
|
| `controlnet` | `controlnet` | `Controlnet` | no (mapping present, opt-in) |
|
||||||
|
|
||||||
|
New folder categories = one line in the mapping table (see §4.1).
|
||||||
|
|
||||||
|
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")` already falls back to `"{base_model}/{first_tag}"` — works with zero change; optional settings-UI row (§9.4).
|
||||||
|
|
||||||
|
### 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.
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "Rezepte",
|
"recipes": "Rezepte",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "Rezepte-Speicherpfad",
|
"recipesPath": "Rezepte-Speicherpfad",
|
||||||
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding-Modelle"
|
"title": "Embedding-Modelle"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Stammverzeichnis",
|
"modelRoot": "Stammverzeichnis",
|
||||||
"collapseAll": "Alle Ordner einklappen",
|
"collapseAll": "Alle Ordner einklappen",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Embedding Manager wird initialisiert",
|
"title": "Embedding Manager wird initialisiert",
|
||||||
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Rezept Manager wird initialisiert",
|
"title": "Rezept Manager wird initialisiert",
|
||||||
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||||
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
||||||
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
||||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Copy checkpoint name",
|
"copyCheckpointName": "Copy checkpoint name",
|
||||||
"copyEmbeddingName": "Copy embedding name",
|
"copyEmbeddingName": "Copy embedding name",
|
||||||
"embeddingNameCopied": "Embedding syntax copied",
|
"embeddingNameCopied": "Embedding syntax copied",
|
||||||
|
"modelNameCopied": "Model name copied",
|
||||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "Other",
|
||||||
"statistics": "Stats"
|
"statistics": "Stats"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||||
"defaultEmbeddingRoot": "Embedding Root",
|
"defaultEmbeddingRoot": "Embedding Root",
|
||||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
"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",
|
||||||
"recipesPath": "Recipes Storage Path",
|
"recipesPath": "Recipes Storage Path",
|
||||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding Models"
|
"title": "Embedding Models"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Root",
|
"modelRoot": "Root",
|
||||||
"collapseAll": "Collapse All Folders",
|
"collapseAll": "Collapse All Folders",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Initializing Embedding Manager",
|
"title": "Initializing Embedding Manager",
|
||||||
"message": "Scanning and building embedding cache. This may take a few minutes..."
|
"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": {
|
"recipes": {
|
||||||
"title": "Initializing Recipe Manager",
|
"title": "Initializing Recipe Manager",
|
||||||
"message": "Loading and processing recipes. This may take a few minutes..."
|
"message": "Loading and processing recipes. This may take a few minutes..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||||
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||||
"embeddingRootsFailed": "Failed to load embedding 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})",
|
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||||
"mappingsCleared": "Base model path mappings cleared",
|
"mappingsCleared": "Base model path mappings cleared",
|
||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "Recetas",
|
"recipes": "Recetas",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "Estadísticas"
|
"statistics": "Estadísticas"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "Ruta de almacenamiento de recetas",
|
"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.",
|
"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",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modelos embedding"
|
"title": "Modelos embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Raíz",
|
"modelRoot": "Raíz",
|
||||||
"collapseAll": "Colapsar todas las carpetas",
|
"collapseAll": "Colapsar todas las carpetas",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Inicializando gestor de embedding",
|
"title": "Inicializando gestor de embedding",
|
||||||
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Inicializando gestor de recetas",
|
"title": "Inicializando gestor de recetas",
|
||||||
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||||
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "Statistiques"
|
"statistics": "Statistiques"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
"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",
|
"defaultEmbeddingRoot": "Racine Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "Chemin de stockage des Recipes",
|
"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.",
|
"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",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modèles Embedding"
|
"title": "Modèles Embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Racine",
|
"modelRoot": "Racine",
|
||||||
"collapseAll": "Réduire tous les dossiers",
|
"collapseAll": "Réduire tous les dossiers",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Initialisation du gestionnaire Embedding",
|
"title": "Initialisation du gestionnaire Embedding",
|
||||||
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Initialisation du gestionnaire de recipes",
|
"title": "Initialisation du gestionnaire de recipes",
|
||||||
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||||
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
"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",
|
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "העתק שם Checkpoint",
|
"copyCheckpointName": "העתק שם Checkpoint",
|
||||||
"copyEmbeddingName": "העתק שם Embedding",
|
"copyEmbeddingName": "העתק שם Embedding",
|
||||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "מתכונים",
|
"recipes": "מתכונים",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "סטטיסטיקה"
|
"statistics": "סטטיסטיקה"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "נתיב אחסון מתכונים",
|
"recipesPath": "נתיב אחסון מתכונים",
|
||||||
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "מודלי Embedding"
|
"title": "מודלי Embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "שורש",
|
"modelRoot": "שורש",
|
||||||
"collapseAll": "כווץ את כל התיקיות",
|
"collapseAll": "כווץ את כל התיקיות",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "מאתחל מנהל Embedding",
|
"title": "מאתחל מנהל Embedding",
|
||||||
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "מאתחל מנהל מתכונים",
|
"title": "מאתחל מנהל מתכונים",
|
||||||
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||||
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
||||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Checkpoint名をコピー",
|
"copyCheckpointName": "Checkpoint名をコピー",
|
||||||
"copyEmbeddingName": "embedding名をコピー",
|
"copyEmbeddingName": "embedding名をコピー",
|
||||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "レシピ",
|
"recipes": "レシピ",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||||
"defaultEmbeddingRoot": "Embeddingルート",
|
"defaultEmbeddingRoot": "Embeddingルート",
|
||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "レシピ保存先",
|
"recipesPath": "レシピ保存先",
|
||||||
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embeddingモデル"
|
"title": "Embeddingモデル"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "ルート",
|
"modelRoot": "ルート",
|
||||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Embedding Managerを初期化中",
|
"title": "Embedding Managerを初期化中",
|
||||||
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "レシピマネージャーを初期化中",
|
"title": "レシピマネージャーを初期化中",
|
||||||
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
||||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
||||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||||
"copyEmbeddingName": "Embedding 이름 복사",
|
"copyEmbeddingName": "Embedding 이름 복사",
|
||||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "레시피",
|
"recipes": "레시피",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "통계"
|
"statistics": "통계"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||||
"defaultEmbeddingRoot": "Embedding 루트",
|
"defaultEmbeddingRoot": "Embedding 루트",
|
||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "레시피 저장 경로",
|
"recipesPath": "레시피 저장 경로",
|
||||||
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 모델"
|
"title": "Embedding 모델"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "루트",
|
"modelRoot": "루트",
|
||||||
"collapseAll": "모든 폴더 접기",
|
"collapseAll": "모든 폴더 접기",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Embedding Manager 초기화 중",
|
"title": "Embedding Manager 초기화 중",
|
||||||
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "레시피 매니저 초기화 중",
|
"title": "레시피 매니저 초기화 중",
|
||||||
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||||
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Копировать имя checkpoint",
|
"copyCheckpointName": "Копировать имя checkpoint",
|
||||||
"copyEmbeddingName": "Копировать имя embedding",
|
"copyEmbeddingName": "Копировать имя embedding",
|
||||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "Рецепты",
|
"recipes": "Рецепты",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "Путь хранения рецептов",
|
"recipesPath": "Путь хранения рецептов",
|
||||||
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Модели Embedding"
|
"title": "Модели Embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Корень",
|
"modelRoot": "Корень",
|
||||||
"collapseAll": "Свернуть все папки",
|
"collapseAll": "Свернуть все папки",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "Инициализация Embedding Manager",
|
"title": "Инициализация Embedding Manager",
|
||||||
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Инициализация менеджера рецептов",
|
"title": "Инициализация менеджера рецептов",
|
||||||
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||||
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
||||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||||
"copyEmbeddingName": "复制 Embedding 名称",
|
"copyEmbeddingName": "复制 Embedding 名称",
|
||||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "统计"
|
"statistics": "统计"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "配方存储路径",
|
"recipesPath": "配方存储路径",
|
||||||
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "根目录",
|
"modelRoot": "根目录",
|
||||||
"collapseAll": "折叠所有文件夹",
|
"collapseAll": "折叠所有文件夹",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "初始化 Embedding 管理器",
|
"title": "初始化 Embedding 管理器",
|
||||||
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "初始化配方管理器",
|
"title": "初始化配方管理器",
|
||||||
"message": "正在加载和处理配方。这可能需要几分钟..."
|
"message": "正在加载和处理配方。这可能需要几分钟..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
||||||
"mappingsCleared": "基础模型路径映射已清除",
|
"mappingsCleared": "基础模型路径映射已清除",
|
||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
|
|||||||
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "複製 Checkpoint 名稱",
|
"copyCheckpointName": "複製 Checkpoint 名稱",
|
||||||
"copyEmbeddingName": "複製嵌入名稱",
|
"copyEmbeddingName": "複製嵌入名稱",
|
||||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||||
|
"modelNameCopied": "[TODO: Translate] Model name copied",
|
||||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -233,6 +234,7 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "[TODO: Translate] Other",
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -533,6 +535,16 @@
|
|||||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||||
|
"defaultVaeRoot": "[TODO: Translate] VAE Root",
|
||||||
|
"defaultVaeRootHelp": "[TODO: Translate] Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "[TODO: Translate] Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "[TODO: Translate] Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "[TODO: Translate] Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "[TODO: Translate] Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "[TODO: Translate] CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "[TODO: Translate] Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "[TODO: Translate] ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "[TODO: Translate] Set default ControlNet root directory for downloads, imports and moves",
|
||||||
"recipesPath": "配方儲存路徑",
|
"recipesPath": "配方儲存路徑",
|
||||||
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1201,6 +1213,9 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Other Models"
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "根目錄",
|
"modelRoot": "根目錄",
|
||||||
"collapseAll": "全部摺疊資料夾",
|
"collapseAll": "全部摺疊資料夾",
|
||||||
@@ -1878,6 +1893,10 @@
|
|||||||
"title": "初始化 Embedding 管理器",
|
"title": "初始化 Embedding 管理器",
|
||||||
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "[TODO: Translate] Initializing Other Models Manager",
|
||||||
|
"message": "[TODO: Translate] Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "初始化配方管理器",
|
"title": "初始化配方管理器",
|
||||||
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
||||||
@@ -2333,6 +2352,7 @@
|
|||||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||||
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||||
|
"otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
|
|||||||
+161
-1
@@ -17,6 +17,10 @@ import types as _types
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||||
|
from .utils.constants import (
|
||||||
|
DEFAULT_OTHER_MODEL_FOLDERS,
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||||
|
)
|
||||||
from .utils.settings_paths import (
|
from .utils.settings_paths import (
|
||||||
ensure_settings_file,
|
ensure_settings_file,
|
||||||
get_settings_dir,
|
get_settings_dir,
|
||||||
@@ -172,6 +176,13 @@ class Config:
|
|||||||
self.embeddings_roots = None
|
self.embeddings_roots = None
|
||||||
self.base_models_roots = self._init_checkpoint_paths()
|
self.base_models_roots = self._init_checkpoint_paths()
|
||||||
self.embeddings_roots = self._init_embedding_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)
|
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
self.extra_loras_roots: List[str] = []
|
self.extra_loras_roots: List[str] = []
|
||||||
self.extra_checkpoints_roots: List[str] = []
|
self.extra_checkpoints_roots: List[str] = []
|
||||||
@@ -336,6 +347,10 @@ class Config:
|
|||||||
"unet": list(self.unet_roots or []),
|
"unet": list(self.unet_roots or []),
|
||||||
"embeddings": list(self.embeddings_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(
|
normalized_target_paths = _normalize_folder_paths_for_comparison(
|
||||||
target_folder_paths
|
target_folder_paths
|
||||||
@@ -522,6 +537,7 @@ class Config:
|
|||||||
roots.extend(self.loras_roots or [])
|
roots.extend(self.loras_roots or [])
|
||||||
roots.extend(self.base_models_roots or [])
|
roots.extend(self.base_models_roots or [])
|
||||||
roots.extend(self.embeddings_roots or [])
|
roots.extend(self.embeddings_roots or [])
|
||||||
|
roots.extend(self.other_roots or [])
|
||||||
# Include extra paths for scanning symlinks
|
# Include extra paths for scanning symlinks
|
||||||
roots.extend(self.extra_loras_roots or [])
|
roots.extend(self.extra_loras_roots or [])
|
||||||
roots.extend(self.extra_checkpoints_roots or [])
|
roots.extend(self.extra_checkpoints_roots or [])
|
||||||
@@ -862,6 +878,8 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
for root in self.embeddings_roots or []:
|
for root in self.embeddings_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
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
|
# Include extra paths for preview access
|
||||||
for root in self.extra_loras_roots or []:
|
for root in self.extra_loras_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
@@ -882,7 +900,7 @@ class Config:
|
|||||||
path for path in preview_roots if path.is_absolute()
|
path for path in preview_roots if path.is_absolute()
|
||||||
}
|
}
|
||||||
logger.debug(
|
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._preview_root_paths),
|
||||||
len(self.loras_roots or []),
|
len(self.loras_roots or []),
|
||||||
len(self.extra_loras_roots or []),
|
len(self.extra_loras_roots or []),
|
||||||
@@ -890,6 +908,7 @@ class Config:
|
|||||||
len(self.extra_checkpoints_roots or []),
|
len(self.extra_checkpoints_roots or []),
|
||||||
len(self.embeddings_roots or []),
|
len(self.embeddings_roots or []),
|
||||||
len(self.extra_embeddings_roots or []),
|
len(self.extra_embeddings_roots or []),
|
||||||
|
len(self.other_roots or []),
|
||||||
len(self._path_mappings),
|
len(self._path_mappings),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1128,6 +1147,102 @@ class Config:
|
|||||||
|
|
||||||
return unique_paths
|
return unique_paths
|
||||||
|
|
||||||
|
def _get_enabled_other_folder_keys(self) -> List[str]:
|
||||||
|
"""Return the OTHER_MODEL_FOLDER_SUBTYPES keys that are enabled.
|
||||||
|
|
||||||
|
Default-enabled categories come from DEFAULT_OTHER_MODEL_FOLDERS;
|
||||||
|
opt-in categories (e.g. controlnet) are added via the
|
||||||
|
``enabled_other_folders`` setting (a list of folder_paths keys).
|
||||||
|
"""
|
||||||
|
keys = list(DEFAULT_OTHER_MODEL_FOLDERS)
|
||||||
|
try:
|
||||||
|
from .services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
extra = get_settings_manager().get("enabled_other_folders", [])
|
||||||
|
except Exception:
|
||||||
|
extra = []
|
||||||
|
if isinstance(extra, str):
|
||||||
|
extra = [extra]
|
||||||
|
if isinstance(extra, Iterable):
|
||||||
|
for key in extra:
|
||||||
|
if (
|
||||||
|
isinstance(key, str)
|
||||||
|
and key in OTHER_MODEL_FOLDER_SUBTYPES
|
||||||
|
and key not in keys
|
||||||
|
):
|
||||||
|
keys.append(key)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
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]] = {}
|
||||||
|
seen_real_paths: Dict[str, str] = {} # real path -> business path
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
):
|
||||||
|
if real_path in seen_real_paths:
|
||||||
|
logger.warning(
|
||||||
|
"Detected the same folder '%s' under multiple other-model "
|
||||||
|
"categories ('%s' is already mapped). Keeping the first "
|
||||||
|
"category; please fix your path configuration.",
|
||||||
|
business_path,
|
||||||
|
seen_real_paths[real_path],
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen_real_paths[real_path] = business_path
|
||||||
|
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(
|
def _apply_library_paths(
|
||||||
self,
|
self,
|
||||||
folder_paths: Mapping[str, Any],
|
folder_paths: Mapping[str, Any],
|
||||||
@@ -1151,6 +1266,16 @@ class Config:
|
|||||||
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_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)
|
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
extra_paths = extra_folder_paths or {}
|
extra_paths = extra_folder_paths or {}
|
||||||
extra_lora_paths = extra_paths.get("loras", []) or []
|
extra_lora_paths = extra_paths.get("loras", []) or []
|
||||||
@@ -1267,6 +1392,41 @@ class Config:
|
|||||||
logger.warning(f"Error initializing embedding paths: {e}")
|
logger.warning(f"Error initializing embedding paths: {e}")
|
||||||
return []
|
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``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
folder_path_map: Dict[str, List[str]] = {}
|
||||||
|
for key in 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 get_preview_static_url(self, preview_path: str) -> str:
|
def get_preview_static_url(self, preview_path: str) -> str:
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+7
-1
@@ -219,6 +219,7 @@ class LoraManager:
|
|||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
|
||||||
# Initialize recipe scanner if needed
|
# Initialize recipe scanner if needed
|
||||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||||
@@ -236,6 +237,10 @@ class LoraManager:
|
|||||||
embedding_scanner.initialize_in_background(),
|
embedding_scanner.initialize_in_background(),
|
||||||
name="embedding_cache_init",
|
name="embedding_cache_init",
|
||||||
),
|
),
|
||||||
|
asyncio.create_task(
|
||||||
|
other_scanner.initialize_in_background(),
|
||||||
|
name="other_cache_init",
|
||||||
|
),
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
|
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.loras_roots)
|
||||||
all_roots.update(config.base_models_roots or [])
|
all_roots.update(config.base_models_roots or [])
|
||||||
all_roots.update(config.embeddings_roots or [])
|
all_roots.update(config.embeddings_roots or [])
|
||||||
|
all_roots.update(config.other_roots or [])
|
||||||
|
|
||||||
total_deleted = 0
|
total_deleted = 0
|
||||||
total_size_freed = 0
|
total_size_freed = 0
|
||||||
@@ -460,7 +466,7 @@ class LoraManager:
|
|||||||
# Cancel any in-flight scanner initialization tasks so thread-pool
|
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||||
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||||
# when the server shuts down (e.g. Ctrl+C on WSL).
|
# 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)
|
scanner = ServiceRegistry.get_service_sync(name)
|
||||||
if scanner is not None and hasattr(scanner, "cancel_task"):
|
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||||
scanner.cancel_task()
|
scanner.cancel_task()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = {
|
|||||||
"get_lora_scanner": "lora",
|
"get_lora_scanner": "lora",
|
||||||
"get_checkpoint_scanner": "checkpoint",
|
"get_checkpoint_scanner": "checkpoint",
|
||||||
"get_embedding_scanner": "embedding",
|
"get_embedding_scanner": "embedding",
|
||||||
|
"get_other_scanner": "other",
|
||||||
}
|
}
|
||||||
|
|
||||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
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:
|
async def identify_model_type(model_path: str) -> str:
|
||||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
|
||||||
``\"embedding\"``) for *model_path*.
|
``\"embedding\"``, or ``\"other\"``) for *model_path*.
|
||||||
|
|
||||||
Falls back to ``\"lora\"`` when unknown.
|
Falls back to ``\"lora\"`` when unknown.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ import logging
|
|||||||
|
|
||||||
from aiohttp import web
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ class DownloadRoutingHandler:
|
|||||||
model_type = payload.get("model_type", "")
|
model_type = payload.get("model_type", "")
|
||||||
base_model = payload.get("base_model") or ""
|
base_model = payload.get("base_model") or ""
|
||||||
file_types = payload.get("file_types") 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:
|
if not isinstance(model_type, str) or not model_type:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -44,6 +49,25 @@ class DownloadRoutingHandler:
|
|||||||
},
|
},
|
||||||
status=400,
|
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:
|
||||||
|
sub_type = resolve_other_download_sub_type(
|
||||||
|
model_type,
|
||||||
|
file_types=(str(t) for t in file_types),
|
||||||
|
selected_file_type=selected_file_type,
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"root_kind": "other",
|
||||||
|
"sub_type": sub_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
is_diffusion = is_diffusion_model_download(
|
is_diffusion = is_diffusion_model_download(
|
||||||
model_type,
|
model_type,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ...utils.constants import (
|
|||||||
PREVIEW_EXTENSIONS,
|
PREVIEW_EXTENSIONS,
|
||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
|
VALID_OTHER_CIVITAI_TYPES,
|
||||||
)
|
)
|
||||||
from .hf_handlers import HfHandler
|
from .hf_handlers import HfHandler
|
||||||
from .agent_handlers import AgentHandler
|
from .agent_handlers import AgentHandler
|
||||||
@@ -658,6 +659,7 @@ class HealthCheckHandler:
|
|||||||
"lora": ServiceRegistry.get_lora_scanner,
|
"lora": ServiceRegistry.get_lora_scanner,
|
||||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||||
|
"other": ServiceRegistry.get_other_scanner,
|
||||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +759,7 @@ class DoctorHandler:
|
|||||||
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
||||||
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
||||||
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
||||||
|
("other", "Other Models", ServiceRegistry.get_other_scanner),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._app_version_getter = app_version_getter
|
self._app_version_getter = app_version_getter
|
||||||
@@ -2066,6 +2069,7 @@ class ServiceRegistryAdapter:
|
|||||||
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
||||||
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
||||||
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
||||||
|
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
|
||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
@@ -2787,12 +2791,30 @@ class ModelLibraryHandler:
|
|||||||
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
||||||
}
|
}
|
||||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_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.
|
||||||
|
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] = {
|
type_scanner_map: Dict[str, Any] = {
|
||||||
**{alias: lora_scanner for alias in lora_type_aliases},
|
**{alias: lora_scanner for alias in lora_type_aliases},
|
||||||
"checkpoint": checkpoint_scanner,
|
"checkpoint": checkpoint_scanner,
|
||||||
"textualinversion": embedding_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]] = []
|
versions: list[dict[str, Any]] = []
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
@@ -2816,12 +2838,17 @@ class ModelLibraryHandler:
|
|||||||
"embedding",
|
"embedding",
|
||||||
model_ids,
|
model_ids,
|
||||||
)
|
)
|
||||||
|
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||||
|
"other",
|
||||||
|
model_ids,
|
||||||
|
)
|
||||||
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||||
"lora": lora_downloaded,
|
"lora": lora_downloaded,
|
||||||
"locon": lora_downloaded,
|
"locon": lora_downloaded,
|
||||||
"dora": lora_downloaded,
|
"dora": lora_downloaded,
|
||||||
"checkpoint": checkpoint_downloaded,
|
"checkpoint": checkpoint_downloaded,
|
||||||
"textualinversion": embedding_downloaded,
|
"textualinversion": embedding_downloaded,
|
||||||
|
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
|
||||||
}
|
}
|
||||||
for model in models:
|
for model in models:
|
||||||
if not isinstance(model, dict):
|
if not isinstance(model, dict):
|
||||||
@@ -3980,6 +4007,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
|||||||
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||||
get_embedding_scanner=ServiceRegistry.get_embedding_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_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||||
get_backup_service=ServiceRegistry.get_backup_service,
|
get_backup_service=ServiceRegistry.get_backup_service,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
|||||||
"loras": "get_lora_scanner",
|
"loras": "get_lora_scanner",
|
||||||
"checkpoints": "get_checkpoint_scanner",
|
"checkpoints": "get_checkpoint_scanner",
|
||||||
"embeddings": "get_embedding_scanner",
|
"embeddings": "get_embedding_scanner",
|
||||||
|
"other": "get_other_scanner",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import logging
|
||||||
|
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 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.
|
||||||
|
"""
|
||||||
|
return model_type.lower() in VALID_OTHER_CIVITAI_TYPES
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
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.models import BaseModelMetadata
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
@@ -904,6 +904,11 @@ class BaseModelService(ABC):
|
|||||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||||
):
|
):
|
||||||
continue
|
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
|
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ from dataclasses import dataclass, field
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
from ..utils.models import (
|
||||||
|
LoraMetadata,
|
||||||
|
CheckpointMetadata,
|
||||||
|
EmbeddingMetadata,
|
||||||
|
OtherModelMetadata,
|
||||||
|
)
|
||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
MODEL_WEIGHT_FILE_TYPES,
|
MODEL_WEIGHT_FILE_TYPES,
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
|
VALID_OTHER_CIVITAI_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
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.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .service_registry import ServiceRegistry
|
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 .settings_manager import get_settings_manager
|
||||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||||
@@ -228,12 +234,21 @@ class DownloadManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _get_scanner_for_model_type(self, model_type: str):
|
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":
|
if model_type == "checkpoint":
|
||||||
return await self._get_checkpoint_scanner()
|
return await self._get_checkpoint_scanner()
|
||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
return await ServiceRegistry.get_embedding_scanner()
|
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
|
@staticmethod
|
||||||
def _resolve_target_file(
|
def _resolve_target_file(
|
||||||
@@ -978,6 +993,8 @@ class DownloadManager:
|
|||||||
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
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)
|
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]:
|
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()
|
lora_scanner = await self._get_lora_scanner()
|
||||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
|
||||||
# Check lora scanner first
|
# Check lora scanner first
|
||||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
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",
|
"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'
|
# Use CivArchive provider directly when source is 'civarchive'
|
||||||
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
||||||
if source == "civarchive":
|
if source == "civarchive":
|
||||||
@@ -1500,6 +1525,8 @@ class DownloadManager:
|
|||||||
model_type = "lora"
|
model_type = "lora"
|
||||||
elif model_type_from_info == "textualinversion":
|
elif model_type_from_info == "textualinversion":
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
|
||||||
|
model_type = "other"
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -1686,6 +1713,13 @@ class DownloadManager:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": "Model version already exists in embedding library",
|
"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
|
# Handle use_default_paths
|
||||||
if use_default_paths:
|
if use_default_paths:
|
||||||
@@ -1725,6 +1759,45 @@ class DownloadManager:
|
|||||||
"error": "Default embedding root path not set in settings",
|
"error": "Default embedding root path not set in settings",
|
||||||
}
|
}
|
||||||
save_dir = default_path
|
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 {}
|
||||||
|
)
|
||||||
|
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}'"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
detail = (
|
||||||
|
"Could not determine the other-model sub-type "
|
||||||
|
"from the model metadata"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"{detail}. Please pick a destination folder "
|
||||||
|
f"explicitly instead of using default paths."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
save_dir = default_path
|
||||||
|
|
||||||
# Calculate relative path using template
|
# Calculate relative path using template
|
||||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||||
@@ -1921,6 +1994,11 @@ class DownloadManager:
|
|||||||
version_info, file_info, save_path
|
version_info, file_info, save_path
|
||||||
)
|
)
|
||||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
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:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -2133,6 +2211,8 @@ class DownloadManager:
|
|||||||
scanner = await self._get_checkpoint_scanner()
|
scanner = await self._get_checkpoint_scanner()
|
||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
elif model_type == "other":
|
||||||
|
scanner = await ServiceRegistry.get_other_scanner()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||||
|
|
||||||
@@ -2629,6 +2709,9 @@ class DownloadManager:
|
|||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
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 = (
|
adjust_cached_entry = (
|
||||||
getattr(scanner, "adjust_cached_entry", None)
|
getattr(scanner, "adjust_cached_entry", None)
|
||||||
@@ -2718,7 +2801,7 @@ class DownloadManager:
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||||
if model_type == "checkpoint":
|
if model_type in ("checkpoint", "other"):
|
||||||
return {
|
return {
|
||||||
".ckpt",
|
".ckpt",
|
||||||
".pt",
|
".pt",
|
||||||
|
|||||||
@@ -10,9 +10,13 @@ two can never disagree.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,3 +55,49 @@ def is_diffusion_model_download(
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
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',
|
'lora': 'loras',
|
||||||
'checkpoint': 'checkpoints',
|
'checkpoint': 'checkpoints',
|
||||||
'embedding': 'embeddings',
|
'embedding': 'embeddings',
|
||||||
|
'other': 'other',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -118,13 +118,15 @@ class ModelServiceFactory:
|
|||||||
|
|
||||||
|
|
||||||
def register_default_model_types():
|
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.lora_service import LoraService
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.embedding_service import EmbeddingService
|
from ..services.embedding_service import EmbeddingService
|
||||||
|
from ..services.other_model_service import OtherModelService
|
||||||
from ..routes.lora_routes import LoraRoutes
|
from ..routes.lora_routes import LoraRoutes
|
||||||
from ..routes.checkpoint_routes import CheckpointRoutes
|
from ..routes.checkpoint_routes import CheckpointRoutes
|
||||||
from ..routes.embedding_routes import EmbeddingRoutes
|
from ..routes.embedding_routes import EmbeddingRoutes
|
||||||
|
from ..routes.other_routes import OtherRoutes
|
||||||
|
|
||||||
# Register LoRA model type
|
# Register LoRA model type
|
||||||
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
||||||
@@ -134,3 +136,6 @@ def register_default_model_types():
|
|||||||
|
|
||||||
# Register Embedding model type
|
# 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,468 @@
|
|||||||
|
# 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 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",
|
"lora": "loras",
|
||||||
"checkpoint": "checkpoints",
|
"checkpoint": "checkpoints",
|
||||||
"embedding": "embeddings",
|
"embedding": "embeddings",
|
||||||
|
"other": "other",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Module-level alias so tests can spy on timer task creation without patching
|
# Module-level alias so tests can spy on timer task creation without patching
|
||||||
@@ -983,6 +984,7 @@ class PendingDeleteService:
|
|||||||
"get_lora_scanner",
|
"get_lora_scanner",
|
||||||
"get_checkpoint_scanner",
|
"get_checkpoint_scanner",
|
||||||
"get_embedding_scanner",
|
"get_embedding_scanner",
|
||||||
|
"get_other_scanner",
|
||||||
):
|
):
|
||||||
getter = getattr(ServiceRegistry, getter_name, None)
|
getter = getattr(ServiceRegistry, getter_name, None)
|
||||||
if not callable(getter):
|
if not callable(getter):
|
||||||
|
|||||||
@@ -314,6 +314,27 @@ class ServiceRegistry:
|
|||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return scanner
|
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
|
@classmethod
|
||||||
def clear_services(cls):
|
def clear_services(cls):
|
||||||
"""Clear all registered services - mainly for testing"""
|
"""Clear all registered services - mainly for testing"""
|
||||||
|
|||||||
@@ -27,7 +27,9 @@ from platformdirs import user_config_dir
|
|||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
DEFAULT_HASH_CHUNK_SIZE_MB,
|
DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
|
VALID_OTHER_SUB_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||||
from ..utils.settings_paths import (
|
from ..utils.settings_paths import (
|
||||||
@@ -83,6 +85,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_unet_root": "",
|
"default_unet_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
|
"default_other_roots": {},
|
||||||
"recipes_path": "",
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
@@ -309,6 +312,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||||
default_unet_root=merged.get("default_unet_root"),
|
default_unet_root=merged.get("default_unet_root"),
|
||||||
default_embedding_root=merged.get("default_embedding_root"),
|
default_embedding_root=merged.get("default_embedding_root"),
|
||||||
|
default_other_roots=merged.get("default_other_roots"),
|
||||||
recipes_path=merged.get("recipes_path"),
|
recipes_path=merged.get("recipes_path"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -443,6 +447,7 @@ class SettingsManager:
|
|||||||
),
|
),
|
||||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||||
default_embedding_root=self.settings.get("default_embedding_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", ""),
|
recipes_path=self.settings.get("recipes_path", ""),
|
||||||
)
|
)
|
||||||
libraries = {library_name: library_payload}
|
libraries = {library_name: library_payload}
|
||||||
@@ -494,6 +499,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||||
default_unet_root=data.get("default_unet_root"),
|
default_unet_root=data.get("default_unet_root"),
|
||||||
default_embedding_root=data.get("default_embedding_root"),
|
default_embedding_root=data.get("default_embedding_root"),
|
||||||
|
default_other_roots=data.get("default_other_roots"),
|
||||||
recipes_path=data.get("recipes_path"),
|
recipes_path=data.get("recipes_path"),
|
||||||
metadata=data.get("metadata"),
|
metadata=data.get("metadata"),
|
||||||
base=data,
|
base=data,
|
||||||
@@ -541,6 +547,9 @@ class SettingsManager:
|
|||||||
self.settings["default_embedding_root"] = active_library.get(
|
self.settings["default_embedding_root"] = active_library.get(
|
||||||
"default_embedding_root", ""
|
"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", "")
|
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
@@ -558,6 +567,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
base: Optional[Mapping[str, Any]] = None,
|
base: Optional[Mapping[str, Any]] = None,
|
||||||
@@ -597,6 +607,15 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
payload.setdefault("default_embedding_root", "")
|
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:
|
if recipes_path is not None:
|
||||||
payload["recipes_path"] = recipes_path
|
payload["recipes_path"] = recipes_path
|
||||||
else:
|
else:
|
||||||
@@ -632,6 +651,35 @@ class SettingsManager:
|
|||||||
normalized[key] = cleaned
|
normalized[key] = cleaned
|
||||||
return normalized
|
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 _has_configured_paths(self, folder_paths: Any) -> bool:
|
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
||||||
if not isinstance(folder_paths, Mapping):
|
if not isinstance(folder_paths, Mapping):
|
||||||
return False
|
return False
|
||||||
@@ -744,6 +792,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
@@ -794,6 +843,14 @@ class SettingsManager:
|
|||||||
library["default_embedding_root"] = default_embedding_root
|
library["default_embedding_root"] = default_embedding_root
|
||||||
changed = True
|
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:
|
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||||
library["recipes_path"] = recipes_path
|
library["recipes_path"] = recipes_path
|
||||||
changed = True
|
changed = True
|
||||||
@@ -894,12 +951,53 @@ class SettingsManager:
|
|||||||
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
||||||
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
||||||
|
|
||||||
|
# Other-model default roots: one entry per sub_type; candidates are the
|
||||||
|
# union of that sub_type's folder_paths keys (text_encoder merges the
|
||||||
|
# legacy 'clip' key with 'text_encoders').
|
||||||
|
sub_type_folder_keys: Dict[str, List[str]] = {}
|
||||||
|
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||||
|
sub_type_folder_keys.setdefault(sub_type, []).append(folder_key)
|
||||||
|
|
||||||
|
other_roots = self._normalize_default_other_roots(
|
||||||
|
self.settings.get("default_other_roots")
|
||||||
|
)
|
||||||
|
for sub_type in VALID_OTHER_SUB_TYPES:
|
||||||
|
candidates: List[str] = []
|
||||||
|
candidate_identities: set[str] = set()
|
||||||
|
for folder_key in 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:
|
if updated:
|
||||||
|
self.settings["default_other_roots"] = other_roots
|
||||||
self._update_active_library_entry(
|
self._update_active_library_entry(
|
||||||
default_lora_root=self.settings.get("default_lora_root"),
|
default_lora_root=self.settings.get("default_lora_root"),
|
||||||
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
||||||
default_unet_root=self.settings.get("default_unet_root"),
|
default_unet_root=self.settings.get("default_unet_root"),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root"),
|
default_embedding_root=self.settings.get("default_embedding_root"),
|
||||||
|
default_other_roots=other_roots,
|
||||||
)
|
)
|
||||||
if self._bootstrap_reason == "missing":
|
if self._bootstrap_reason == "missing":
|
||||||
self._needs_initial_save = True
|
self._needs_initial_save = True
|
||||||
@@ -1599,6 +1697,8 @@ class SettingsManager:
|
|||||||
value = self.normalize_download_skip_base_models(value)
|
value = self.normalize_download_skip_base_models(value)
|
||||||
elif key == "mature_blur_level":
|
elif key == "mature_blur_level":
|
||||||
value = self.normalize_mature_blur_level(value)
|
value = self.normalize_mature_blur_level(value)
|
||||||
|
elif key == "default_other_roots":
|
||||||
|
value = self._normalize_default_other_roots(value, strict=True)
|
||||||
elif key == "recipes_path":
|
elif key == "recipes_path":
|
||||||
current_recipes_dir = self._get_effective_recipes_dir()
|
current_recipes_dir = self._get_effective_recipes_dir()
|
||||||
value = self._normalize_recipes_path_value(value)
|
value = self._normalize_recipes_path_value(value)
|
||||||
@@ -1626,6 +1726,8 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(default_unet_root=str(value))
|
self._update_active_library_entry(default_unet_root=str(value))
|
||||||
elif key == "default_embedding_root":
|
elif key == "default_embedding_root":
|
||||||
self._update_active_library_entry(default_embedding_root=str(value))
|
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":
|
elif key == "recipes_path":
|
||||||
self._update_active_library_entry(recipes_path=str(value))
|
self._update_active_library_entry(recipes_path=str(value))
|
||||||
elif key == "model_name_display":
|
elif key == "model_name_display":
|
||||||
@@ -1796,6 +1898,7 @@ class SettingsManager:
|
|||||||
"lora_scanner",
|
"lora_scanner",
|
||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
|
"other_scanner",
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
):
|
):
|
||||||
service = ServiceRegistry.get_service_sync(service_name)
|
service = ServiceRegistry.get_service_sync(service_name)
|
||||||
@@ -1960,6 +2063,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -2004,6 +2108,11 @@ class SettingsManager:
|
|||||||
if default_embedding_root is not None
|
if default_embedding_root is not None
|
||||||
else existing.get("default_embedding_root")
|
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=(
|
||||||
recipes_path
|
recipes_path
|
||||||
if recipes_path is not None
|
if recipes_path is not None
|
||||||
@@ -2036,6 +2145,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: str = "",
|
default_checkpoint_root: str = "",
|
||||||
default_unet_root: str = "",
|
default_unet_root: str = "",
|
||||||
default_embedding_root: str = "",
|
default_embedding_root: str = "",
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: str = "",
|
recipes_path: str = "",
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -2054,6 +2164,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
default_other_roots=default_other_roots,
|
||||||
recipes_path=recipes_path,
|
recipes_path=recipes_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=activate,
|
activate=activate,
|
||||||
@@ -2114,6 +2225,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update folder paths for the active library."""
|
"""Update folder paths for the active library."""
|
||||||
@@ -2127,6 +2239,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
default_other_roots=default_other_roots,
|
||||||
recipes_path=recipes_path,
|
recipes_path=recipes_path,
|
||||||
activate=True,
|
activate=True,
|
||||||
)
|
)
|
||||||
@@ -2151,6 +2264,7 @@ class SettingsManager:
|
|||||||
"lora_scanner",
|
"lora_scanner",
|
||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
|
"other_scanner",
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
"model_update_service",
|
"model_update_service",
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -83,6 +83,63 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
|||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
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",
|
||||||
|
}
|
||||||
|
# folder_paths keys scanned by default; anything else in
|
||||||
|
# OTHER_MODEL_FOLDER_SUBTYPES (e.g. controlnet) is opt-in via the
|
||||||
|
# "enabled_other_folders" setting.
|
||||||
|
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 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
|
# Backward compatibility alias
|
||||||
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
||||||
|
|
||||||
@@ -91,6 +148,7 @@ CIVITAI_USER_MODEL_TYPES = [
|
|||||||
*VALID_LORA_TYPES,
|
*VALID_LORA_TYPES,
|
||||||
"textualinversion",
|
"textualinversion",
|
||||||
"checkpoint",
|
"checkpoint",
|
||||||
|
*sorted(VALID_OTHER_CIVITAI_TYPES),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default chunk size in megabytes used for hashing large files.
|
# Default chunk size in megabytes used for hashing large files.
|
||||||
|
|||||||
@@ -420,6 +420,10 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", 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)
|
# Load progress file to check processed models (async to avoid blocking)
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
active_library = settings_manager.get_active_library_name()
|
active_library = settings_manager.get_active_library_name()
|
||||||
@@ -600,6 +604,10 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", 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
|
# Get all models
|
||||||
all_models = []
|
all_models = []
|
||||||
for scanner_type, scanner in scanners:
|
for scanner_type, scanner in scanners:
|
||||||
@@ -1098,6 +1106,10 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", 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
|
# Find the specified models
|
||||||
models_to_process = []
|
models_to_process = []
|
||||||
for scanner_type, scanner in scanners:
|
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 typing import Callable, Dict, Optional, List, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
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
|
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
|
@dataclass
|
||||||
class EmbeddingMetadata(BaseModelMetadata):
|
class EmbeddingMetadata(BaseModelMetadata):
|
||||||
"""Represents the metadata structure for an Embedding model"""
|
"""Represents the metadata structure for an Embedding model"""
|
||||||
|
|||||||
@@ -17,7 +17,20 @@
|
|||||||
"embeddings": [
|
"embeddings": [
|
||||||
"C:/path/to/your/embeddings_folder",
|
"C:/path/to/your/embeddings_folder",
|
||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
|
],
|
||||||
|
"vae": [
|
||||||
|
"C:/path/to/your/vae_folder"
|
||||||
|
],
|
||||||
|
"upscale_models": [
|
||||||
|
"C:/path/to/your/upscale_models_folder"
|
||||||
|
],
|
||||||
|
"text_encoders": [
|
||||||
|
"C:/path/to/your/text_encoders_folder"
|
||||||
|
],
|
||||||
|
"clip_vision": [
|
||||||
|
"C:/path/to/your/clip_vision_folder"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"default_other_roots": {},
|
||||||
"auto_organize_exclusions": []
|
"auto_organize_exclusions": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { state } from '../state/index.js';
|
|||||||
export const MODEL_TYPES = {
|
export const MODEL_TYPES = {
|
||||||
LORA: 'loras',
|
LORA: 'loras',
|
||||||
CHECKPOINT: 'checkpoints',
|
CHECKPOINT: 'checkpoints',
|
||||||
EMBEDDING: 'embeddings' // Future model type
|
EMBEDDING: 'embeddings',
|
||||||
|
OTHER: 'other'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base API configuration for each model type
|
// Base API configuration for each model type
|
||||||
@@ -40,6 +41,15 @@ export const MODEL_CONFIG = {
|
|||||||
supportsBulkOperations: true,
|
supportsBulkOperations: true,
|
||||||
supportsMove: true,
|
supportsMove: true,
|
||||||
templateName: 'embeddings.html'
|
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]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
|
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 { LoraApiClient } from './loraApi.js';
|
||||||
import { CheckpointApiClient } from './checkpointApi.js';
|
import { CheckpointApiClient } from './checkpointApi.js';
|
||||||
import { EmbeddingApiClient } from './embeddingApi.js';
|
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||||
|
import { OtherApiClient } from './otherApi.js';
|
||||||
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ export function createModelApiClient(modelType) {
|
|||||||
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
||||||
case MODEL_TYPES.EMBEDDING:
|
case MODEL_TYPES.EMBEDDING:
|
||||||
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
||||||
|
case MODEL_TYPES.OTHER:
|
||||||
|
return new OtherApiClient(MODEL_TYPES.OTHER);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported model type: ${modelType}`);
|
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"]');
|
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
|
||||||
if (downloadExampleImagesSubmenu) {
|
if (downloadExampleImagesSubmenu) {
|
||||||
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
// Show on model pages (loras, checkpoints, embeddings, other), hide on recipes
|
||||||
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
|
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export const ModelContextMenuMixin = {
|
|||||||
const prefixMap = {
|
const prefixMap = {
|
||||||
lora: 'loras',
|
lora: 'loras',
|
||||||
checkpoint: 'checkpoints',
|
checkpoint: 'checkpoints',
|
||||||
embedding: 'embeddings'
|
embedding: 'embeddings',
|
||||||
|
other: 'other'
|
||||||
};
|
};
|
||||||
return prefixMap[this.modelType] || 'loras';
|
return prefixMap[this.modelType] || 'loras';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
|
export { OtherContextMenu } from './OtherContextMenu.js';
|
||||||
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
|
import { OtherContextMenu } from './OtherContextMenu.js';
|
||||||
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
|
|
||||||
// Factory method to create page-specific context menu instances
|
// Factory method to create page-specific context menu instances
|
||||||
@@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) {
|
|||||||
return new CheckpointContextMenu();
|
return new CheckpointContextMenu();
|
||||||
case 'embeddings':
|
case 'embeddings':
|
||||||
return new EmbeddingContextMenu();
|
return new EmbeddingContextMenu();
|
||||||
|
case 'other':
|
||||||
|
return new OtherContextMenu();
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class HeaderManager {
|
|||||||
if (path.includes('/loras/recipes')) return 'recipes';
|
if (path.includes('/loras/recipes')) return 'recipes';
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
|
if (path.includes('/other')) return 'other';
|
||||||
if (path.includes('/statistics')) return 'statistics';
|
if (path.includes('/statistics')) return 'statistics';
|
||||||
if (path.includes('/loras')) return 'loras';
|
if (path.includes('/loras')) return 'loras';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
|
|||||||
@@ -1126,6 +1126,7 @@ export class SidebarManager {
|
|||||||
recipes: 'Recipes',
|
recipes: 'Recipes',
|
||||||
checkpoints: 'Checkpoints',
|
checkpoints: 'Checkpoints',
|
||||||
embeddings: 'Embeddings',
|
embeddings: 'Embeddings',
|
||||||
|
other: 'Other Models',
|
||||||
};
|
};
|
||||||
return names[this.pageType] || this.pageType;
|
return names[this.pageType] || this.pageType;
|
||||||
}
|
}
|
||||||
@@ -1804,7 +1805,7 @@ export class SidebarManager {
|
|||||||
_migrateOldSettings() {
|
_migrateOldSettings() {
|
||||||
if (getStorageItem('_sidebar_migration_done')) return;
|
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
|
// 1. Migrate global hide setting to per-page
|
||||||
if (state?.global?.settings?.show_folder_sidebar === false) {
|
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 { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
||||||
|
import { OtherControls } from './OtherControls.js';
|
||||||
|
|
||||||
// Export the classes
|
// 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
|
* 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
|
* @returns {PageControls} - The appropriate controls instance
|
||||||
*/
|
*/
|
||||||
export function createPageControls(pageType) {
|
export function createPageControls(pageType) {
|
||||||
@@ -19,6 +20,8 @@ export function createPageControls(pageType) {
|
|||||||
return new CheckpointsControls();
|
return new CheckpointsControls();
|
||||||
} else if (pageType === 'embeddings') {
|
} else if (pageType === 'embeddings') {
|
||||||
return new EmbeddingsControls();
|
return new EmbeddingsControls();
|
||||||
|
} else if (pageType === 'other') {
|
||||||
|
return new OtherControls();
|
||||||
} else {
|
} else {
|
||||||
console.error(`Unknown page type: ${pageType}`);
|
console.error(`Unknown page type: ${pageType}`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class InitializationManager {
|
|||||||
this.pageType = 'recipes';
|
this.pageType = 'recipes';
|
||||||
} else if (path.includes('/checkpoints')) {
|
} else if (path.includes('/checkpoints')) {
|
||||||
this.pageType = 'checkpoints';
|
this.pageType = 'checkpoints';
|
||||||
|
} else if (path.includes('/other')) {
|
||||||
|
this.pageType = 'other';
|
||||||
} else if (path.includes('/loras')) {
|
} else if (path.includes('/loras')) {
|
||||||
this.pageType = 'loras';
|
this.pageType = 'loras';
|
||||||
} else if (path.includes('/embeddings')) {
|
} else if (path.includes('/embeddings')) {
|
||||||
@@ -221,6 +223,7 @@ class InitializationManager {
|
|||||||
'lora': 'loras',
|
'lora': 'loras',
|
||||||
'checkpoint': 'checkpoints',
|
'checkpoint': 'checkpoints',
|
||||||
'embedding': 'embeddings',
|
'embedding': 'embeddings',
|
||||||
|
'other': 'other',
|
||||||
'recipe': 'recipes'
|
'recipe': 'recipes'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,11 @@ function handleCopyAction(card, modelType) {
|
|||||||
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
||||||
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
||||||
copyToClipboard(embeddingCode, message);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -116,7 +116,7 @@ export class AppCore {
|
|||||||
initializePageFeatures() {
|
initializePageFeatures() {
|
||||||
const pageType = this.getPageType();
|
const pageType = this.getPageType();
|
||||||
|
|
||||||
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
|
||||||
this.initializeContextMenus(pageType);
|
this.initializeContextMenus(pageType);
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -424,12 +424,13 @@ class BannerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current page type from the URL
|
* 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() {
|
getCurrentPageType() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
|
if (path.includes('/other')) return 'other';
|
||||||
if (path.includes('/recipes')) return 'recipes';
|
if (path.includes('/recipes')) return 'recipes';
|
||||||
return 'loras';
|
return 'loras';
|
||||||
}
|
}
|
||||||
@@ -443,7 +444,8 @@ class BannerService {
|
|||||||
const endpoints = {
|
const endpoints = {
|
||||||
'loras': '/api/lm/loras/reload?rebuild=true',
|
'loras': '/api/lm/loras/reload?rebuild=true',
|
||||||
'checkpoints': '/api/lm/checkpoints/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'];
|
return endpoints[pageType] || endpoints['loras'];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ export class BulkManager {
|
|||||||
setFavorite: true,
|
setFavorite: true,
|
||||||
unfavorite: 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: {
|
recipes: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
sendToWorkflow: false,
|
sendToWorkflow: false,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
|||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.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 { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
|
||||||
import { formatFileSize } from '../utils/formatters.js';
|
import { formatFileSize } from '../utils/formatters.js';
|
||||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||||
@@ -956,11 +957,17 @@ export class DownloadManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||||
|
this._otherSubType = await this._resolveOtherSubType();
|
||||||
|
|
||||||
let rootsData;
|
let rootsData;
|
||||||
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||||
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||||
|
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
|
||||||
|
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
|
||||||
} else {
|
} 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();
|
rootsData = await this.apiClient.fetchModelRoots();
|
||||||
}
|
}
|
||||||
const modelRoot = document.getElementById('modelRoot');
|
const modelRoot = document.getElementById('modelRoot');
|
||||||
@@ -968,19 +975,29 @@ export class DownloadManager {
|
|||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const singularType = this._isDiffusionModel
|
let defaultRoot;
|
||||||
? 'unet'
|
let subtypeDisplay;
|
||||||
: this.apiClient.modelType.replace(/s$/, '');
|
if (this.apiClient.modelType === 'other') {
|
||||||
const defaultRootKey = `default_${singularType}_root`;
|
const otherDefaultRoots = state.global.settings.default_other_roots || {};
|
||||||
const defaultRoot = state.global.settings[defaultRootKey];
|
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
|
||||||
console.log(`Default root for ${singularType}:`, defaultRoot);
|
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);
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
console.log(`Setting default root: ${defaultRoot}`);
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
|
||||||
document.getElementById('modelRootLabel').textContent =
|
document.getElementById('modelRootLabel').textContent =
|
||||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||||
|
|
||||||
@@ -1065,6 +1082,50 @@ export class DownloadManager {
|
|||||||
return localFileTypeCheck;
|
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();
|
||||||
|
return data.sub_type || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[download] other routing endpoint unavailable, '
|
||||||
|
+ 'falling back to manual root selection:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadDefaultPathSetting() {
|
loadDefaultPathSetting() {
|
||||||
const modelType = this.apiClient.modelType;
|
const modelType = this.apiClient.modelType;
|
||||||
const storageKey = `use_default_path_${modelType}`;
|
const storageKey = `use_default_path_${modelType}`;
|
||||||
|
|||||||
@@ -805,7 +805,7 @@ export class FilterManager {
|
|||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
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
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
}
|
}
|
||||||
@@ -904,7 +904,7 @@ export class FilterManager {
|
|||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
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);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class MoveManager {
|
|||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
const apiClient = this._getApiClient(modelType);
|
const apiClient = this._getApiClient(modelType);
|
||||||
const currentPageType = state.currentPageType;
|
|
||||||
const modelConfig = apiClient.apiConfig.config;
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
// Handle bulk mode
|
// Handle bulk mode
|
||||||
@@ -113,7 +112,7 @@ class MoveManager {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default root if available
|
// 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];
|
const defaultRoot = state.global.settings[settingsKey];
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
modelRootSelect.value = defaultRoot;
|
modelRootSelect.value = defaultRoot;
|
||||||
@@ -228,13 +227,12 @@ class MoveManager {
|
|||||||
if (modelRoot) {
|
if (modelRoot) {
|
||||||
if (this.useDefaultPath) {
|
if (this.useDefaultPath) {
|
||||||
// Show actual template path
|
// Show actual template path
|
||||||
try {
|
const singularType = config.singularName || apiClient.modelType.replace(/s$/, '');
|
||||||
const singularType = apiClient.modelType.replace(/s$/, '');
|
const templates = state.global.settings.download_path_templates;
|
||||||
const templates = state.global.settings.download_path_templates;
|
const template = templates[singularType];
|
||||||
const template = templates[singularType];
|
if (template) {
|
||||||
fullPath += `/${template}`;
|
fullPath += `/${template}`;
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('Failed to fetch template:', error);
|
|
||||||
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ export class SearchManager {
|
|||||||
pageState.searchOptions.loraName = options.loraName || false;
|
pageState.searchOptions.loraName = options.loraName || false;
|
||||||
pageState.searchOptions.loraModel = options.loraModel || false;
|
pageState.searchOptions.loraModel = options.loraModel || false;
|
||||||
pageState.searchOptions.prompt = options.prompt || 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
|
// Update only the relevant fields in searchOptions instead of replacing the whole object
|
||||||
pageState.searchOptions.filename = options.filename || false;
|
pageState.searchOptions.filename = options.filename || false;
|
||||||
pageState.searchOptions.modelname = options.modelname || 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
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes(true);
|
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
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1153,6 +1153,9 @@ export class SettingsManager {
|
|||||||
// Load default unet root
|
// Load default unet root
|
||||||
await this.loadUnetRoots();
|
await this.loadUnetRoots();
|
||||||
|
|
||||||
|
// Load default other-model roots (per sub_type)
|
||||||
|
await this.loadOtherRoots();
|
||||||
|
|
||||||
// Load extra folder paths
|
// Load extra folder paths
|
||||||
this.loadExtraFolderPaths();
|
this.loadExtraFolderPaths();
|
||||||
|
|
||||||
@@ -1658,6 +1661,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() {
|
async loadEmbeddingRoots() {
|
||||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||||
if (!defaultEmbeddingRootSelect) return;
|
if (!defaultEmbeddingRootSelect) return;
|
||||||
@@ -2339,6 +2387,27 @@ 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
|
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
|
||||||
* Shared entry point for the settings modal segmented control and the
|
* Shared entry point for the settings modal segmented control and the
|
||||||
@@ -3360,6 +3429,9 @@ export class SettingsManager {
|
|||||||
} else if (this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'embeddings') {
|
||||||
// Reload the embeddings without updating folders
|
// Reload the embeddings without updating folders
|
||||||
await resetAndReload(false);
|
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 };
|
||||||
@@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
default_lora_root: '',
|
default_lora_root: '',
|
||||||
default_checkpoint_root: '',
|
default_checkpoint_root: '',
|
||||||
default_embedding_root: '',
|
default_embedding_root: '',
|
||||||
|
default_other_roots: {},
|
||||||
recipes_path: '',
|
recipes_path: '',
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
@@ -72,6 +73,7 @@ export function createDefaultSettings() {
|
|||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
|
default_other_roots: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +81,7 @@ export function createDefaultSettings() {
|
|||||||
const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
|
const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
|
||||||
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
|
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
|
||||||
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
|
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
|
||||||
|
const otherPreviewVersions = getMapFromStorage('other_preview_versions');
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
// Global state
|
// Global state
|
||||||
@@ -234,6 +237,44 @@ export const state = {
|
|||||||
search: '',
|
search: '',
|
||||||
},
|
},
|
||||||
activeViewSnapshot: null,
|
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.
|
* Register the storage listener and push the current (restored) state once.
|
||||||
* The initial push covers server restarts, where the backend store is empty
|
* The initial push covers server restarts, where the backend store is empty
|
||||||
* until the manager page re-publishes its localStorage-restored filters.
|
* 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) {
|
export function initActiveFiltersSync(pageType) {
|
||||||
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
|
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
|||||||
diffusion_model: "Diffusion Model",
|
diffusion_model: "Diffusion Model",
|
||||||
// Embedding sub-types
|
// Embedding sub-types
|
||||||
embedding: "Embedding",
|
embedding: "Embedding",
|
||||||
|
// Other model sub-types
|
||||||
|
vae: "VAE",
|
||||||
|
upscaler: "Upscaler",
|
||||||
|
text_encoder: "Text Encoder",
|
||||||
|
clip_vision: "CLIP Vision",
|
||||||
|
controlnet: "ControlNet",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Backward compatibility alias
|
// Backward compatibility alias
|
||||||
@@ -119,6 +125,11 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = {
|
|||||||
checkpoint: "CKPT",
|
checkpoint: "CKPT",
|
||||||
diffusion_model: "DM",
|
diffusion_model: "DM",
|
||||||
embedding: "EMB",
|
embedding: "EMB",
|
||||||
|
vae: "VAE",
|
||||||
|
upscaler: "UPS",
|
||||||
|
text_encoder: "TE",
|
||||||
|
clip_vision: "CV",
|
||||||
|
controlnet: "CN",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSubTypeAbbreviation(subType) {
|
export function getSubTypeAbbreviation(subType) {
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async function getCardCreator(pageType) {
|
|||||||
|
|
||||||
// Function to get the appropriate data fetcher based on page type
|
// Function to get the appropriate data fetcher based on page type
|
||||||
async function getDataFetcher(pageType) {
|
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);
|
return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize);
|
||||||
} else if (pageType === 'recipes') {
|
} else if (pageType === 'recipes') {
|
||||||
// Import the recipeApi module and use the fetchRecipesPage function
|
// Import the recipeApi module and use the fetchRecipesPage function
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = 'lora_manager_';
|
|||||||
|
|
||||||
// Matches keys that carry the manager page's active filter state
|
// Matches keys that carry the manager page's active filter state
|
||||||
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
|
// (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;
|
let activeFiltersListener = null;
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
|
<option value="size:asc">{{ t('loras.controls.sort.sizeAsc') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% 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') }}">
|
<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: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>
|
<option value="usage:asc">{{ t('loras.controls.sort.usageAsc', default='Times used (low to high)') }}</option>
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
{% set current_page = 'checkpoints' %}
|
{% set current_page = 'checkpoints' %}
|
||||||
{% elif current_path.startswith('/embeddings') %}
|
{% elif current_path.startswith('/embeddings') %}
|
||||||
{% set current_page = 'embeddings' %}
|
{% set current_page = 'embeddings' %}
|
||||||
|
{% elif current_path.startswith('/other') %}
|
||||||
|
{% set current_page = 'other' %}
|
||||||
{% elif current_path.startswith('/statistics') %}
|
{% elif current_path.startswith('/statistics') %}
|
||||||
{% set current_page = 'statistics' %}
|
{% set current_page = 'statistics' %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -36,6 +38,10 @@
|
|||||||
id="embeddingsNavItem">
|
id="embeddingsNavItem">
|
||||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/other" class="nav-item{% if current_path.startswith('/other') %} active{% endif %}"
|
||||||
|
id="otherNavItem">
|
||||||
|
<i class="fas fa-cubes"></i> <span>{{ t('header.navigation.other') }}</span>
|
||||||
|
</a>
|
||||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||||
id="statisticsNavItem">
|
id="statisticsNavItem">
|
||||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||||
@@ -208,7 +214,7 @@
|
|||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<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="creator">{{ t('header.search.filters.creator') }}</div>
|
||||||
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</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="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="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||||
@@ -295,7 +301,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
<div class="filter-section">
|
||||||
<h4>{{ t('header.filter.modelTypes') }}</h4>
|
<h4>{{ t('header.filter.modelTypes') }}</h4>
|
||||||
<div class="filter-tags" id="modelTypeTags">
|
<div class="filter-tags" id="modelTypeTags">
|
||||||
|
|||||||
@@ -38,6 +38,34 @@
|
|||||||
{{ sm.setting_select('defaultUnetRoot', 'default_unet_root', 'settings.folderSettings.defaultUnetRoot', [], 'settings.folderSettings.defaultUnetRootHelp') }}
|
{{ 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_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }}
|
||||||
|
|
||||||
|
{% 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'),
|
||||||
|
] %}
|
||||||
|
{% if 'controlnet' in (settings.get('enabled_other_folders') or []) %}
|
||||||
|
{% set other_root_selects = other_root_selects + [
|
||||||
|
('controlnet', 'defaultOtherRootControlnet', 'settings.folderSettings.defaultControlnetRoot', 'settings.folderSettings.defaultControlnetRootHelp'),
|
||||||
|
] %}
|
||||||
|
{% endif %}
|
||||||
|
{% 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 }}" onchange="settingsManager.saveOtherRootSetting('{{ sub_type }}', this.value)">
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recipe Settings -->
|
<!-- Recipe Settings -->
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
{% 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 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 %}
|
||||||
|
<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>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlay %}
|
||||||
|
<div class="bulk-mode-overlay"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block main_script %}
|
||||||
|
<script type="module" src="/loras_static/js/other.js?v={{ version }}"></script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
|
||||||
|
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_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_controlnet(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()
|
||||||
|
|
||||||
|
assert _normalize(dirs["controlnet"]) not in roots
|
||||||
|
assert _normalize(dirs["controlnet"]) not in config.other_root_subtypes
|
||||||
|
for key in ("vae", "upscale_models", "text_encoders", "clip", "clip_vision"):
|
||||||
|
assert _normalize(dirs[key]) in roots
|
||||||
|
|
||||||
|
def test_controlnet_opt_in_via_setting(self, monkeypatch, tmp_path):
|
||||||
|
controlnet_dir = tmp_path / "controlnet"
|
||||||
|
controlnet_dir.mkdir()
|
||||||
|
|
||||||
|
self._stub_folder_paths(monkeypatch, {"controlnet": str(controlnet_dir)})
|
||||||
|
get_settings_manager().set("enabled_other_folders", ["controlnet"])
|
||||||
|
|
||||||
|
config = _make_config()
|
||||||
|
roots = config._init_other_paths()
|
||||||
|
|
||||||
|
assert _normalize(str(controlnet_dir)) in roots
|
||||||
|
assert (
|
||||||
|
config.other_root_subtypes[_normalize(str(controlnet_dir))]
|
||||||
|
== "controlnet"
|
||||||
|
)
|
||||||
|
|
||||||
|
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_folders", ["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"))
|
||||||
@@ -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,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,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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem
|
|||||||
import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js';
|
import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js';
|
||||||
import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.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', () => {
|
describe('AppCore page orchestration', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ vi.mock(SUMMARY_MODULE, () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
|
const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE);
|
||||||
|
const { state } = await import(STATE_MODULE);
|
||||||
|
|
||||||
describe('DownloadManager._resolveIsDiffusionModel', () => {
|
describe('DownloadManager._resolveIsDiffusionModel', () => {
|
||||||
let manager;
|
let manager;
|
||||||
@@ -144,3 +145,185 @@ describe('DownloadManager._resolveIsDiffusionModel', () => {
|
|||||||
expect(fetchMock).not.toHaveBeenCalled();
|
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('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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ vi.mock('../../../static/js/state/index.js', () => {
|
|||||||
},
|
},
|
||||||
createDefaultSettings: () => ({
|
createDefaultSettings: () => ({
|
||||||
language: 'en',
|
language: 'en',
|
||||||
|
default_other_roots: {},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -502,6 +503,182 @@ 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('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 recipes layout switch', () => {
|
describe('SettingsManager recipes layout switch', () => {
|
||||||
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
|
it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => {
|
||||||
const manager = createManager();
|
const manager = createManager();
|
||||||
|
|||||||
@@ -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.
|
* Renders the Recipes page template with expected dataset attributes.
|
||||||
* @returns {Element}
|
* @returns {Element}
|
||||||
|
|||||||
@@ -91,3 +91,61 @@ async def test_invalid_json_rejected():
|
|||||||
FakeRequest(json.JSONDecodeError("bad", "", 0))
|
FakeRequest(json.JSONDecodeError("bad", "", 0))
|
||||||
)
|
)
|
||||||
assert response.status == 400
|
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
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
|
|||||||
"lora": _DummyScanner("lora"),
|
"lora": _DummyScanner("lora"),
|
||||||
"checkpoint": _DummyScanner("checkpoint"),
|
"checkpoint": _DummyScanner("checkpoint"),
|
||||||
"embedding": _DummyScanner("embedding"),
|
"embedding": _DummyScanner("embedding"),
|
||||||
|
"other": _DummyScanner("other"),
|
||||||
"recipe": _DummyScanner("recipe"),
|
"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_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_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_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"]))
|
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_recipe_scanner", lambda: _stub("recipe_scanner", scanners["recipe"]))
|
||||||
|
|
||||||
migration_calls: list[bool] = []
|
migration_calls: list[bool] = []
|
||||||
@@ -205,7 +207,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path:
|
|||||||
await asyncio.gather(*pending)
|
await asyncio.gather(*pending)
|
||||||
|
|
||||||
task_names = {task.get_name() for task in scheduled_tasks}
|
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
|
# Startup sweep: an expired pending-delete purge task is spawned during
|
||||||
# service initialization (covers both plugin and standalone modes).
|
# 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):
|
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 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)
|
||||||
|
|||||||
@@ -1133,8 +1133,8 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": 4,
|
"id": 4,
|
||||||
"name": "Unsupported",
|
"name": "VAE Model",
|
||||||
"type": "Other",
|
"type": "VAE",
|
||||||
"modelVersions": [
|
"modelVersions": [
|
||||||
{
|
{
|
||||||
"id": 400,
|
"id": 400,
|
||||||
@@ -1142,6 +1142,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)
|
provider = FakeUserModelsProvider(models)
|
||||||
@@ -1152,6 +1163,7 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
lora_scanner = FakeExistenceScanner({101})
|
lora_scanner = FakeExistenceScanner({101})
|
||||||
checkpoint_scanner = FakeExistenceScanner()
|
checkpoint_scanner = FakeExistenceScanner()
|
||||||
embedding_scanner = FakeExistenceScanner({202})
|
embedding_scanner = FakeExistenceScanner({202})
|
||||||
|
other_scanner = FakeExistenceScanner({400})
|
||||||
|
|
||||||
async def lora_factory():
|
async def lora_factory():
|
||||||
return lora_scanner
|
return lora_scanner
|
||||||
@@ -1162,11 +1174,15 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
async def embedding_factory():
|
async def embedding_factory():
|
||||||
return embedding_scanner
|
return embedding_scanner
|
||||||
|
|
||||||
|
async def other_factory():
|
||||||
|
return other_scanner
|
||||||
|
|
||||||
handler = ModelLibraryHandler(
|
handler = ModelLibraryHandler(
|
||||||
ServiceRegistryAdapter(
|
ServiceRegistryAdapter(
|
||||||
get_lora_scanner=lora_factory,
|
get_lora_scanner=lora_factory,
|
||||||
get_checkpoint_scanner=checkpoint_factory,
|
get_checkpoint_scanner=checkpoint_factory,
|
||||||
get_embedding_scanner=embedding_factory,
|
get_embedding_scanner=embedding_factory,
|
||||||
|
get_other_scanner=other_factory,
|
||||||
get_downloaded_version_history_service=lambda: fake_download_history_service_factory(),
|
get_downloaded_version_history_service=lambda: fake_download_history_service_factory(),
|
||||||
),
|
),
|
||||||
metadata_provider_factory=provider_factory,
|
metadata_provider_factory=provider_factory,
|
||||||
@@ -1240,6 +1256,18 @@ async def test_get_civitai_user_models_marks_library_versions():
|
|||||||
"inLibrary": False,
|
"inLibrary": False,
|
||||||
"hasBeenDownloaded": 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"]
|
assert provider.received_usernames == ["pixel"]
|
||||||
@@ -1351,7 +1379,7 @@ async def test_get_civitai_user_models_returns_pagination_fields():
|
|||||||
{
|
{
|
||||||
"id": 2,
|
"id": 2,
|
||||||
"name": "Unsupported",
|
"name": "Unsupported",
|
||||||
"type": "Other",
|
"type": "Wildcard",
|
||||||
"modelVersions": [{"id": 200, "name": "v1"}],
|
"modelVersions": [{"id": 200, "name": "v1"}],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,165 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
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_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,565 @@
|
|||||||
|
"""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"),
|
||||||
|
},
|
||||||
|
"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_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"])
|
||||||
|
|
||||||
|
|
||||||
|
@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 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 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"
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
def test_empty_inputs_stay_on_checkpoint_roots():
|
||||||
assert not is_diffusion_model_download("checkpoint")
|
assert not is_diffusion_model_download("checkpoint")
|
||||||
assert not is_diffusion_model_download("checkpoint", file_types=[], base_model="")
|
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()
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""Tests for OtherScanner: root aggregation, sub_type derivation, lazy hash."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py import config as config_module
|
||||||
|
from py.services import model_scanner
|
||||||
|
from py.services.model_scanner import ModelScanner
|
||||||
|
from py.services.other_scanner import OtherScanner
|
||||||
|
from py.utils.models import OtherModelMetadata
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize(path) -> str:
|
||||||
|
return str(path).replace(os.sep, "/")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_model_scanner_singletons():
|
||||||
|
ModelScanner._instances.clear()
|
||||||
|
ModelScanner._locks.clear()
|
||||||
|
yield
|
||||||
|
ModelScanner._instances.clear()
|
||||||
|
ModelScanner._locks.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_config(monkeypatch, tmp_path):
|
||||||
|
"""Point the global config at a synthetic set of other-model roots."""
|
||||||
|
vae_root = tmp_path / "vae"
|
||||||
|
upscaler_root = tmp_path / "upscale_models"
|
||||||
|
te_root = tmp_path / "text_encoders"
|
||||||
|
clip_root = tmp_path / "clip"
|
||||||
|
clip_vision_root = tmp_path / "clip_vision"
|
||||||
|
for root in (vae_root, upscaler_root, te_root, clip_root, clip_vision_root):
|
||||||
|
root.mkdir()
|
||||||
|
|
||||||
|
roots = [
|
||||||
|
_normalize(vae_root),
|
||||||
|
_normalize(upscaler_root),
|
||||||
|
_normalize(te_root),
|
||||||
|
_normalize(clip_root),
|
||||||
|
_normalize(clip_vision_root),
|
||||||
|
]
|
||||||
|
subtypes = {
|
||||||
|
_normalize(vae_root): "vae",
|
||||||
|
_normalize(upscaler_root): "upscaler",
|
||||||
|
_normalize(te_root): "text_encoder",
|
||||||
|
_normalize(clip_root): "text_encoder",
|
||||||
|
_normalize(clip_vision_root): "clip_vision",
|
||||||
|
}
|
||||||
|
monkeypatch.setattr(config_module.config, "other_roots", roots)
|
||||||
|
monkeypatch.setattr(config_module.config, "other_root_subtypes", subtypes)
|
||||||
|
return {
|
||||||
|
"roots": roots,
|
||||||
|
"subtypes": subtypes,
|
||||||
|
"vae": _normalize(vae_root),
|
||||||
|
"upscaler": _normalize(upscaler_root),
|
||||||
|
"text_encoders": _normalize(te_root),
|
||||||
|
"clip": _normalize(clip_root),
|
||||||
|
"clip_vision": _normalize(clip_vision_root),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _make_scanner() -> OtherScanner:
|
||||||
|
"""Create a scanner without __init__ to avoid async initialization."""
|
||||||
|
scanner = object.__new__(OtherScanner)
|
||||||
|
scanner.model_type = "other"
|
||||||
|
scanner.model_class = OtherModelMetadata
|
||||||
|
scanner.file_extensions = {".safetensors", ".pt", ".bin"}
|
||||||
|
scanner._hash_index = MagicMock()
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherScannerRoots:
|
||||||
|
"""Root aggregation and sub_type resolution."""
|
||||||
|
|
||||||
|
def test_get_model_roots_aggregates_and_dedupes(self, other_config, monkeypatch):
|
||||||
|
scanner = _make_scanner()
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config_module.config,
|
||||||
|
"other_roots",
|
||||||
|
other_config["roots"] + [other_config["vae"]],
|
||||||
|
)
|
||||||
|
roots = scanner.get_model_roots()
|
||||||
|
assert roots == other_config["roots"]
|
||||||
|
|
||||||
|
def test_get_model_roots_empty_when_unconfigured(self, monkeypatch):
|
||||||
|
scanner = _make_scanner()
|
||||||
|
monkeypatch.setattr(config_module.config, "other_roots", None)
|
||||||
|
assert scanner.get_model_roots() == []
|
||||||
|
|
||||||
|
def test_resolve_sub_type_for_each_default_category(self, other_config):
|
||||||
|
scanner = _make_scanner()
|
||||||
|
cases = [
|
||||||
|
(other_config["vae"], "vae"),
|
||||||
|
(other_config["upscaler"], "upscaler"),
|
||||||
|
(other_config["text_encoders"], "text_encoder"),
|
||||||
|
# Legacy ComfyUI 'clip' key maps to text_encoder as well
|
||||||
|
(other_config["clip"], "text_encoder"),
|
||||||
|
(other_config["clip_vision"], "clip_vision"),
|
||||||
|
]
|
||||||
|
for root, expected in cases:
|
||||||
|
file_path = f"{root}/model.safetensors"
|
||||||
|
assert scanner.resolve_sub_type_for_path(file_path) == expected
|
||||||
|
|
||||||
|
def test_resolve_sub_type_longest_prefix_wins(self, monkeypatch, tmp_path):
|
||||||
|
"""A nested root (controlnet inside vae) resolves to the inner category."""
|
||||||
|
outer = tmp_path / "vae"
|
||||||
|
inner = outer / "controlnet"
|
||||||
|
inner.mkdir(parents=True)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
config_module.config,
|
||||||
|
"other_root_subtypes",
|
||||||
|
{_normalize(outer): "vae", _normalize(inner): "controlnet"},
|
||||||
|
)
|
||||||
|
scanner = _make_scanner()
|
||||||
|
assert (
|
||||||
|
scanner.resolve_sub_type_for_path(f"{_normalize(inner)}/cn.safetensors")
|
||||||
|
== "controlnet"
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
scanner.resolve_sub_type_for_path(f"{_normalize(outer)}/vae.safetensors")
|
||||||
|
== "vae"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_resolve_sub_type_none_for_unknown_or_empty(self, other_config):
|
||||||
|
scanner = _make_scanner()
|
||||||
|
assert scanner.resolve_sub_type_for_path(None) is None
|
||||||
|
assert scanner.resolve_sub_type_for_path("") is None
|
||||||
|
assert scanner.resolve_sub_type_for_path("/unrelated/model.safetensors") is None
|
||||||
|
|
||||||
|
def test_adjust_metadata_sets_sub_type(self, other_config):
|
||||||
|
scanner = _make_scanner()
|
||||||
|
metadata = OtherModelMetadata(
|
||||||
|
file_name="te",
|
||||||
|
model_name="te",
|
||||||
|
file_path=f"{other_config['text_encoders']}/te.safetensors",
|
||||||
|
size=1,
|
||||||
|
modified=0.0,
|
||||||
|
sha256="",
|
||||||
|
base_model="Unknown",
|
||||||
|
preview_url="",
|
||||||
|
)
|
||||||
|
result = scanner.adjust_metadata(
|
||||||
|
metadata,
|
||||||
|
metadata.file_path,
|
||||||
|
other_config["text_encoders"],
|
||||||
|
)
|
||||||
|
assert result.sub_type == "text_encoder"
|
||||||
|
|
||||||
|
def test_adjust_cached_entry_rederives_sub_type(self, other_config):
|
||||||
|
"""Persisted sub_type is never trusted: it is re-derived from location."""
|
||||||
|
scanner = _make_scanner()
|
||||||
|
entry = {
|
||||||
|
"file_path": f"{other_config['clip']}/legacy.safetensors",
|
||||||
|
"sub_type": "vae", # stale value from an old snapshot
|
||||||
|
}
|
||||||
|
result = scanner.adjust_cached_entry(entry)
|
||||||
|
assert result["sub_type"] == "text_encoder"
|
||||||
|
|
||||||
|
def test_adjust_cached_entry_keeps_value_when_root_unknown(self, other_config):
|
||||||
|
scanner = _make_scanner()
|
||||||
|
entry = {
|
||||||
|
"file_path": "/gone/model.safetensors",
|
||||||
|
"sub_type": "upscaler",
|
||||||
|
}
|
||||||
|
result = scanner.adjust_cached_entry(entry)
|
||||||
|
assert result["sub_type"] == "upscaler"
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherScannerLazyHash:
|
||||||
|
"""Lazy hashing: pending by default, singleflight on-demand calculation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_metadata_has_pending_hash(self, other_config):
|
||||||
|
vae_file = Path(other_config["vae"]) / "vae_model.safetensors"
|
||||||
|
vae_file.write_text("fake vae content", encoding="utf-8")
|
||||||
|
|
||||||
|
scanner = OtherScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(_normalize(vae_file))
|
||||||
|
|
||||||
|
assert metadata is not None
|
||||||
|
assert metadata.sha256 == ""
|
||||||
|
assert metadata.hash_status == "pending"
|
||||||
|
assert metadata.from_civitai is False
|
||||||
|
assert metadata.sub_type == "vae"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_default_metadata_sub_type_from_location(self, other_config):
|
||||||
|
te_file = Path(other_config["text_encoders"]) / "t5.safetensors"
|
||||||
|
te_file.write_text("fake text encoder", encoding="utf-8")
|
||||||
|
|
||||||
|
scanner = OtherScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(_normalize(te_file))
|
||||||
|
|
||||||
|
assert metadata is not None
|
||||||
|
assert metadata.sub_type == "text_encoder"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_for_model_completes_pending(self, other_config):
|
||||||
|
model_file = Path(other_config["upscaler"]) / "upscaler.safetensors"
|
||||||
|
model_file.write_text("fake upscaler content", encoding="utf-8")
|
||||||
|
normalized_file = _normalize(model_file)
|
||||||
|
|
||||||
|
scanner = OtherScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(normalized_file)
|
||||||
|
assert metadata is not None and metadata.hash_status == "pending"
|
||||||
|
|
||||||
|
hash_result = await scanner.calculate_hash_for_model(normalized_file)
|
||||||
|
|
||||||
|
assert hash_result is not None
|
||||||
|
assert len(hash_result) == 64
|
||||||
|
|
||||||
|
metadata_file = model_file.with_suffix(".metadata.json")
|
||||||
|
saved_data = json.loads(metadata_file.read_text(encoding="utf-8"))
|
||||||
|
assert saved_data["sha256"] == hash_result
|
||||||
|
assert saved_data["hash_status"] == "completed"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_singleflight_same_file(self, other_config):
|
||||||
|
"""Concurrent calls for the same file share one SHA256 task."""
|
||||||
|
model_file = Path(other_config["vae"]) / "shared.safetensors"
|
||||||
|
model_file.write_text("fake content", encoding="utf-8")
|
||||||
|
normalized_file = _normalize(model_file)
|
||||||
|
real_file = os.path.realpath(normalized_file)
|
||||||
|
|
||||||
|
scanner = OtherScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(normalized_file)
|
||||||
|
assert metadata is not None
|
||||||
|
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
async def fake_calculate_sha256(file_path: str) -> str:
|
||||||
|
calls.append(file_path)
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
return "a" * 64
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"py.utils.file_utils.calculate_sha256", side_effect=fake_calculate_sha256
|
||||||
|
):
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*[scanner.calculate_hash_for_model(normalized_file) for _ in range(8)]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert calls == [real_file]
|
||||||
|
assert results == ["a" * 64] * 8
|
||||||
|
assert scanner._hash_calculation_tasks == {}
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_hash_skips_completed(self, other_config):
|
||||||
|
model_file = Path(other_config["clip_vision"]) / "cv.safetensors"
|
||||||
|
model_file.write_text("fake content", encoding="utf-8")
|
||||||
|
normalized_file = _normalize(model_file)
|
||||||
|
|
||||||
|
scanner = OtherScanner()
|
||||||
|
metadata = await scanner._create_default_metadata(normalized_file)
|
||||||
|
assert metadata is not None
|
||||||
|
# Simulate an already-completed hash
|
||||||
|
metadata.sha256 = "existing_hash"
|
||||||
|
metadata.hash_status = "completed"
|
||||||
|
from py.utils.metadata_manager import MetadataManager
|
||||||
|
|
||||||
|
await MetadataManager.save_metadata(normalized_file, metadata)
|
||||||
|
|
||||||
|
with patch("py.utils.file_utils.calculate_sha256") as mock_calc:
|
||||||
|
hash_result = await scanner.calculate_hash_for_model(normalized_file)
|
||||||
|
|
||||||
|
assert hash_result == "existing_hash"
|
||||||
|
mock_calc.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_calculate_all_pending_hashes(self, other_config):
|
||||||
|
for index in range(3):
|
||||||
|
model_file = Path(other_config["vae"]) / f"model_{index}.safetensors"
|
||||||
|
model_file.write_text(f"content {index}", encoding="utf-8")
|
||||||
|
|
||||||
|
scanner = OtherScanner()
|
||||||
|
for index in range(3):
|
||||||
|
model_file = Path(other_config["vae"]) / f"model_{index}.safetensors"
|
||||||
|
await scanner._create_default_metadata(_normalize(model_file))
|
||||||
|
|
||||||
|
progress_calls = []
|
||||||
|
|
||||||
|
async def progress_callback(current, total, file_path):
|
||||||
|
progress_calls.append((current, total, file_path))
|
||||||
|
|
||||||
|
result = await scanner.calculate_all_pending_hashes(progress_callback)
|
||||||
|
|
||||||
|
assert result["total"] == 3
|
||||||
|
assert result["completed"] == 3
|
||||||
|
assert result["failed"] == 0
|
||||||
|
assert len(progress_calls) == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherModelMetadataFromCivitai:
|
||||||
|
"""CivitAI type mapping in OtherModelMetadata.from_civitai_info."""
|
||||||
|
|
||||||
|
def _build(self, civitai_type: str) -> OtherModelMetadata:
|
||||||
|
return OtherModelMetadata.from_civitai_info(
|
||||||
|
{
|
||||||
|
"baseModel": "SDXL",
|
||||||
|
"model": {
|
||||||
|
"name": "Model",
|
||||||
|
"tags": ["tag"],
|
||||||
|
"description": "desc",
|
||||||
|
"type": civitai_type,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"name": "model.safetensors", "sizeKB": 1, "hashes": {"SHA256": "AB"}},
|
||||||
|
"/tmp/model.safetensors",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"civitai_type,expected",
|
||||||
|
[
|
||||||
|
("VAE", "vae"),
|
||||||
|
("Upscaler", "upscaler"),
|
||||||
|
("TextEncoder", "text_encoder"),
|
||||||
|
("CLIP", "text_encoder"),
|
||||||
|
("CLIPVision", "clip_vision"),
|
||||||
|
("Controlnet", "controlnet"),
|
||||||
|
("Other", "vae"), # unknown types fall back to the placeholder
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_civitai_type_mapping(self, civitai_type, expected):
|
||||||
|
metadata = self._build(civitai_type)
|
||||||
|
assert metadata.sub_type == expected
|
||||||
|
assert metadata.sha256 == "ab"
|
||||||
|
assert metadata.tags == ["tag"]
|
||||||
|
|
||||||
|
def test_top_level_type_key_is_ignored(self):
|
||||||
|
"""Regression: the CivitAI type lives at version["model"]["type"]; a
|
||||||
|
top-level version["type"] key must not drive the mapping (#Phase-1 bug)."""
|
||||||
|
metadata = OtherModelMetadata.from_civitai_info(
|
||||||
|
{
|
||||||
|
"type": "Upscaler",
|
||||||
|
"baseModel": "SDXL",
|
||||||
|
"model": {"name": "Model", "type": "VAE"},
|
||||||
|
},
|
||||||
|
{"name": "model.safetensors", "sizeKB": 1, "hashes": {"SHA256": "AB"}},
|
||||||
|
"/tmp/model.safetensors",
|
||||||
|
)
|
||||||
|
assert metadata.sub_type == "vae"
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_type_maps_to_other():
|
||||||
|
"""The WS progress page type for the other scanner is 'other'."""
|
||||||
|
assert model_scanner.PAGE_TYPE_MAP["other"] == "other"
|
||||||
|
scanner = _make_scanner()
|
||||||
|
assert scanner.page_type == "other"
|
||||||
@@ -7,6 +7,7 @@ from unittest.mock import MagicMock, AsyncMock
|
|||||||
from py.services.lora_service import LoraService
|
from py.services.lora_service import LoraService
|
||||||
from py.services.checkpoint_service import CheckpointService
|
from py.services.checkpoint_service import CheckpointService
|
||||||
from py.services.embedding_service import EmbeddingService
|
from py.services.embedding_service import EmbeddingService
|
||||||
|
from py.services.other_model_service import OtherModelService
|
||||||
|
|
||||||
|
|
||||||
class TestLoraServiceFormatResponse:
|
class TestLoraServiceFormatResponse:
|
||||||
@@ -206,6 +207,89 @@ class TestEmbeddingServiceFormatResponse:
|
|||||||
assert "model_type" not in result # Removed in refactoring
|
assert "model_type" not in result # Removed in refactoring
|
||||||
|
|
||||||
|
|
||||||
|
class TestOtherModelServiceFormatResponse:
|
||||||
|
"""Test OtherModelService.format_response includes sub_type."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_scanner(self):
|
||||||
|
scanner = MagicMock()
|
||||||
|
scanner._hash_index = MagicMock()
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def other_service(self, mock_scanner):
|
||||||
|
return OtherModelService(mock_scanner)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_response_includes_sub_type(self, other_service):
|
||||||
|
"""format_response should include sub_type field."""
|
||||||
|
other_data = {
|
||||||
|
"model_name": "Test VAE",
|
||||||
|
"file_name": "test_vae",
|
||||||
|
"preview_url": "test.webp",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"folder": "",
|
||||||
|
"sha256": "abc123",
|
||||||
|
"file_path": "/models/vae/test_vae.safetensors",
|
||||||
|
"size": 1000,
|
||||||
|
"modified": 1234567890.0,
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"notes": "",
|
||||||
|
"favorite": False,
|
||||||
|
"sub_type": "vae",
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await other_service.format_response(other_data)
|
||||||
|
|
||||||
|
assert "sub_type" in result
|
||||||
|
assert result["sub_type"] == "vae"
|
||||||
|
assert "model_type" not in result # Removed in refactoring
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_response_defaults_to_vae(self, other_service):
|
||||||
|
"""format_response should default to 'vae' if no sub_type field."""
|
||||||
|
other_data = {
|
||||||
|
"model_name": "Test Upscaler",
|
||||||
|
"file_name": "test_upscaler",
|
||||||
|
"preview_url": "test.webp",
|
||||||
|
"preview_nsfw_level": 0,
|
||||||
|
"base_model": "SD1.5",
|
||||||
|
"folder": "",
|
||||||
|
"sha256": "abc123",
|
||||||
|
"file_path": "/models/upscale_models/test.pth",
|
||||||
|
"size": 1000,
|
||||||
|
"modified": 1234567890.0,
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"civitai": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await other_service.format_response(other_data)
|
||||||
|
|
||||||
|
assert result["sub_type"] == "vae"
|
||||||
|
assert "model_type" not in result # Removed in refactoring
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_format_response_returns_none_on_missing_file_path(self, other_service):
|
||||||
|
"""format_response returns None when file_path is missing (corrupted row)."""
|
||||||
|
other_data = {
|
||||||
|
"model_name": "Test",
|
||||||
|
"file_name": "test",
|
||||||
|
"file_path": None, # corrupted: missing file_path
|
||||||
|
"folder": "",
|
||||||
|
"sha256": "abc",
|
||||||
|
"tags": [],
|
||||||
|
"from_civitai": True,
|
||||||
|
"civitai": {},
|
||||||
|
"sub_type": "text_encoder",
|
||||||
|
}
|
||||||
|
result = await other_service.format_response(other_data)
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestFormatResponseCorruptedEntries:
|
class TestFormatResponseCorruptedEntries:
|
||||||
"""Test format_response handles corrupted cache entries gracefully (issue #730).
|
"""Test format_response handles corrupted cache entries gracefully (issue #730).
|
||||||
|
|
||||||
|
|||||||
@@ -1208,3 +1208,137 @@ def test_skip_previously_downloaded_model_versions_coerces_string_input(manager)
|
|||||||
|
|
||||||
assert manager.get_skip_previously_downloaded_model_versions() is True
|
assert manager.get_skip_previously_downloaded_model_versions() is True
|
||||||
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
assert manager.settings["skip_previously_downloaded_model_versions"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_other_roots_stay_empty_without_other_folders(manager):
|
||||||
|
assert manager._get_default_settings()["default_other_roots"] == {}
|
||||||
|
|
||||||
|
manager.settings["default_other_roots"] = {}
|
||||||
|
manager.settings["folder_paths"] = {}
|
||||||
|
manager.settings["extra_folder_paths"] = {}
|
||||||
|
|
||||||
|
manager._auto_set_default_roots()
|
||||||
|
|
||||||
|
assert manager.get("default_other_roots") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_set_default_other_roots(manager):
|
||||||
|
manager.settings["default_other_roots"] = {}
|
||||||
|
manager.settings["folder_paths"] = {
|
||||||
|
"vae": ["/vae"],
|
||||||
|
"upscale_models": ["/upscalers"],
|
||||||
|
"clip_vision": ["/clip_vision"],
|
||||||
|
}
|
||||||
|
|
||||||
|
manager._auto_set_default_roots()
|
||||||
|
|
||||||
|
roots = manager.get("default_other_roots")
|
||||||
|
assert roots["vae"] == "/vae"
|
||||||
|
assert roots["upscaler"] == "/upscalers"
|
||||||
|
assert roots["clip_vision"] == "/clip_vision"
|
||||||
|
# text_encoder has no configured folders -> no entry
|
||||||
|
assert "text_encoder" not in roots
|
||||||
|
assert "controlnet" not in roots
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_set_default_other_roots_text_encoder_dual_key_union(manager):
|
||||||
|
"""text_encoder candidates merge text_encoders and the legacy clip key."""
|
||||||
|
manager.settings["default_other_roots"] = {}
|
||||||
|
manager.settings["folder_paths"] = {
|
||||||
|
"clip": ["/legacy-clip"],
|
||||||
|
"text_encoders": ["/text-encoders"],
|
||||||
|
}
|
||||||
|
|
||||||
|
manager._auto_set_default_roots()
|
||||||
|
|
||||||
|
roots = manager.get("default_other_roots")
|
||||||
|
assert roots["text_encoder"] in {"/legacy-clip", "/text-encoders"}
|
||||||
|
|
||||||
|
# A value pointing at either key's root is considered valid
|
||||||
|
manager.settings["default_other_roots"] = {"text_encoder": "/legacy-clip"}
|
||||||
|
manager._auto_set_default_roots()
|
||||||
|
assert manager.get("default_other_roots")["text_encoder"] == "/legacy-clip"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_set_default_other_roots_repairs_stale(manager):
|
||||||
|
manager.settings["default_other_roots"] = {"vae": "/stale-vae"}
|
||||||
|
manager.settings["folder_paths"] = {"vae": ["/vae"]}
|
||||||
|
|
||||||
|
manager._auto_set_default_roots()
|
||||||
|
|
||||||
|
assert manager.get("default_other_roots")["vae"] == "/vae"
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_set_default_other_roots_uses_extra_folder_paths(manager):
|
||||||
|
manager.settings["default_other_roots"] = {}
|
||||||
|
manager.settings["folder_paths"] = {"vae": []}
|
||||||
|
manager.settings["extra_folder_paths"] = {"vae": ["/extra-vae"]}
|
||||||
|
|
||||||
|
manager._auto_set_default_roots()
|
||||||
|
|
||||||
|
assert manager.get("default_other_roots")["vae"] == "/extra-vae"
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_default_other_roots_syncs_active_library(manager):
|
||||||
|
manager.set("default_other_roots", {"vae": "/vae"})
|
||||||
|
|
||||||
|
libraries = manager.get_libraries()
|
||||||
|
active = manager.get_active_library_name()
|
||||||
|
assert libraries[active]["default_other_roots"] == {"vae": "/vae"}
|
||||||
|
assert manager.get("default_other_roots") == {"vae": "/vae"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_default_other_roots_rejects_illegal_sub_type(manager):
|
||||||
|
with pytest.raises(ValueError, match="Unknown other-model sub-type"):
|
||||||
|
manager.set("default_other_roots", {"vae": "/vae", "lora": "/loras"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_default_other_roots_normalizes_values(manager):
|
||||||
|
manager.set("default_other_roots", {"vae": " /vae ", "upscaler": ""})
|
||||||
|
assert manager.get("default_other_roots") == {"vae": "/vae"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_upsert_library_passthrough_default_other_roots(manager, tmp_path):
|
||||||
|
manager.upsert_library(
|
||||||
|
"studio",
|
||||||
|
folder_paths={"loras": ["/studio/loras"], "vae": ["/studio/vae"]},
|
||||||
|
default_other_roots={"vae": "/studio/vae"},
|
||||||
|
activate=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
libraries = manager.get_libraries()
|
||||||
|
assert libraries["studio"]["default_other_roots"] == {"vae": "/studio/vae"}
|
||||||
|
assert manager.get("default_other_roots") == {"vae": "/studio/vae"}
|
||||||
|
|
||||||
|
# Omitting the argument preserves the stored value
|
||||||
|
manager.upsert_library("studio", folder_paths={"loras": ["/studio/loras"]})
|
||||||
|
libraries = manager.get_libraries()
|
||||||
|
assert libraries["studio"]["default_other_roots"] == {"vae": "/studio/vae"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_switch_restores_default_other_roots(manager):
|
||||||
|
manager.set("default_other_roots", {"vae": "/default-vae"})
|
||||||
|
manager.create_library(
|
||||||
|
"studio",
|
||||||
|
folder_paths={"loras": ["/studio/loras"]},
|
||||||
|
default_other_roots={"vae": "/studio-vae"},
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.activate_library("studio")
|
||||||
|
assert manager.get("default_other_roots") == {"vae": "/studio-vae"}
|
||||||
|
|
||||||
|
manager.activate_library("default")
|
||||||
|
assert manager.get("default_other_roots") == {"vae": "/default-vae"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_sanitizes_legacy_libraries_includes_other_roots(tmp_path, monkeypatch):
|
||||||
|
initial = {
|
||||||
|
"libraries": {"legacy": "not-a-dict"},
|
||||||
|
"active_library": "legacy",
|
||||||
|
"folder_paths": {"loras": ["/old"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
manager = _create_manager_with_settings(tmp_path, monkeypatch, initial)
|
||||||
|
|
||||||
|
payload = manager.get_libraries()["legacy"]
|
||||||
|
assert payload["default_other_roots"] == {}
|
||||||
|
|||||||
Reference in New Issue
Block a user