From 3070838a42c487f8c27306bae05d8787ab52207b Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 09:29:42 +0800 Subject: [PATCH 1/7] docs: plan for Other Models page (VAE/upscaler/text encoder management) --- docs/plans/other-models-page.md | 170 ++++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 docs/plans/other-models-page.md diff --git a/docs/plans/other-models-page.md b/docs/plans/other-models-page.md new file mode 100644 index 00000000..1c2a1847 --- /dev/null +++ b/docs/plans/other-models-page.md @@ -0,0 +1,170 @@ +# Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc. + +**Status:** v1 — decisions locked with feature owner (2026-09-12); ready for implementation. +**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 (out of scope here, outlined in §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 Outline (NOT in this implementation) + +CivitAI downloads for `other` types: + +- `py/services/download_manager.py`: extend the type map (`:1496-1507` — currently rejects unknown types), `_get_scanner_for_model_type` (`:230-236`), `_build_metadata_for_resume` (`:977-981`). +- **Default-root selection** (`:1699-1727`): needs per-sub_type settings keys (`default_vae_root`, `default_upscaler_root`, …) — this is what pulls in `settings_manager.py` (library default keys `:82-85`, `_check_and_auto_set` ~`:890`, `set()` dispatch `:1621-1628`, `upsert_library` params, library-switch scanner notifications `:1795-1799`/`:2150-2155`). +- File-type routing: consult CivitAI's `primaryFileTypesByModelType` (e.g. CLIP-type models ship `Text Encoder`/`Vision Encoder` primary files, not `Model`); a `file.type → target folder` map analogous to `download_routing.py:is_diffusion_model_download`. +- Settings UI for the new default roots. + +## 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. From 27da7b3ca370dc2079f38b4443f5be905a95d8e2 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 11:25:51 +0800 Subject: [PATCH 2/7] feat(backend): add Other model type (VAE/upscaler/text encoder) scanner, service and routes --- AGENTS.md | 2 +- py/config.py | 162 +++++- py/lora_manager.py | 8 +- py/metadata_ops/__init__.py | 5 +- py/routes/handlers/misc_handlers.py | 2 + py/routes/handlers/pending_delete_handler.py | 1 + py/routes/other_routes.py | 74 +++ py/services/base_model_service.py | 7 +- py/services/model_scanner.py | 1 + py/services/model_service_factory.py | 15 +- py/services/other_model_service.py | 79 +++ py/services/other_scanner.py | 468 ++++++++++++++++++ py/services/pending_delete_service.py | 2 + py/services/service_registry.py | 31 +- py/utils/constants.py | 43 ++ py/utils/models.py | 56 ++- settings.json.example | 12 + tests/config/test_other_paths.py | 277 +++++++++++ tests/routes/test_lora_manager_lifecycle.py | 6 +- tests/routes/test_other_routes.py | 118 +++++ tests/services/test_other_scanner.py | 337 +++++++++++++ .../test_service_format_response_sub_type.py | 84 ++++ 22 files changed, 1771 insertions(+), 19 deletions(-) create mode 100644 py/routes/other_routes.py create mode 100644 py/services/other_model_service.py create mode 100644 py/services/other_scanner.py create mode 100644 tests/config/test_other_paths.py create mode 100644 tests/routes/test_other_routes.py create mode 100644 tests/services/test_other_scanner.py diff --git a/AGENTS.md b/AGENTS.md index 7e4543cb..0a07b3f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -166,7 +166,7 @@ The system runs in two modes: ### Model Types & Routes -- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns +- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*`, `/other/*` patterns - Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc. - Request handlers in `py/routes/handlers/` implement route logic - All routes use aiohttp, return `web.json_response` or `web.Response` diff --git a/py/config.py b/py/config.py index 8e96b2e8..99d589b8 100644 --- a/py/config.py +++ b/py/config.py @@ -17,6 +17,10 @@ import types as _types import time 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 ( ensure_settings_file, get_settings_dir, @@ -172,6 +176,13 @@ class Config: self.embeddings_roots = None self.base_models_roots = self._init_checkpoint_paths() self.embeddings_roots = self._init_embedding_paths() + # Other-model roots (VAE, upscalers, text encoders, ...): flat deduped + # list plus a normalized root -> sub_type map and per-folder_paths-key + # roots for settings persistence. + self.other_roots: Optional[List[str]] = None + self.other_root_subtypes: Dict[str, str] = {} + self.other_folder_roots: Dict[str, List[str]] = {} + self.other_roots = self._init_other_paths() # Extra paths (only for LoRA Manager, not shared with ComfyUI) self.extra_loras_roots: List[str] = [] self.extra_checkpoints_roots: List[str] = [] @@ -336,6 +347,10 @@ class Config: "unet": list(self.unet_roots or []), "embeddings": list(self.embeddings_roots or []), } + # Persist the other-model roots under their original folder_paths + # keys so library switching round-trips them. + for key, roots in (self.other_folder_roots or {}).items(): + target_folder_paths[key] = list(roots) normalized_target_paths = _normalize_folder_paths_for_comparison( target_folder_paths @@ -522,6 +537,7 @@ class Config: roots.extend(self.loras_roots or []) roots.extend(self.base_models_roots or []) roots.extend(self.embeddings_roots or []) + roots.extend(self.other_roots or []) # Include extra paths for scanning symlinks roots.extend(self.extra_loras_roots or []) roots.extend(self.extra_checkpoints_roots or []) @@ -862,6 +878,8 @@ class Config: preview_roots.update(self._expand_preview_root(root)) for root in self.embeddings_roots or []: preview_roots.update(self._expand_preview_root(root)) + for root in self.other_roots or []: + preview_roots.update(self._expand_preview_root(root)) # Include extra paths for preview access for root in self.extra_loras_roots or []: preview_roots.update(self._expand_preview_root(root)) @@ -882,7 +900,7 @@ class Config: path for path in preview_roots if path.is_absolute() } logger.debug( - "Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings", + "Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d other roots, %d symlink mappings", len(self._preview_root_paths), len(self.loras_roots or []), len(self.extra_loras_roots or []), @@ -890,6 +908,7 @@ class Config: len(self.extra_checkpoints_roots or []), len(self.embeddings_roots or []), len(self.extra_embeddings_roots or []), + len(self.other_roots or []), len(self._path_mappings), ) @@ -1128,6 +1147,102 @@ class Config: 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( self, folder_paths: Mapping[str, Any], @@ -1151,6 +1266,16 @@ class Config: ) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths) self.embeddings_roots = self._prepare_embedding_paths(embedding_paths) + other_path_map = { + key: folder_paths.get(key, []) or [] + for key in self._get_enabled_other_folder_keys() + } + ( + self.other_roots, + self.other_root_subtypes, + self.other_folder_roots, + ) = self._prepare_other_paths(other_path_map) + # Process extra paths (only for LoRA Manager, not shared with ComfyUI) extra_paths = extra_folder_paths or {} extra_lora_paths = extra_paths.get("loras", []) or [] @@ -1267,6 +1392,41 @@ class Config: logger.warning(f"Error initializing embedding paths: {e}") return [] + def _init_other_paths(self) -> List[str]: + """Initialize and validate other-model paths from ComfyUI settings. + + Iterates the enabled OTHER_MODEL_FOLDER_SUBTYPES keys and pulls each + from ``folder_paths.get_folder_paths(key)`` (in standalone mode the + mock serves arbitrary keys from ``settings.json.folder_paths``). + """ + 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: if not preview_path: return "" diff --git a/py/lora_manager.py b/py/lora_manager.py index c745c16c..0fc364c6 100644 --- a/py/lora_manager.py +++ b/py/lora_manager.py @@ -219,6 +219,7 @@ class LoraManager: lora_scanner = await ServiceRegistry.get_lora_scanner() checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner() + other_scanner = await ServiceRegistry.get_other_scanner() # Initialize recipe scanner if needed recipe_scanner = await ServiceRegistry.get_recipe_scanner() @@ -236,6 +237,10 @@ class LoraManager: embedding_scanner.initialize_in_background(), name="embedding_cache_init", ), + asyncio.create_task( + other_scanner.initialize_in_background(), + name="other_cache_init", + ), asyncio.create_task( recipe_scanner.initialize_in_background(), name="recipe_cache_init" ), @@ -328,6 +333,7 @@ class LoraManager: all_roots.update(config.loras_roots) all_roots.update(config.base_models_roots or []) all_roots.update(config.embeddings_roots or []) + all_roots.update(config.other_roots or []) total_deleted = 0 total_size_freed = 0 @@ -460,7 +466,7 @@ class LoraManager: # Cancel any in-flight scanner initialization tasks so thread-pool # workers (e.g. _initialize_cache_sync) can break out of their loops # when the server shuts down (e.g. Ctrl+C on WSL). - for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"): + for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner"): scanner = ServiceRegistry.get_service_sync(name) if scanner is not None and hasattr(scanner, "cancel_task"): scanner.cancel_task() diff --git a/py/metadata_ops/__init__.py b/py/metadata_ops/__init__.py index 3ecb957c..a83356d8 100644 --- a/py/metadata_ops/__init__.py +++ b/py/metadata_ops/__init__.py @@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = { "get_lora_scanner": "lora", "get_checkpoint_scanner": "checkpoint", "get_embedding_scanner": "embedding", + "get_other_scanner": "other", } SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys()) @@ -80,8 +81,8 @@ async def _find_scanner_for_model( async def identify_model_type(model_path: str) -> str: - """Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or - ``\"embedding\"``) for *model_path*. + """Determine the model type (``\"lora\"``, ``\"checkpoint\"``, + ``\"embedding\"``, or ``\"other\"``) for *model_path*. Falls back to ``\"lora\"`` when unknown. """ diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index e879327b..c461841d 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -658,6 +658,7 @@ class HealthCheckHandler: "lora": ServiceRegistry.get_lora_scanner, "checkpoint": ServiceRegistry.get_checkpoint_scanner, "embedding": ServiceRegistry.get_embedding_scanner, + "other": ServiceRegistry.get_other_scanner, "recipe": ServiceRegistry.get_recipe_scanner, } @@ -757,6 +758,7 @@ class DoctorHandler: ("lora", "LoRAs", ServiceRegistry.get_lora_scanner), ("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner), ("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner), + ("other", "Other Models", ServiceRegistry.get_other_scanner), ) ) self._app_version_getter = app_version_getter diff --git a/py/routes/handlers/pending_delete_handler.py b/py/routes/handlers/pending_delete_handler.py index 8213d89d..4cf44b8f 100644 --- a/py/routes/handlers/pending_delete_handler.py +++ b/py/routes/handlers/pending_delete_handler.py @@ -35,6 +35,7 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = { "loras": "get_lora_scanner", "checkpoints": "get_checkpoint_scanner", "embeddings": "get_embedding_scanner", + "other": "get_other_scanner", } # Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is diff --git a/py/routes/other_routes.py b/py/routes/other_routes.py new file mode 100644 index 00000000..be36e066 --- /dev/null +++ b/py/routes/other_routes.py @@ -0,0 +1,74 @@ +import logging +from typing import Any, Dict +from aiohttp import web + +from .base_model_routes import BaseModelRoutes +from .model_route_registrar import ModelRouteRegistrar +from ..services.other_model_service import OtherModelService +from ..services.service_registry import ServiceRegistry +from ..utils.constants import 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) + + 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_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) diff --git a/py/services/base_model_service.py b/py/services/base_model_service.py index 67b03bb6..f27dee35 100644 --- a/py/services/base_model_service.py +++ b/py/services/base_model_service.py @@ -7,7 +7,7 @@ import logging import os import time -from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES +from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES, VALID_OTHER_SUB_TYPES from ..utils.models import BaseModelMetadata from ..utils.metadata_manager import MetadataManager from ..utils.usage_stats import UsageStats @@ -904,6 +904,11 @@ class BaseModelService(ABC): and normalized_type not in VALID_CHECKPOINT_SUB_TYPES ): continue + if ( + self.model_type == "other" + and normalized_type not in VALID_OTHER_SUB_TYPES + ): + continue type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1 diff --git a/py/services/model_scanner.py b/py/services/model_scanner.py index ec892da9..a6869be3 100644 --- a/py/services/model_scanner.py +++ b/py/services/model_scanner.py @@ -68,6 +68,7 @@ PAGE_TYPE_MAP = { 'lora': 'loras', 'checkpoint': 'checkpoints', 'embedding': 'embeddings', + 'other': 'other', } diff --git a/py/services/model_service_factory.py b/py/services/model_service_factory.py index c38faf8a..61cc2182 100644 --- a/py/services/model_service_factory.py +++ b/py/services/model_service_factory.py @@ -118,19 +118,24 @@ class ModelServiceFactory: def register_default_model_types(): - """Register the default model types (LoRA, Checkpoint, and Embedding)""" + """Register the default model types (LoRA, Checkpoint, Embedding, and Other)""" from ..services.lora_service import LoraService from ..services.checkpoint_service import CheckpointService from ..services.embedding_service import EmbeddingService + from ..services.other_model_service import OtherModelService from ..routes.lora_routes import LoraRoutes from ..routes.checkpoint_routes import CheckpointRoutes from ..routes.embedding_routes import EmbeddingRoutes - + from ..routes.other_routes import OtherRoutes + # Register LoRA model type ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes) - + # Register Checkpoint model type ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes) - + # Register Embedding model type - ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes) \ No newline at end of file + ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes) + + # Register Other model type (VAE, upscaler, text encoder, ...) + ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes) \ No newline at end of file diff --git a/py/services/other_model_service.py b/py/services/other_model_service.py new file mode 100644 index 00000000..76b5aa0b --- /dev/null +++ b/py/services/other_model_service.py @@ -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", ""), + ) + 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() diff --git a/py/services/other_scanner.py b/py/services/other_scanner.py new file mode 100644 index 00000000..366e2c98 --- /dev/null +++ b/py/services/other_scanner.py @@ -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 diff --git a/py/services/pending_delete_service.py b/py/services/pending_delete_service.py index 0b47fae5..39d36ea8 100644 --- a/py/services/pending_delete_service.py +++ b/py/services/pending_delete_service.py @@ -59,6 +59,7 @@ _MODEL_TYPE_PAGE_MAP = { "lora": "loras", "checkpoint": "checkpoints", "embedding": "embeddings", + "other": "other", } # Module-level alias so tests can spy on timer task creation without patching @@ -983,6 +984,7 @@ class PendingDeleteService: "get_lora_scanner", "get_checkpoint_scanner", "get_embedding_scanner", + "get_other_scanner", ): getter = getattr(ServiceRegistry, getter_name, None) if not callable(getter): diff --git a/py/services/service_registry.py b/py/services/service_registry.py index e4ff3ec3..5c0a7c37 100644 --- a/py/services/service_registry.py +++ b/py/services/service_registry.py @@ -297,23 +297,44 @@ class ServiceRegistry: async def get_embedding_scanner(cls): """Get or create Embedding scanner instance""" service_name = "embedding_scanner" - + if service_name in cls._services: return cls._services[service_name] - + async with cls._get_lock(service_name): # Double-check after acquiring lock if service_name in cls._services: return cls._services[service_name] - + # Import here to avoid circular imports from .embedding_scanner import EmbeddingScanner - + scanner = await EmbeddingScanner.get_instance() cls._services[service_name] = scanner logger.debug(f"Created and registered {service_name}") return scanner - + + @classmethod + async def get_other_scanner(cls): + """Get or create Other-model scanner instance""" + service_name = "other_scanner" + + if service_name in cls._services: + return cls._services[service_name] + + async with cls._get_lock(service_name): + # Double-check after acquiring lock + if service_name in cls._services: + return cls._services[service_name] + + # Import here to avoid circular imports + from .other_scanner import OtherScanner + + scanner = await OtherScanner.get_instance() + cls._services[service_name] = scanner + logger.debug(f"Created and registered {service_name}") + return scanner + @classmethod def clear_services(cls): """Clear all registered services - mainly for testing""" diff --git a/py/utils/constants.py b/py/utils/constants.py index 73325aef..fd8c4812 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -83,6 +83,49 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"] VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"] VALID_EMBEDDING_SUB_TYPES = ["embedding"] +# folder_paths key -> sub_type; single source of truth for extensibility. +# Adding support for a new ComfyUI folder category is a one-line change here. +OTHER_MODEL_FOLDER_SUBTYPES = { + "vae": "vae", + "upscale_models": "upscaler", + "text_encoders": "text_encoder", + "clip": "text_encoder", # legacy ComfyUI key + "clip_vision": "clip_vision", + "controlnet": "controlnet", +} +# 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", +} + # Backward compatibility alias VALID_LORA_TYPES = VALID_LORA_SUB_TYPES diff --git a/py/utils/models.py b/py/utils/models.py index 8c14b626..f9961cf6 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict, field from typing import Callable, Dict, Optional, List, Any from datetime import datetime import os -from .constants import INVALID_AUTOV3_EMPTY_HASH +from .constants import CIVITAI_TYPE_TO_OTHER_SUB_TYPE, INVALID_AUTOV3_EMPTY_HASH from .model_utils import determine_base_model @@ -318,6 +318,60 @@ 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. + civitai_type = str(version_info.get("type", "") or "").lower() + sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(civitai_type, "vae") + + # Extract tags and description if available + tags = [] + description = "" + model_data = version_info.get("model") or {} + if "tags" in model_data: + tags = model_data["tags"] + if "description" in model_data: + description = model_data["description"] + + return cls( + file_name=os.path.splitext(file_name)[0], + model_name=model_data.get("name", os.path.splitext(file_name)[0]), + file_path=save_path.replace(os.sep, "/"), + size=file_info.get("sizeKB", 0) * 1024, + modified=datetime.now().timestamp(), + sha256=sha256_value, + base_model=base_model, + preview_url="", # Will be updated after preview download + preview_nsfw_level=0, + from_civitai=True, + civitai=version_info, + sub_type=sub_type, + tags=tags, + modelDescription=description, + # Direct read: the downloaded file IS file_info, no SHA256 matching. + autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")), + ) + + @dataclass class EmbeddingMetadata(BaseModelMetadata): """Represents the metadata structure for an Embedding model""" diff --git a/settings.json.example b/settings.json.example index b15d24ac..101a217d 100644 --- a/settings.json.example +++ b/settings.json.example @@ -17,6 +17,18 @@ "embeddings": [ "C:/path/to/your/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" ] }, "auto_organize_exclusions": [] diff --git a/tests/config/test_other_paths.py b/tests/config/test_other_paths.py new file mode 100644 index 00000000..9fddc3d9 --- /dev/null +++ b/tests/config/test_other_paths.py @@ -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")) diff --git a/tests/routes/test_lora_manager_lifecycle.py b/tests/routes/test_lora_manager_lifecycle.py index 2e62e4d4..a922b7ca 100644 --- a/tests/routes/test_lora_manager_lifecycle.py +++ b/tests/routes/test_lora_manager_lifecycle.py @@ -132,6 +132,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: "lora": _DummyScanner("lora"), "checkpoint": _DummyScanner("checkpoint"), "embedding": _DummyScanner("embedding"), + "other": _DummyScanner("other"), "recipe": _DummyScanner("recipe"), } @@ -147,6 +148,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: monkeypatch.setattr(lora_manager.ServiceRegistry, "get_lora_scanner", lambda: _stub("lora_scanner", scanners["lora"])) monkeypatch.setattr(lora_manager.ServiceRegistry, "get_checkpoint_scanner", lambda: _stub("checkpoint_scanner", scanners["checkpoint"])) monkeypatch.setattr(lora_manager.ServiceRegistry, "get_embedding_scanner", lambda: _stub("embedding_scanner", scanners["embedding"])) + monkeypatch.setattr(lora_manager.ServiceRegistry, "get_other_scanner", lambda: _stub("other_scanner", scanners["other"])) monkeypatch.setattr(lora_manager.ServiceRegistry, "get_recipe_scanner", lambda: _stub("recipe_scanner", scanners["recipe"])) migration_calls: list[bool] = [] @@ -205,7 +207,7 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: await asyncio.gather(*pending) task_names = {task.get_name() for task in scheduled_tasks} - assert {"lora_cache_init", "checkpoint_cache_init", "embedding_cache_init", "recipe_cache_init", "post_init_tasks", "cleanup_bak_files"}.issubset(task_names) + assert {"lora_cache_init", "checkpoint_cache_init", "embedding_cache_init", "other_cache_init", "recipe_cache_init", "post_init_tasks", "cleanup_bak_files"}.issubset(task_names) # Startup sweep: an expired pending-delete purge task is spawned during # service initialization (covers both plugin and standalone modes). @@ -219,4 +221,4 @@ async def test_lora_manager_lifecycle(monkeypatch: pytest.MonkeyPatch, tmp_path: for root in (loras_root, checkpoints_root, embeddings_root): assert not any(path.suffix == ".bak" for path in root.rglob("*")), f"Backup files remain in {root}" - assert {"civitai_client", "download_manager", "websocket_manager", "lora_scanner", "checkpoint_scanner", "embedding_scanner", "recipe_scanner"}.issubset(registry_calls) + assert {"civitai_client", "download_manager", "websocket_manager", "lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner", "recipe_scanner"}.issubset(registry_calls) diff --git a/tests/routes/test_other_routes.py b/tests/routes/test_other_routes.py new file mode 100644 index 00000000..e87fee94 --- /dev/null +++ b/tests/routes/test_other_routes.py @@ -0,0 +1,118 @@ +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 diff --git a/tests/services/test_other_scanner.py b/tests/services/test_other_scanner.py new file mode 100644 index 00000000..63c4dc3a --- /dev/null +++ b/tests/services/test_other_scanner.py @@ -0,0 +1,337 @@ +"""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( + { + "type": civitai_type, + "baseModel": "SDXL", + "model": {"name": "Model", "tags": ["tag"], "description": "desc"}, + }, + {"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_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" diff --git a/tests/services/test_service_format_response_sub_type.py b/tests/services/test_service_format_response_sub_type.py index b1f3e192..9bc936bb 100644 --- a/tests/services/test_service_format_response_sub_type.py +++ b/tests/services/test_service_format_response_sub_type.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, AsyncMock from py.services.lora_service import LoraService from py.services.checkpoint_service import CheckpointService from py.services.embedding_service import EmbeddingService +from py.services.other_model_service import OtherModelService class TestLoraServiceFormatResponse: @@ -206,6 +207,89 @@ class TestEmbeddingServiceFormatResponse: 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: """Test format_response handles corrupted cache entries gracefully (issue #730). From fa7ce725c11cffb9e55dc67fecb31b7c954e6af5 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 11:25:51 +0800 Subject: [PATCH 3/7] feat(frontend): add Other Models page with subtype filter and badges --- locales/de.json | 9 + locales/en.json | 9 + locales/es.json | 9 + locales/fr.json | 9 + locales/he.json | 9 + locales/ja.json | 9 + locales/ko.json | 9 + locales/ru.json | 9 + locales/zh-CN.json | 9 + locales/zh-TW.json | 9 + static/js/api/apiConfig.js | 15 +- static/js/api/modelApiFactory.js | 3 + static/js/api/otherApi.js | 7 + .../components/ContextMenu/BulkContextMenu.js | 4 +- .../ContextMenu/ModelContextMenuMixin.js | 3 +- .../ContextMenu/OtherContextMenu.js | 72 ++++++++ static/js/components/ContextMenu/index.js | 4 + static/js/components/Header.js | 1 + static/js/components/SidebarManager.js | 3 +- .../js/components/controls/OtherControls.js | 60 +++++++ static/js/components/controls/index.js | 9 +- static/js/components/initialization.js | 3 + static/js/components/shared/ModelCard.js | 5 + static/js/core.js | 2 +- static/js/managers/BannerService.js | 6 +- static/js/managers/BulkManager.js | 14 ++ static/js/managers/FilterManager.js | 4 +- static/js/managers/MoveManager.js | 14 +- static/js/managers/SearchManager.js | 4 +- static/js/managers/SettingsManager.js | 3 + static/js/other.js | 57 +++++++ static/js/state/index.js | 39 +++++ static/js/utils/activeFiltersSync.js | 2 +- static/js/utils/constants.js | 11 ++ static/js/utils/infiniteScroll.js | 2 +- static/js/utils/storageHelpers.js | 2 +- templates/components/controls.html | 4 +- templates/components/header.html | 10 +- templates/other.html | 74 ++++++++ tests/frontend/api/otherApi.test.js | 85 ++++++++++ .../components/modelCard.subTypeBadge.test.js | 128 ++++++++++++++ .../components/otherFactories.test.js | 159 ++++++++++++++++++ tests/frontend/core/appCore.test.js | 2 +- tests/frontend/pages/otherPage.test.js | 105 ++++++++++++ tests/frontend/utils/pageFixtures.js | 12 ++ 45 files changed, 988 insertions(+), 30 deletions(-) create mode 100644 static/js/api/otherApi.js create mode 100644 static/js/components/ContextMenu/OtherContextMenu.js create mode 100644 static/js/components/controls/OtherControls.js create mode 100644 static/js/other.js create mode 100644 templates/other.html create mode 100644 tests/frontend/api/otherApi.test.js create mode 100644 tests/frontend/components/modelCard.subTypeBadge.test.js create mode 100644 tests/frontend/components/otherFactories.test.js create mode 100644 tests/frontend/pages/otherPage.test.js diff --git a/locales/de.json b/locales/de.json index a4bdf46b..db83bcbe 100644 --- a/locales/de.json +++ b/locales/de.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Checkpoint-Name kopieren", "copyEmbeddingName": "Embedding-Name kopieren", "embeddingNameCopied": "Embedding-Syntax kopiert", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "An ComfyUI senden", "sendEmbeddingToWorkflow": "An ComfyUI senden" }, @@ -233,6 +234,7 @@ "recipes": "Rezepte", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Statistiken" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding-Modelle" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Stammverzeichnis", "collapseAll": "Alle Ordner einklappen", @@ -1878,6 +1883,10 @@ "title": "Embedding Manager wird initialisiert", "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": { "title": "Rezept Manager wird initialisiert", "message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..." diff --git a/locales/en.json b/locales/en.json index b6c9e93b..c5e45b63 100644 --- a/locales/en.json +++ b/locales/en.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Copy checkpoint name", "copyEmbeddingName": "Copy embedding name", "embeddingNameCopied": "Embedding syntax copied", + "modelNameCopied": "Model name copied", "sendCheckpointToWorkflow": "Send to ComfyUI", "sendEmbeddingToWorkflow": "Send to ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Recipes", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "Other", "statistics": "Stats" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding Models" }, + "other": { + "title": "Other Models" + }, "sidebar": { "modelRoot": "Root", "collapseAll": "Collapse All Folders", @@ -1878,6 +1883,10 @@ "title": "Initializing Embedding Manager", "message": "Scanning and building embedding cache. This may take a few minutes..." }, + "other": { + "title": "Initializing Other Models Manager", + "message": "Scanning and building model cache. This may take a few minutes..." + }, "recipes": { "title": "Initializing Recipe Manager", "message": "Loading and processing recipes. This may take a few minutes..." diff --git a/locales/es.json b/locales/es.json index 6a555902..7d32f6ac 100644 --- a/locales/es.json +++ b/locales/es.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Copiar nombre del checkpoint", "copyEmbeddingName": "Copiar nombre del embedding", "embeddingNameCopied": "Sintaxis de embedding copiada", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "Enviar a ComfyUI", "sendEmbeddingToWorkflow": "Enviar a ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Recetas", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Estadísticas" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Modelos embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Raíz", "collapseAll": "Colapsar todas las carpetas", @@ -1878,6 +1883,10 @@ "title": "Inicializando gestor de embedding", "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": { "title": "Inicializando gestor de recetas", "message": "Cargando y procesando recetas. Esto puede tomar unos minutos..." diff --git a/locales/fr.json b/locales/fr.json index 9fbfa1fd..9d3464f3 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Copier le nom du checkpoint", "copyEmbeddingName": "Copier le nom de l'embedding", "embeddingNameCopied": "Syntaxe dembedding copiée", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "Envoyer vers ComfyUI", "sendEmbeddingToWorkflow": "Envoyer vers ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Recipes", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Statistiques" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Modèles Embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Racine", "collapseAll": "Réduire tous les dossiers", @@ -1878,6 +1883,10 @@ "title": "Initialisation du gestionnaire Embedding", "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": { "title": "Initialisation du gestionnaire de recipes", "message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..." diff --git a/locales/he.json b/locales/he.json index 589a34d0..d0da7ac1 100644 --- a/locales/he.json +++ b/locales/he.json @@ -149,6 +149,7 @@ "copyCheckpointName": "העתק שם Checkpoint", "copyEmbeddingName": "העתק שם Embedding", "embeddingNameCopied": "תחביר Embedding הועתק", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "שלח ל-ComfyUI", "sendEmbeddingToWorkflow": "שלח ל-ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "מתכונים", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "סטטיסטיקה" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "מודלי Embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "שורש", "collapseAll": "כווץ את כל התיקיות", @@ -1878,6 +1883,10 @@ "title": "מאתחל מנהל 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": { "title": "מאתחל מנהל מתכונים", "message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..." diff --git a/locales/ja.json b/locales/ja.json index 76698bc6..afc1edbb 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Checkpoint名をコピー", "copyEmbeddingName": "embedding名をコピー", "embeddingNameCopied": "Embedding構文をコピーしました", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "ComfyUIに送信", "sendEmbeddingToWorkflow": "ComfyUIに送信" }, @@ -233,6 +234,7 @@ "recipes": "レシピ", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "統計" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embeddingモデル" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "ルート", "collapseAll": "すべてのフォルダを折りたたむ", @@ -1878,6 +1883,10 @@ "title": "Embedding Managerを初期化中", "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": { "title": "レシピマネージャーを初期化中", "message": "レシピを読み込んで処理中。数分かかる場合があります..." diff --git a/locales/ko.json b/locales/ko.json index 69eecc88..b32d16da 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Checkpoint 이름 복사", "copyEmbeddingName": "Embedding 이름 복사", "embeddingNameCopied": "Embedding 구문 복사됨", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "ComfyUI로 전송", "sendEmbeddingToWorkflow": "ComfyUI로 전송" }, @@ -233,6 +234,7 @@ "recipes": "레시피", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "통계" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding 모델" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "루트", "collapseAll": "모든 폴더 접기", @@ -1878,6 +1883,10 @@ "title": "Embedding Manager 초기화 중", "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": { "title": "레시피 매니저 초기화 중", "message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..." diff --git a/locales/ru.json b/locales/ru.json index 2a19a262..bbdbd5a7 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -149,6 +149,7 @@ "copyCheckpointName": "Копировать имя checkpoint", "copyEmbeddingName": "Копировать имя embedding", "embeddingNameCopied": "Синтаксис embedding скопирован", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "Отправить в ComfyUI", "sendEmbeddingToWorkflow": "Отправить в ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "Рецепты", "checkpoints": "Checkpoints", "embeddings": "Embeddings", + "other": "[TODO: Translate] Other", "statistics": "Статистика" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Модели Embedding" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "Корень", "collapseAll": "Свернуть все папки", @@ -1878,6 +1883,10 @@ "title": "Инициализация Embedding Manager", "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": { "title": "Инициализация менеджера рецептов", "message": "Загрузка и обработка рецептов. Это может занять несколько минут..." diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 29935f11..817bdd54 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -149,6 +149,7 @@ "copyCheckpointName": "复制 Checkpoint 名称", "copyEmbeddingName": "复制 Embedding 名称", "embeddingNameCopied": "已复制 Embedding 语法", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "发送到 ComfyUI", "sendEmbeddingToWorkflow": "发送到 ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "配方", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "统计" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding 模型" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "根目录", "collapseAll": "折叠所有文件夹", @@ -1878,6 +1883,10 @@ "title": "初始化 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": { "title": "初始化配方管理器", "message": "正在加载和处理配方。这可能需要几分钟..." diff --git a/locales/zh-TW.json b/locales/zh-TW.json index dec6b87c..a79b4c45 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -149,6 +149,7 @@ "copyCheckpointName": "複製 Checkpoint 名稱", "copyEmbeddingName": "複製嵌入名稱", "embeddingNameCopied": "已複製 Embedding 語法", + "modelNameCopied": "[TODO: Translate] Model name copied", "sendCheckpointToWorkflow": "傳送到 ComfyUI", "sendEmbeddingToWorkflow": "傳送到 ComfyUI" }, @@ -233,6 +234,7 @@ "recipes": "配方", "checkpoints": "Checkpoint", "embeddings": "Embedding", + "other": "[TODO: Translate] Other", "statistics": "統計" }, "search": { @@ -1201,6 +1203,9 @@ "embeddings": { "title": "Embedding 模型" }, + "other": { + "title": "[TODO: Translate] Other Models" + }, "sidebar": { "modelRoot": "根目錄", "collapseAll": "全部摺疊資料夾", @@ -1878,6 +1883,10 @@ "title": "初始化 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": { "title": "初始化配方管理器", "message": "正在載入並處理配方,可能需要幾分鐘..." diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index dbecbab5..d1bce2cc 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -9,7 +9,8 @@ import { state } from '../state/index.js'; export const MODEL_TYPES = { LORA: 'loras', CHECKPOINT: 'checkpoints', - EMBEDDING: 'embeddings' // Future model type + EMBEDDING: 'embeddings', + OTHER: 'other' }; // Base API configuration for each model type @@ -40,6 +41,15 @@ export const MODEL_CONFIG = { supportsBulkOperations: true, supportsMove: true, templateName: 'embeddings.html' + }, + [MODEL_TYPES.OTHER]: { + displayName: 'Other Model', + singularName: 'other', + defaultPageSize: 100, + supportsLetterFilter: false, + supportsBulkOperations: true, + supportsMove: true, + templateName: 'other.html' } }; @@ -133,6 +143,9 @@ export const MODEL_SPECIFIC_ENDPOINTS = { }, [MODEL_TYPES.EMBEDDING]: { metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`, + }, + [MODEL_TYPES.OTHER]: { + metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`, } }; diff --git a/static/js/api/modelApiFactory.js b/static/js/api/modelApiFactory.js index 154b103b..c5ef1469 100644 --- a/static/js/api/modelApiFactory.js +++ b/static/js/api/modelApiFactory.js @@ -1,6 +1,7 @@ import { LoraApiClient } from './loraApi.js'; import { CheckpointApiClient } from './checkpointApi.js'; import { EmbeddingApiClient } from './embeddingApi.js'; +import { OtherApiClient } from './otherApi.js'; import { MODEL_TYPES, isValidModelType } from './apiConfig.js'; import { state } from '../state/index.js'; @@ -12,6 +13,8 @@ export function createModelApiClient(modelType) { return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT); case MODEL_TYPES.EMBEDDING: return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING); + case MODEL_TYPES.OTHER: + return new OtherApiClient(MODEL_TYPES.OTHER); default: throw new Error(`Unsupported model type: ${modelType}`); } diff --git a/static/js/api/otherApi.js b/static/js/api/otherApi.js new file mode 100644 index 00000000..eecf72d2 --- /dev/null +++ b/static/js/api/otherApi.js @@ -0,0 +1,7 @@ +import { BaseModelApiClient } from './baseModelApi.js'; + +/** + * Other-models-specific API client (VAE, upscalers, text encoders, etc.) + */ +export class OtherApiClient extends BaseModelApiClient { +} diff --git a/static/js/components/ContextMenu/BulkContextMenu.js b/static/js/components/ContextMenu/BulkContextMenu.js index b9194a66..1ba8be57 100644 --- a/static/js/components/ContextMenu/BulkContextMenu.js +++ b/static/js/components/ContextMenu/BulkContextMenu.js @@ -139,8 +139,8 @@ export class BulkContextMenu extends BaseContextMenu { const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]'); if (downloadExampleImagesSubmenu) { - // Show on model pages (loras, checkpoints, embeddings), hide on recipes - downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none'; + // Show on model pages (loras, checkpoints, embeddings, other), hide on recipes + downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none'; } const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]'); diff --git a/static/js/components/ContextMenu/ModelContextMenuMixin.js b/static/js/components/ContextMenu/ModelContextMenuMixin.js index 42a116c7..4fcf96c8 100644 --- a/static/js/components/ContextMenu/ModelContextMenuMixin.js +++ b/static/js/components/ContextMenu/ModelContextMenuMixin.js @@ -112,7 +112,8 @@ export const ModelContextMenuMixin = { const prefixMap = { lora: 'loras', checkpoint: 'checkpoints', - embedding: 'embeddings' + embedding: 'embeddings', + other: 'other' }; return prefixMap[this.modelType] || 'loras'; }, diff --git a/static/js/components/ContextMenu/OtherContextMenu.js b/static/js/components/ContextMenu/OtherContextMenu.js new file mode 100644 index 00000000..b51cad6e --- /dev/null +++ b/static/js/components/ContextMenu/OtherContextMenu.js @@ -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); diff --git a/static/js/components/ContextMenu/index.js b/static/js/components/ContextMenu/index.js index 7d01c0f6..a629cd4e 100644 --- a/static/js/components/ContextMenu/index.js +++ b/static/js/components/ContextMenu/index.js @@ -2,6 +2,7 @@ export { LoraContextMenu } from './LoraContextMenu.js'; export { RecipeContextMenu } from './RecipeContextMenu.js'; export { CheckpointContextMenu } from './CheckpointContextMenu.js'; export { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; +export { OtherContextMenu } from './OtherContextMenu.js'; export { GlobalContextMenu } from './GlobalContextMenu.js'; export { ModelContextMenuMixin } from './ModelContextMenuMixin.js'; @@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js'; import { RecipeContextMenu } from './RecipeContextMenu.js'; import { CheckpointContextMenu } from './CheckpointContextMenu.js'; import { EmbeddingContextMenu } from './EmbeddingContextMenu.js'; +import { OtherContextMenu } from './OtherContextMenu.js'; import { GlobalContextMenu } from './GlobalContextMenu.js'; // Factory method to create page-specific context menu instances @@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) { return new CheckpointContextMenu(); case 'embeddings': return new EmbeddingContextMenu(); + case 'other': + return new OtherContextMenu(); default: return null; } diff --git a/static/js/components/Header.js b/static/js/components/Header.js index 835bf560..56133956 100644 --- a/static/js/components/Header.js +++ b/static/js/components/Header.js @@ -32,6 +32,7 @@ export class HeaderManager { if (path.includes('/loras/recipes')) return 'recipes'; if (path.includes('/checkpoints')) return 'checkpoints'; if (path.includes('/embeddings')) return 'embeddings'; + if (path.includes('/other')) return 'other'; if (path.includes('/statistics')) return 'statistics'; if (path.includes('/loras')) return 'loras'; return 'unknown'; diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index e2d12e70..8c127be6 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -1126,6 +1126,7 @@ export class SidebarManager { recipes: 'Recipes', checkpoints: 'Checkpoints', embeddings: 'Embeddings', + other: 'Other Models', }; return names[this.pageType] || this.pageType; } @@ -1804,7 +1805,7 @@ export class SidebarManager { _migrateOldSettings() { if (getStorageItem('_sidebar_migration_done')) return; - const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings']; + const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other']; // 1. Migrate global hide setting to per-page if (state?.global?.settings?.show_folder_sidebar === false) { diff --git a/static/js/components/controls/OtherControls.js b/static/js/components/controls/OtherControls.js new file mode 100644 index 00000000..e7a19388 --- /dev/null +++ b/static/js/components/controls/OtherControls.js @@ -0,0 +1,60 @@ +// 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'; + +/** + * 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(); + }, + + 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); + } +} diff --git a/static/js/components/controls/index.js b/static/js/components/controls/index.js index 97f1ca91..5d1dcaa6 100644 --- a/static/js/components/controls/index.js +++ b/static/js/components/controls/index.js @@ -3,13 +3,14 @@ import { PageControls } from './PageControls.js'; import { LorasControls } from './LorasControls.js'; import { CheckpointsControls } from './CheckpointsControls.js'; import { EmbeddingsControls } from './EmbeddingsControls.js'; +import { OtherControls } from './OtherControls.js'; // Export the classes -export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls }; +export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls }; /** * Factory function to create the appropriate controls based on page type - * @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings') + * @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'other') * @returns {PageControls} - The appropriate controls instance */ export function createPageControls(pageType) { @@ -19,8 +20,10 @@ export function createPageControls(pageType) { return new CheckpointsControls(); } else if (pageType === 'embeddings') { return new EmbeddingsControls(); + } else if (pageType === 'other') { + return new OtherControls(); } else { console.error(`Unknown page type: ${pageType}`); return null; } -} \ No newline at end of file +} diff --git a/static/js/components/initialization.js b/static/js/components/initialization.js index 00a9409a..209b5311 100644 --- a/static/js/components/initialization.js +++ b/static/js/components/initialization.js @@ -58,6 +58,8 @@ class InitializationManager { this.pageType = 'recipes'; } else if (path.includes('/checkpoints')) { this.pageType = 'checkpoints'; + } else if (path.includes('/other')) { + this.pageType = 'other'; } else if (path.includes('/loras')) { this.pageType = 'loras'; } else if (path.includes('/embeddings')) { @@ -221,6 +223,7 @@ class InitializationManager { 'lora': 'loras', 'checkpoint': 'checkpoints', 'embedding': 'embeddings', + 'other': 'other', 'recipe': 'recipes' }; diff --git a/static/js/components/shared/ModelCard.js b/static/js/components/shared/ModelCard.js index fd0f9a20..67dcee31 100644 --- a/static/js/components/shared/ModelCard.js +++ b/static/js/components/shared/ModelCard.js @@ -250,6 +250,11 @@ function handleCopyAction(card, modelType) { const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`; const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied'); copyToClipboard(embeddingCode, message); + } else { + // Other model types (VAE, upscalers, ...) - copy the file name + const fileName = card.dataset.file_name; + const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied'); + copyToClipboard(fileName, message); } } diff --git a/static/js/core.js b/static/js/core.js index c9943bff..866a136a 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -116,7 +116,7 @@ export class AppCore { initializePageFeatures() { const pageType = this.getPageType(); - if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) { + if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) { this.initializeContextMenus(pageType); initializeInfiniteScroll(pageType); } diff --git a/static/js/managers/BannerService.js b/static/js/managers/BannerService.js index fe837aca..5b4b4a34 100644 --- a/static/js/managers/BannerService.js +++ b/static/js/managers/BannerService.js @@ -424,12 +424,13 @@ class BannerService { /** * Get the current page type from the URL - * @returns {string} Page type (loras, checkpoints, embeddings, recipes) + * @returns {string} Page type (loras, checkpoints, embeddings, other, recipes) */ getCurrentPageType() { const path = window.location.pathname; if (path.includes('/checkpoints')) return 'checkpoints'; if (path.includes('/embeddings')) return 'embeddings'; + if (path.includes('/other')) return 'other'; if (path.includes('/recipes')) return 'recipes'; return 'loras'; } @@ -443,7 +444,8 @@ class BannerService { const endpoints = { 'loras': '/api/lm/loras/reload?rebuild=true', 'checkpoints': '/api/lm/checkpoints/reload?rebuild=true', - 'embeddings': '/api/lm/embeddings/reload?rebuild=true' + 'embeddings': '/api/lm/embeddings/reload?rebuild=true', + 'other': '/api/lm/other/reload?rebuild=true' }; return endpoints[pageType] || endpoints['loras']; } diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index c7bee250..284128c8 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -93,6 +93,20 @@ export class BulkManager { setFavorite: true, unfavorite: true }, + [MODEL_TYPES.OTHER]: { + addTags: true, + sendToWorkflow: false, + copyAll: false, + refreshAll: true, + checkUpdates: true, + moveAll: true, + autoOrganize: true, + deleteAll: true, + setContentRating: true, + skipMetadataRefresh: true, + setFavorite: true, + unfavorite: true + }, recipes: { addTags: true, sendToWorkflow: false, diff --git a/static/js/managers/FilterManager.js b/static/js/managers/FilterManager.js index 218bbfb9..76640f33 100644 --- a/static/js/managers/FilterManager.js +++ b/static/js/managers/FilterManager.js @@ -805,7 +805,7 @@ export class FilterManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') { // For models page, reset the page and reload await getModelApiClient().loadMoreWithVirtualScroll(true, false); } @@ -904,7 +904,7 @@ export class FilterManager { // Reload data using the appropriate method for the current page if (this.currentPage === 'recipes' && window.recipeManager) { await window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { + } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') { await getModelApiClient().loadMoreWithVirtualScroll(true, true); } diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index da1656ca..d5042415 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -60,7 +60,6 @@ class MoveManager { this.bulkFilePaths = null; const apiClient = this._getApiClient(modelType); - const currentPageType = state.currentPageType; const modelConfig = apiClient.apiConfig.config; // Handle bulk mode @@ -113,7 +112,7 @@ class MoveManager { ).join(''); // Set default root if available - const settingsKey = `default_${currentPageType.slice(0, -1)}_root`; + const settingsKey = `default_${modelConfig.singularName}_root`; const defaultRoot = state.global.settings[settingsKey]; if (defaultRoot && rootsData.roots.includes(defaultRoot)) { modelRootSelect.value = defaultRoot; @@ -228,13 +227,12 @@ class MoveManager { if (modelRoot) { if (this.useDefaultPath) { // Show actual template path - try { - const singularType = apiClient.modelType.replace(/s$/, ''); - const templates = state.global.settings.download_path_templates; - const template = templates[singularType]; + const singularType = config.singularName || apiClient.modelType.replace(/s$/, ''); + const templates = state.global.settings.download_path_templates; + const template = templates[singularType]; + if (template) { fullPath += `/${template}`; - } catch (error) { - console.error('Failed to fetch template:', error); + } else { fullPath += '/' + translate('modals.download.autoOrganizedPath'); } } else { diff --git a/static/js/managers/SearchManager.js b/static/js/managers/SearchManager.js index b4612f20..c610618d 100644 --- a/static/js/managers/SearchManager.js +++ b/static/js/managers/SearchManager.js @@ -298,7 +298,7 @@ export class SearchManager { pageState.searchOptions.loraName = options.loraName || false; pageState.searchOptions.loraModel = options.loraModel || false; pageState.searchOptions.prompt = options.prompt || false; - } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') { + } else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') { // Update only the relevant fields in searchOptions instead of replacing the whole object pageState.searchOptions.filename = options.filename || false; pageState.searchOptions.modelname = options.modelname || false; @@ -311,7 +311,7 @@ export class SearchManager { // Call the appropriate manager's load method based on page type if (this.currentPage === 'recipes' && window.recipeManager) { window.recipeManager.loadRecipes(true); - } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') { + } else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') { // For models page, reset the page and reload getModelApiClient().loadMoreWithVirtualScroll(true, false); } diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index fa2239bb..ec5f2dc9 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -3360,6 +3360,9 @@ export class SettingsManager { } else if (this.currentPage === 'embeddings') { // Reload the embeddings without updating folders await resetAndReload(false); + } else if (this.currentPage === 'other') { + // Reload the other models without updating folders + await resetAndReload(false); } } diff --git a/static/js/other.js b/static/js/other.js new file mode 100644 index 00000000..401ac7e0 --- /dev/null +++ b/static/js/other.js @@ -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 }; diff --git a/static/js/state/index.js b/static/js/state/index.js index af73d4c9..e20b82d4 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -79,6 +79,7 @@ export function createDefaultSettings() { const loraPreviewVersions = getMapFromStorage('loras_preview_versions'); const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions'); const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions'); +const otherPreviewVersions = getMapFromStorage('other_preview_versions'); export const state = { // Global state @@ -234,6 +235,44 @@ export const state = { search: '', }, activeViewSnapshot: null, + }, + + [MODEL_TYPES.OTHER]: { + currentPage: 1, + isLoading: false, + hasMore: true, + sortBy: 'name', + activeFolder: getStorageItem(`${MODEL_TYPES.OTHER}_activeFolder`), + previewVersions: otherPreviewVersions, + searchManager: null, + searchOptions: { + filename: true, + modelname: true, + tags: false, + creator: false, + hash: false, + recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true), + }, + filters: { + baseModel: [], + tags: {}, + license: {}, + modelTypes: [], + search: '', + tagLogic: 'any', + }, + bulkMode: false, + selectedModels: new Set(), + metadataCache: new Map(), + showFavoritesOnly: false, + showUpdateAvailableOnly: false, + duplicatesMode: false, + viewMode: 'active', + excludedViewState: { + sortBy: 'name:asc', + search: '', + }, + activeViewSnapshot: null, } }, diff --git a/static/js/utils/activeFiltersSync.js b/static/js/utils/activeFiltersSync.js index 1500f2d4..51e45ca3 100644 --- a/static/js/utils/activeFiltersSync.js +++ b/static/js/utils/activeFiltersSync.js @@ -53,7 +53,7 @@ export function syncActiveFilters(pageType) { * Register the storage listener and push the current (restored) state once. * The initial push covers server restarts, where the backend store is empty * until the manager page re-publishes its localStorage-restored filters. - * @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' + * @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' | 'other' */ export function initActiveFiltersSync(pageType) { setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType)); diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index f29f7522..412ac647 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -106,6 +106,12 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = { diffusion_model: "Diffusion Model", // Embedding sub-types embedding: "Embedding", + // Other model sub-types + vae: "VAE", + upscaler: "Upscaler", + text_encoder: "Text Encoder", + clip_vision: "CLIP Vision", + controlnet: "ControlNet", }; // Backward compatibility alias @@ -119,6 +125,11 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = { checkpoint: "CKPT", diffusion_model: "DM", embedding: "EMB", + vae: "VAE", + upscaler: "UPS", + text_encoder: "TE", + clip_vision: "CV", + controlnet: "CN", }; export function getSubTypeAbbreviation(subType) { diff --git a/static/js/utils/infiniteScroll.js b/static/js/utils/infiniteScroll.js index 9e834f29..138bceb4 100644 --- a/static/js/utils/infiniteScroll.js +++ b/static/js/utils/infiniteScroll.js @@ -66,7 +66,7 @@ async function getCardCreator(pageType) { // Function to get the appropriate data fetcher based on page type async function getDataFetcher(pageType) { - if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints') { + if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints' || pageType === 'other') { return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize); } else if (pageType === 'recipes') { // Import the recipeApi module and use the fetchRecipesPage function diff --git a/static/js/utils/storageHelpers.js b/static/js/utils/storageHelpers.js index 4250605a..82a7df88 100644 --- a/static/js/utils/storageHelpers.js +++ b/static/js/utils/storageHelpers.js @@ -8,7 +8,7 @@ const STORAGE_PREFIX = 'lora_manager_'; // Matches keys that carry the manager page's active filter state // (e.g. 'loras_activeFolder', 'checkpoints_filters'). -const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/; +const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings|other)_(activeFolder|recursiveSearch|filters)$/; let activeFiltersListener = null; diff --git a/templates/components/controls.html b/templates/components/controls.html index 49532bd2..ef83941b 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -36,7 +36,7 @@ {% endif %} - {% if page_id != 'embeddings' and page_id != 'recipes' %} + {% if page_id != 'embeddings' and page_id != 'recipes' and page_id != 'other' %} @@ -80,12 +80,14 @@
+ {% if page_id != 'other' %}
{% endif %} + {% endif %} {% if page_id == 'recipes' %}
diff --git a/templates/components/header.html b/templates/components/header.html index 9785e4bb..d55f328c 100644 --- a/templates/components/header.html +++ b/templates/components/header.html @@ -15,6 +15,8 @@ {% set current_page = 'checkpoints' %} {% elif current_path.startswith('/embeddings') %} {% set current_page = 'embeddings' %} + {% elif current_path.startswith('/other') %} + {% set current_page = 'other' %} {% elif current_path.startswith('/statistics') %} {% set current_page = 'statistics' %} {% else %} @@ -36,6 +38,10 @@ id="embeddingsNavItem"> {{ t('header.navigation.embeddings') }} + + {{ t('header.navigation.other') }} + {{ t('header.navigation.statistics') }} @@ -208,7 +214,7 @@
{{ t('header.search.filters.tags') }}
{{ t('header.search.filters.creator') }}
{{ t('header.search.filters.hash') }}
- {% elif request.path == '/embeddings' %} + {% elif request.path == '/embeddings' or request.path == '/other' %}
{{ t('header.search.filters.filename') }}
{{ t('header.search.filters.modelname') }}
{{ t('header.search.filters.tags') }}
@@ -295,7 +301,7 @@
{% endif %} - {% if current_page == 'loras' or current_page == 'checkpoints' %} + {% if current_page == 'loras' or current_page == 'checkpoints' or current_page == 'other' %}

{{ t('header.filter.modelTypes') }}

diff --git a/templates/other.html b/templates/other.html new file mode 100644 index 00000000..46f25668 --- /dev/null +++ b/templates/other.html @@ -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 %} + + +{% endblock %} + +{% block content %} +
+ {% include 'components/controls.html' %} + {% include 'components/breadcrumb.html' %} +
+ {% include 'components/duplicates_banner.html' %} + {% include 'components/folder_sidebar.html' %} + + +
+ +
+{% endblock %} + +{% block overlay %} +
+{% endblock %} + +{% block main_script %} + +{% endblock %} diff --git a/tests/frontend/api/otherApi.test.js b/tests/frontend/api/otherApi.test.js new file mode 100644 index 00000000..c8293bf4 --- /dev/null +++ b/tests/frontend/api/otherApi.test.js @@ -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'); + }); +}); diff --git a/tests/frontend/components/modelCard.subTypeBadge.test.js b/tests/frontend/components/modelCard.subTypeBadge.test.js new file mode 100644 index 00000000..a1bea917 --- /dev/null +++ b/tests/frontend/components/modelCard.subTypeBadge.test.js @@ -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'); + }); +}); diff --git a/tests/frontend/components/otherFactories.test.js b/tests/frontend/components/otherFactories.test.js new file mode 100644 index 00000000..39cd0983 --- /dev/null +++ b/tests/frontend/components/otherFactories.test.js @@ -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 = ''; + }); + + 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'); + }); +}); diff --git a/tests/frontend/core/appCore.test.js b/tests/frontend/core/appCore.test.js index 71cdae9e..d7d54bcd 100644 --- a/tests/frontend/core/appCore.test.js +++ b/tests/frontend/core/appCore.test.js @@ -120,7 +120,7 @@ import { initializeEventManagement } from '../../../static/js/utils/eventManagem import { initializeInfiniteScroll } from '../../../static/js/utils/infiniteScroll.js'; import { createPageContextMenu, createGlobalContextMenu } from '../../../static/js/components/ContextMenu/index.js'; -const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings']; +const SUPPORTED_PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other']; describe('AppCore page orchestration', () => { beforeEach(() => { diff --git a/tests/frontend/pages/otherPage.test.js b/tests/frontend/pages/otherPage.test.js new file mode 100644 index 00000000..b39005c5 --- /dev/null +++ b/tests/frontend/pages/otherPage.test.js @@ -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); + }); +}); diff --git a/tests/frontend/utils/pageFixtures.js b/tests/frontend/utils/pageFixtures.js index 465ea8b3..df8bcb24 100644 --- a/tests/frontend/utils/pageFixtures.js +++ b/tests/frontend/utils/pageFixtures.js @@ -36,6 +36,18 @@ export function renderEmbeddingsPage() { }); } +/** + * Renders the Other Models page template with expected dataset attributes. + * @returns {Element} + */ +export function renderOtherPage() { + return renderTemplate('other.html', { + dataset: { + page: 'other', + }, + }); +} + /** * Renders the Recipes page template with expected dataset attributes. * @returns {Element} From 57729375b6b3f6b20d4a99c3163d573507c22096 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 14:24:35 +0800 Subject: [PATCH 4/7] docs: detail Phase 2 download design for Other Models page --- docs/plans/other-models-page.md | 98 ++++++++++++++++++++++++++++++--- 1 file changed, 90 insertions(+), 8 deletions(-) diff --git a/docs/plans/other-models-page.md b/docs/plans/other-models-page.md index 1c2a1847..97d038aa 100644 --- a/docs/plans/other-models-page.md +++ b/docs/plans/other-models-page.md @@ -1,7 +1,7 @@ # Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc. -**Status:** v1 — decisions locked with feature owner (2026-09-12); ready for implementation. -**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 (out of scope here, outlined in §9):** one-click download from CivitAI for these types. +**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 designed** (§9, decisions pending owner sign-off). +**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 @@ -152,14 +152,87 @@ Follow existing conventions (`pytest.ini`, `tests/frontend/` vitest): 6. i18n keys + sync script 7. `pytest` + `npm test` full runs; hand to user for manual UI check -## 9. Phase 2 Outline (NOT in this implementation) +## 9. Phase 2 Detailed Design — CivitAI Downloads for `other` -CivitAI downloads for `other` types: +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. -- `py/services/download_manager.py`: extend the type map (`:1496-1507` — currently rejects unknown types), `_get_scanner_for_model_type` (`:230-236`), `_build_metadata_for_resume` (`:977-981`). -- **Default-root selection** (`:1699-1727`): needs per-sub_type settings keys (`default_vae_root`, `default_upscaler_root`, …) — this is what pulls in `settings_manager.py` (library default keys `:82-85`, `_check_and_auto_set` ~`:890`, `set()` dispatch `:1621-1628`, `upsert_library` params, library-switch scanner notifications `:1795-1799`/`:2150-2155`). -- File-type routing: consult CivitAI's `primaryFileTypesByModelType` (e.g. CLIP-type models ship `Text Encoder`/`Vision Encoder` primary files, not `Model`); a `file.type → target folder` map analogous to `download_routing.py:is_diffusion_model_download`. -- Settings UI for the new default roots. +### 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 @@ -168,3 +241,12 @@ CivitAI downloads for `other` types: - **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. From f2a7297cb99feb7639bf7d8df0a387f65c398703 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 15:56:47 +0800 Subject: [PATCH 5/7] feat(backend): CivitAI download support for other model types with subtype routing --- .../handlers/download_routing_handlers.py | 26 +- py/routes/handlers/misc_handlers.py | 26 + py/routes/other_routes.py | 34 +- py/services/download_manager.py | 93 ++- py/services/download_routing.py | 54 +- py/services/settings_manager.py | 114 ++++ py/utils/constants.py | 15 + py/utils/example_images_download_manager.py | 12 + py/utils/models.py | 3 +- settings.json.example | 1 + .../routes/test_download_routing_handlers.py | 58 ++ tests/routes/test_misc_routes.py | 34 +- tests/routes/test_other_routes.py | 47 ++ tests/services/test_download_manager_other.py | 565 ++++++++++++++++++ tests/services/test_download_routing.py | 102 ++++ .../test_example_images_other_dispatch.py | 168 ++++++ tests/services/test_other_scanner.py | 22 +- tests/services/test_settings_manager.py | 134 +++++ 18 files changed, 1492 insertions(+), 16 deletions(-) create mode 100644 tests/services/test_download_manager_other.py create mode 100644 tests/services/test_example_images_other_dispatch.py diff --git a/py/routes/handlers/download_routing_handlers.py b/py/routes/handlers/download_routing_handlers.py index 0d3c7a12..645b38a7 100644 --- a/py/routes/handlers/download_routing_handlers.py +++ b/py/routes/handlers/download_routing_handlers.py @@ -7,7 +7,11 @@ import logging from aiohttp import web -from ...services.download_routing import is_diffusion_model_download +from ...services.download_routing import ( + is_diffusion_model_download, + resolve_other_download_sub_type, +) +from ...utils.constants import VALID_OTHER_CIVITAI_TYPES logger = logging.getLogger(__name__) @@ -31,6 +35,7 @@ class DownloadRoutingHandler: model_type = payload.get("model_type", "") base_model = payload.get("base_model") or "" file_types = payload.get("file_types") or [] + selected_file_type = payload.get("selected_file_type") if not isinstance(model_type, str) or not model_type: return web.json_response( @@ -44,6 +49,25 @@ class DownloadRoutingHandler: }, status=400, ) + if selected_file_type is not None and not isinstance(selected_file_type, str): + return web.json_response( + {"success": False, "error": "selected_file_type must be a string"}, + status=400, + ) + + if model_type.lower() in VALID_OTHER_CIVITAI_TYPES: + 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( model_type, diff --git a/py/routes/handlers/misc_handlers.py b/py/routes/handlers/misc_handlers.py index c461841d..5ca0450b 100644 --- a/py/routes/handlers/misc_handlers.py +++ b/py/routes/handlers/misc_handlers.py @@ -53,6 +53,7 @@ from ...utils.constants import ( PREVIEW_EXTENSIONS, SUPPORTED_MEDIA_EXTENSIONS, VALID_LORA_TYPES, + VALID_OTHER_CIVITAI_TYPES, ) from .hf_handlers import HfHandler from .agent_handlers import AgentHandler @@ -2068,6 +2069,7 @@ class ServiceRegistryAdapter: get_embedding_scanner: Callable[[], Awaitable[Any]] get_downloaded_version_history_service: Callable[[], Awaitable[Any]] get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service + get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner class ModelLibraryHandler: @@ -2789,12 +2791,30 @@ class ModelLibraryHandler: model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES } lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES} + other_type_aliases = { + model_type.lower() for model_type in VALID_OTHER_CIVITAI_TYPES + } + + # Acquire the other scanner lazily so adapters without it only + # fail when the payload actually contains other-type models. + needs_other_scanner = any( + isinstance(model, dict) + and str(model.get("type", "")).lower() in other_type_aliases + for model in models + ) + other_scanner = None + if needs_other_scanner: + other_scanner = await self._service_registry.get_other_scanner() type_scanner_map: Dict[str, Any] = { **{alias: lora_scanner for alias in lora_type_aliases}, "checkpoint": checkpoint_scanner, "textualinversion": embedding_scanner, } + if other_scanner is not None: + type_scanner_map.update( + {alias: other_scanner for alias in other_type_aliases} + ) versions: list[dict[str, Any]] = [] history_service = await self._get_download_history_service() @@ -2818,12 +2838,17 @@ class ModelLibraryHandler: "embedding", model_ids, ) + other_downloaded = await history_service.get_downloaded_version_ids_bulk( + "other", + model_ids, + ) downloaded_version_map: Dict[str, Dict[int, set[int]]] = { "lora": lora_downloaded, "locon": lora_downloaded, "dora": lora_downloaded, "checkpoint": checkpoint_downloaded, "textualinversion": embedding_downloaded, + **{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES}, } for model in models: if not isinstance(model, dict): @@ -3982,6 +4007,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter: get_lora_scanner=ServiceRegistry.get_lora_scanner, get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner, get_embedding_scanner=ServiceRegistry.get_embedding_scanner, + get_other_scanner=ServiceRegistry.get_other_scanner, get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service, get_backup_service=ServiceRegistry.get_backup_service, ) diff --git a/py/routes/other_routes.py b/py/routes/other_routes.py index be36e066..3647c840 100644 --- a/py/routes/other_routes.py +++ b/py/routes/other_routes.py @@ -1,12 +1,13 @@ import logging -from typing import Any, Dict +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 VALID_OTHER_CIVITAI_TYPES +from ..utils.constants import OTHER_MODEL_FOLDER_SUBTYPES, VALID_OTHER_CIVITAI_TYPES logger = logging.getLogger(__name__) @@ -41,6 +42,9 @@ class OtherRoutes(BaseModelRoutes): """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. @@ -58,6 +62,32 @@ class OtherRoutes(BaseModelRoutes): """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: diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 2f52f337..c77f28bf 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -17,12 +17,18 @@ from dataclasses import dataclass, field import uuid from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast from urllib.parse import urlparse -from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata +from ..utils.models import ( + LoraMetadata, + CheckpointMetadata, + EmbeddingMetadata, + OtherModelMetadata, +) from ..utils.constants import ( CARD_PREVIEW_WIDTH, MODEL_WEIGHT_FILE_TYPES, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, VALID_LORA_TYPES, + VALID_OTHER_CIVITAI_TYPES, ) from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url from ..utils.file_utils import calculate_sha256, calculate_autov3 @@ -31,7 +37,7 @@ from ..utils.utils import sanitize_folder_name from ..utils.exif_utils import ExifUtils from ..utils.metadata_manager import MetadataManager from .service_registry import ServiceRegistry -from .download_routing import is_diffusion_model_download +from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type from .settings_manager import get_settings_manager from .metadata_service import get_default_metadata_provider, get_metadata_provider from .downloader import get_downloader, DownloadProgress, DownloadStreamControl @@ -228,12 +234,21 @@ class DownloadManager: return False async def _get_scanner_for_model_type(self, model_type: str): - """Return the scanner responsible for the given model type.""" + """Return the scanner responsible for the given model type. + + Every supported type resolves explicitly — an unknown type must never + fall through to the lora scanner (an "other" download would silently + dedupe against the lora library). + """ if model_type == "checkpoint": return await self._get_checkpoint_scanner() if model_type == "embedding": return await ServiceRegistry.get_embedding_scanner() - return await self._get_lora_scanner() + if model_type == "other": + return await ServiceRegistry.get_other_scanner() + if model_type == "lora": + return await self._get_lora_scanner() + raise ValueError(f'Unknown model type "{model_type}"') @staticmethod def _resolve_target_file( @@ -978,6 +993,8 @@ class DownloadManager: return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path) if model_type == "embedding": return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path) + if model_type == "other": + return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path) return LoraMetadata.from_civitai_info(version_info, file_info, save_path) def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]: @@ -1438,6 +1455,7 @@ class DownloadManager: lora_scanner = await self._get_lora_scanner() checkpoint_scanner = await self._get_checkpoint_scanner() embedding_scanner = await ServiceRegistry.get_embedding_scanner() + other_scanner = await ServiceRegistry.get_other_scanner() # Check lora scanner first if await lora_scanner.check_model_version_exists(model_version_id): @@ -1462,6 +1480,13 @@ class DownloadManager: "error": "Model version already exists in embedding library", } + # Check other scanner + if await other_scanner.check_model_version_exists(model_version_id): + return { + "success": False, + "error": "Model version already exists in other library", + } + # Use CivArchive provider directly when source is 'civarchive' # This prioritizes CivArchive metadata (with mirror availability info) over Civitai if source == "civarchive": @@ -1500,6 +1525,8 @@ class DownloadManager: model_type = "lora" elif model_type_from_info == "textualinversion": model_type = "embedding" + elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES: + model_type = "other" else: return { "success": False, @@ -1686,6 +1713,13 @@ class DownloadManager: "success": False, "error": "Model version already exists in embedding library", } + elif model_type == "other": + other_scanner = await ServiceRegistry.get_other_scanner() + if await other_scanner.check_model_version_exists(version_id): + return { + "success": False, + "error": "Model version already exists in other library", + } # Handle use_default_paths if use_default_paths: @@ -1725,6 +1759,45 @@ class DownloadManager: "error": "Default embedding root path not set in settings", } save_dir = default_path + elif model_type == "other": + other_sub_type = resolve_other_download_sub_type( + model_type_from_info, + file_types=( + f.get("type", "") + for f in version_info.get("files", []) + if isinstance(f, dict) + ), + selected_file_type=( + target_file.get("type") if explicit_file else None + ), + ) + default_other_roots = ( + settings_manager.get("default_other_roots") or {} + ) + 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 relative_path = self._calculate_relative_path(version_info, model_type) @@ -1921,6 +1994,11 @@ class DownloadManager: version_info, file_info, save_path ) logger.info(f"Creating EmbeddingMetadata for {file_name}") + elif model_type == "other": + metadata = OtherModelMetadata.from_civitai_info( + version_info, file_info, save_path + ) + logger.info(f"Creating OtherModelMetadata for {file_name}") else: return { "success": False, @@ -2133,6 +2211,8 @@ class DownloadManager: scanner = await self._get_checkpoint_scanner() elif model_type == "embedding": scanner = await ServiceRegistry.get_embedding_scanner() + elif model_type == "other": + scanner = await ServiceRegistry.get_other_scanner() except Exception as exc: logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc) @@ -2629,6 +2709,9 @@ class DownloadManager: elif model_type == "embedding": scanner = await ServiceRegistry.get_embedding_scanner() logger.info(f"Updating embedding cache for {actual_file_paths[0]}") + elif model_type == "other": + scanner = await ServiceRegistry.get_other_scanner() + logger.info(f"Updating other-model cache for {actual_file_paths[0]}") adjust_cached_entry = ( getattr(scanner, "adjust_cached_entry", None) @@ -2718,7 +2801,7 @@ class DownloadManager: return {"success": False, "error": str(e)} def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]: - if model_type == "checkpoint": + if model_type in ("checkpoint", "other"): return { ".ckpt", ".pt", diff --git a/py/services/download_routing.py b/py/services/download_routing.py index ec09d24d..a18c2080 100644 --- a/py/services/download_routing.py +++ b/py/services/download_routing.py @@ -10,9 +10,13 @@ two can never disagree. from __future__ import annotations import logging -from typing import Iterable +from typing import Iterable, Optional -from ..utils.constants import DIFFUSION_MODEL_BASE_MODELS +from ..utils.constants import ( + CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE, + CIVITAI_TYPE_TO_OTHER_SUB_TYPE, + DIFFUSION_MODEL_BASE_MODELS, +) logger = logging.getLogger(__name__) @@ -51,3 +55,49 @@ def is_diffusion_model_download( return True return False + + +def resolve_other_download_sub_type( + civitai_model_type: str, + file_types: Iterable[str] = (), + selected_file_type: Optional[str] = None, +) -> Optional[str]: + """Resolve the "other"-page sub_type for a download. + + Fixed priority (locked design, docs/plans/other-models-page.md §9.2): + + 1. Explicit user file pick — when the picked file's type maps, it wins + even when model.type maps to something else. + 2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE. + 3. file.type fallback — only when model.type maps to nothing. Must NOT + override a mapped model.type: checkpoint models routinely bundle + VAE/Text Encoder component files. + 4. Still undecidable -> None (caller must ask the user for a folder). + """ + if selected_file_type: + mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type) + if mapped: + logger.info( + "Explicit file pick type '%s' routes other download to '%s'", + selected_file_type, + mapped, + ) + return mapped + + normalized_model_type = (civitai_model_type or "").strip().lower() + mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type) + if mapped: + return mapped + + for file_type in file_types: + mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type) + if mapped: + logger.info( + "model.type '%s' unmapped; file type '%s' routes other download to '%s'", + civitai_model_type, + file_type, + mapped, + ) + return mapped + + return None diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 08d71ac0..cdfb41fb 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -27,7 +27,9 @@ from platformdirs import user_config_dir from ..utils.constants import ( DEFAULT_HASH_CHUNK_SIZE_MB, DEFAULT_PRIORITY_TAG_CONFIG, + OTHER_MODEL_FOLDER_SUBTYPES, SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS, + VALID_OTHER_SUB_TYPES, ) from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS from ..utils.settings_paths import ( @@ -83,6 +85,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "default_checkpoint_root": "", "default_unet_root": "", "default_embedding_root": "", + "default_other_roots": {}, "recipes_path": "", "base_model_path_mappings": {}, "download_path_templates": {}, @@ -309,6 +312,7 @@ class SettingsManager: default_checkpoint_root=merged.get("default_checkpoint_root"), default_unet_root=merged.get("default_unet_root"), default_embedding_root=merged.get("default_embedding_root"), + default_other_roots=merged.get("default_other_roots"), recipes_path=merged.get("recipes_path"), ) } @@ -443,6 +447,7 @@ class SettingsManager: ), default_unet_root=self.settings.get("default_unet_root", ""), default_embedding_root=self.settings.get("default_embedding_root", ""), + default_other_roots=self.settings.get("default_other_roots"), recipes_path=self.settings.get("recipes_path", ""), ) libraries = {library_name: library_payload} @@ -494,6 +499,7 @@ class SettingsManager: default_checkpoint_root=data.get("default_checkpoint_root"), default_unet_root=data.get("default_unet_root"), default_embedding_root=data.get("default_embedding_root"), + default_other_roots=data.get("default_other_roots"), recipes_path=data.get("recipes_path"), metadata=data.get("metadata"), base=data, @@ -541,6 +547,9 @@ class SettingsManager: self.settings["default_embedding_root"] = active_library.get( "default_embedding_root", "" ) + self.settings["default_other_roots"] = self._normalize_default_other_roots( + active_library.get("default_other_roots", {}) + ) self.settings["recipes_path"] = active_library.get("recipes_path", "") if save: @@ -558,6 +567,7 @@ class SettingsManager: default_checkpoint_root: Optional[str] = None, default_unet_root: Optional[str] = None, default_embedding_root: Optional[str] = None, + default_other_roots: Optional[Mapping[str, str]] = None, recipes_path: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, base: Optional[Mapping[str, Any]] = None, @@ -597,6 +607,15 @@ class SettingsManager: else: payload.setdefault("default_embedding_root", "") + if default_other_roots is not None: + payload["default_other_roots"] = self._normalize_default_other_roots( + default_other_roots + ) + else: + payload["default_other_roots"] = self._normalize_default_other_roots( + payload.get("default_other_roots", {}) + ) + if recipes_path is not None: payload["recipes_path"] = recipes_path else: @@ -632,6 +651,35 @@ class SettingsManager: normalized[key] = cleaned return normalized + def _normalize_default_other_roots( + self, value: Any, *, strict: bool = False + ) -> Dict[str, str]: + """Normalize a ``default_other_roots`` mapping ({sub_type: root path}). + + Unknown sub_type keys and non-string/empty paths are dropped; with + ``strict=True`` unknown sub_type keys raise instead (used by ``set()`` + so typos in API payloads surface as errors). + """ + if not isinstance(value, Mapping): + if strict and value is not None: + raise ValueError("default_other_roots must be a mapping") + return {} + normalized: Dict[str, str] = {} + for sub_type, path in value.items(): + if sub_type not in VALID_OTHER_SUB_TYPES: + if strict: + raise ValueError( + f"Unknown other-model sub-type '{sub_type}'; " + f"expected one of {sorted(VALID_OTHER_SUB_TYPES)}" + ) + continue + if not isinstance(path, str): + continue + stripped = path.strip() + if stripped: + normalized[sub_type] = stripped + return normalized + def _has_configured_paths(self, folder_paths: Any) -> bool: if not isinstance(folder_paths, Mapping): return False @@ -744,6 +792,7 @@ class SettingsManager: default_checkpoint_root: Optional[str] = None, default_unet_root: Optional[str] = None, default_embedding_root: Optional[str] = None, + default_other_roots: Optional[Mapping[str, str]] = None, recipes_path: Optional[str] = None, ) -> bool: libraries = self.settings.get("libraries", {}) @@ -794,6 +843,14 @@ class SettingsManager: library["default_embedding_root"] = default_embedding_root changed = True + if default_other_roots is not None: + normalized_other_roots = self._normalize_default_other_roots( + default_other_roots + ) + if library.get("default_other_roots") != normalized_other_roots: + library["default_other_roots"] = normalized_other_roots + changed = True + if recipes_path is not None and library.get("recipes_path") != recipes_path: library["recipes_path"] = recipes_path changed = True @@ -894,12 +951,53 @@ class SettingsManager: updated = _check_and_auto_set("unet", "default_unet_root") or updated updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated + # Other-model default roots: one entry per 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: + self.settings["default_other_roots"] = other_roots self._update_active_library_entry( default_lora_root=self.settings.get("default_lora_root"), default_checkpoint_root=self.settings.get("default_checkpoint_root"), default_unet_root=self.settings.get("default_unet_root"), default_embedding_root=self.settings.get("default_embedding_root"), + default_other_roots=other_roots, ) if self._bootstrap_reason == "missing": self._needs_initial_save = True @@ -1599,6 +1697,8 @@ class SettingsManager: value = self.normalize_download_skip_base_models(value) elif key == "mature_blur_level": value = self.normalize_mature_blur_level(value) + elif key == "default_other_roots": + value = self._normalize_default_other_roots(value, strict=True) elif key == "recipes_path": current_recipes_dir = self._get_effective_recipes_dir() value = self._normalize_recipes_path_value(value) @@ -1626,6 +1726,8 @@ class SettingsManager: self._update_active_library_entry(default_unet_root=str(value)) elif key == "default_embedding_root": self._update_active_library_entry(default_embedding_root=str(value)) + elif key == "default_other_roots": + self._update_active_library_entry(default_other_roots=value) elif key == "recipes_path": self._update_active_library_entry(recipes_path=str(value)) elif key == "model_name_display": @@ -1796,6 +1898,7 @@ class SettingsManager: "lora_scanner", "checkpoint_scanner", "embedding_scanner", + "other_scanner", "recipe_scanner", ): service = ServiceRegistry.get_service_sync(service_name) @@ -1960,6 +2063,7 @@ class SettingsManager: default_checkpoint_root: Optional[str] = None, default_unet_root: Optional[str] = None, default_embedding_root: Optional[str] = None, + default_other_roots: Optional[Mapping[str, str]] = None, recipes_path: Optional[str] = None, metadata: Optional[Mapping[str, Any]] = None, activate: bool = False, @@ -2004,6 +2108,11 @@ class SettingsManager: if default_embedding_root is not None else existing.get("default_embedding_root") ), + default_other_roots=( + default_other_roots + if default_other_roots is not None + else existing.get("default_other_roots") + ), recipes_path=( recipes_path if recipes_path is not None @@ -2036,6 +2145,7 @@ class SettingsManager: default_checkpoint_root: str = "", default_unet_root: str = "", default_embedding_root: str = "", + default_other_roots: Optional[Mapping[str, str]] = None, recipes_path: str = "", metadata: Optional[Mapping[str, Any]] = None, activate: bool = False, @@ -2054,6 +2164,7 @@ class SettingsManager: default_checkpoint_root=default_checkpoint_root, default_unet_root=default_unet_root, default_embedding_root=default_embedding_root, + default_other_roots=default_other_roots, recipes_path=recipes_path, metadata=metadata, activate=activate, @@ -2114,6 +2225,7 @@ class SettingsManager: default_checkpoint_root: Optional[str] = None, default_unet_root: Optional[str] = None, default_embedding_root: Optional[str] = None, + default_other_roots: Optional[Mapping[str, str]] = None, recipes_path: Optional[str] = None, ) -> None: """Update folder paths for the active library.""" @@ -2127,6 +2239,7 @@ class SettingsManager: default_checkpoint_root=default_checkpoint_root, default_unet_root=default_unet_root, default_embedding_root=default_embedding_root, + default_other_roots=default_other_roots, recipes_path=recipes_path, activate=True, ) @@ -2151,6 +2264,7 @@ class SettingsManager: "lora_scanner", "checkpoint_scanner", "embedding_scanner", + "other_scanner", "recipe_scanner", "model_update_service", ): diff --git a/py/utils/constants.py b/py/utils/constants.py index fd8c4812..bc44ea21 100644 --- a/py/utils/constants.py +++ b/py/utils/constants.py @@ -126,6 +126,20 @@ CIVITAI_TYPE_TO_OTHER_SUB_TYPE = { "controlnet": "controlnet", } +# CivitAI ModelFile.type values -> internal sub_type for the "other" model +# page. Used for download routing only, and strictly as an explicit user file +# pick or a fallback when model.type maps to nothing — checkpoint models +# routinely bundle VAE/Text Encoder component files, so file types must never +# override a mapped model.type. +CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = { + "VAE": "vae", + "Upscaler": "upscaler", + "Text Encoder": "text_encoder", + "Vision Encoder": "clip_vision", + "CLIPVision": "clip_vision", + "ControlNet": "controlnet", +} + # Backward compatibility alias VALID_LORA_TYPES = VALID_LORA_SUB_TYPES @@ -134,6 +148,7 @@ CIVITAI_USER_MODEL_TYPES = [ *VALID_LORA_TYPES, "textualinversion", "checkpoint", + *sorted(VALID_OTHER_CIVITAI_TYPES), ] # Default chunk size in megabytes used for hashing large files. diff --git a/py/utils/example_images_download_manager.py b/py/utils/example_images_download_manager.py index 80a754ff..82a0e36f 100644 --- a/py/utils/example_images_download_manager.py +++ b/py/utils/example_images_download_manager.py @@ -420,6 +420,10 @@ class DownloadManager: embedding_scanner = await ServiceRegistry.get_embedding_scanner() scanners.append(("embedding", embedding_scanner)) + if "other" in model_types: + other_scanner = await ServiceRegistry.get_other_scanner() + scanners.append(("other", other_scanner)) + # Load progress file to check processed models (async to avoid blocking) settings_manager = get_settings_manager() active_library = settings_manager.get_active_library_name() @@ -600,6 +604,10 @@ class DownloadManager: embedding_scanner = await ServiceRegistry.get_embedding_scanner() scanners.append(("embedding", embedding_scanner)) + if "other" in model_types: + other_scanner = await ServiceRegistry.get_other_scanner() + scanners.append(("other", other_scanner)) + # Get all models all_models = [] for scanner_type, scanner in scanners: @@ -1098,6 +1106,10 @@ class DownloadManager: embedding_scanner = await ServiceRegistry.get_embedding_scanner() scanners.append(("embedding", embedding_scanner)) + if "other" in model_types: + other_scanner = await ServiceRegistry.get_other_scanner() + scanners.append(("other", other_scanner)) + # Find the specified models models_to_process = [] for scanner_type, scanner in scanners: diff --git a/py/utils/models.py b/py/utils/models.py index f9961cf6..ebbf71c7 100644 --- a/py/utils/models.py +++ b/py/utils/models.py @@ -340,7 +340,8 @@ class OtherModelMetadata(BaseModelMetadata): 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. - civitai_type = str(version_info.get("type", "") or "").lower() + # 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 diff --git a/settings.json.example b/settings.json.example index 101a217d..ba09276a 100644 --- a/settings.json.example +++ b/settings.json.example @@ -31,5 +31,6 @@ "C:/path/to/your/clip_vision_folder" ] }, + "default_other_roots": {}, "auto_organize_exclusions": [] } diff --git a/tests/routes/test_download_routing_handlers.py b/tests/routes/test_download_routing_handlers.py index 48bcf767..260a9e11 100644 --- a/tests/routes/test_download_routing_handlers.py +++ b/tests/routes/test_download_routing_handlers.py @@ -91,3 +91,61 @@ async def test_invalid_json_rejected(): FakeRequest(json.JSONDecodeError("bad", "", 0)) ) assert response.status == 400 + + +@pytest.mark.asyncio +async def test_other_model_type_returns_sub_type(): + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest({"model_type": "TextEncoder", "file_types": ["Model"]}) + ) + payload = json.loads(response.text) + assert response.status == 200 + assert payload == {"success": True, "root_kind": "other", "sub_type": "text_encoder"} + + +@pytest.mark.asyncio +async def test_other_explicit_file_pick_wins(): + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest( + { + "model_type": "Other", + "file_types": ["Model"], + "selected_file_type": "VAE", + } + ) + ) + payload = json.loads(response.text) + assert payload["root_kind"] == "other" + assert payload["sub_type"] == "vae" + + +@pytest.mark.asyncio +async def test_other_file_type_fallback_when_model_type_unmapped(): + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest({"model_type": "Other", "file_types": ["Model", "Upscaler"]}) + ) + payload = json.loads(response.text) + assert payload["sub_type"] == "upscaler" + + +@pytest.mark.asyncio +async def test_other_undecidable_sub_type_is_none(): + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest({"model_type": "Other", "file_types": ["Model"]}) + ) + payload = json.loads(response.text) + assert response.status == 200 + assert payload == {"success": True, "root_kind": "other", "sub_type": None} + + +@pytest.mark.asyncio +async def test_other_invalid_selected_file_type_rejected(): + handler = DownloadRoutingHandler() + response = await handler.get_download_routing( + FakeRequest({"model_type": "VAE", "selected_file_type": 123}) + ) + assert response.status == 400 diff --git a/tests/routes/test_misc_routes.py b/tests/routes/test_misc_routes.py index 6b6b51be..9ada287d 100644 --- a/tests/routes/test_misc_routes.py +++ b/tests/routes/test_misc_routes.py @@ -1133,8 +1133,8 @@ async def test_get_civitai_user_models_marks_library_versions(): }, { "id": 4, - "name": "Unsupported", - "type": "Other", + "name": "VAE Model", + "type": "VAE", "modelVersions": [ { "id": 400, @@ -1142,6 +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) @@ -1152,6 +1163,7 @@ async def test_get_civitai_user_models_marks_library_versions(): lora_scanner = FakeExistenceScanner({101}) checkpoint_scanner = FakeExistenceScanner() embedding_scanner = FakeExistenceScanner({202}) + other_scanner = FakeExistenceScanner({400}) async def lora_factory(): return lora_scanner @@ -1162,11 +1174,15 @@ async def test_get_civitai_user_models_marks_library_versions(): async def embedding_factory(): return embedding_scanner + async def other_factory(): + return other_scanner + handler = ModelLibraryHandler( ServiceRegistryAdapter( get_lora_scanner=lora_factory, get_checkpoint_scanner=checkpoint_factory, get_embedding_scanner=embedding_factory, + get_other_scanner=other_factory, get_downloaded_version_history_service=lambda: fake_download_history_service_factory(), ), metadata_provider_factory=provider_factory, @@ -1240,6 +1256,18 @@ async def test_get_civitai_user_models_marks_library_versions(): "inLibrary": False, "hasBeenDownloaded": False, }, + { + "modelId": 4, + "versionId": 400, + "modelName": "VAE Model", + "versionName": "v1", + "type": "VAE", + "tags": [], + "baseModel": None, + "thumbnailUrl": None, + "inLibrary": True, + "hasBeenDownloaded": False, + }, ] assert provider.received_usernames == ["pixel"] @@ -1351,7 +1379,7 @@ async def test_get_civitai_user_models_returns_pagination_fields(): { "id": 2, "name": "Unsupported", - "type": "Other", + "type": "Wildcard", "modelVersions": [{"id": 200, "name": "v1"}], }, ] diff --git a/tests/routes/test_other_routes.py b/tests/routes/test_other_routes.py index e87fee94..c7085858 100644 --- a/tests/routes/test_other_routes.py +++ b/tests/routes/test_other_routes.py @@ -116,3 +116,50 @@ async def test_initialize_services_builds_other_model_service(monkeypatch): 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": {}} diff --git a/tests/services/test_download_manager_other.py b/tests/services/test_download_manager_other.py new file mode 100644 index 00000000..532b54da --- /dev/null +++ b/tests/services/test_download_manager_other.py @@ -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])] diff --git a/tests/services/test_download_routing.py b/tests/services/test_download_routing.py index 2d9dfb22..07d78bd1 100644 --- a/tests/services/test_download_routing.py +++ b/tests/services/test_download_routing.py @@ -38,3 +38,105 @@ def test_non_checkpoint_types_never_route_to_unet(): def test_empty_inputs_stay_on_checkpoint_roots(): assert not is_diffusion_model_download("checkpoint") assert not is_diffusion_model_download("checkpoint", file_types=[], base_model="") + + +from py.services.download_routing import resolve_other_download_sub_type + + +class TestResolveOtherDownloadSubType: + """Fixed priority: explicit file pick > model.type > file.type fallback.""" + + def test_explicit_file_pick_wins_over_model_type(self): + """User explicitly picked a VAE component file of a Checkpoint model — + the picked file type wins.""" + assert ( + resolve_other_download_sub_type( + "Checkpoint", file_types=["Model", "VAE"], selected_file_type="VAE" + ) + == "vae" + ) + + @pytest.mark.parametrize( + "selected,expected", + [ + ("VAE", "vae"), + ("Upscaler", "upscaler"), + ("Text Encoder", "text_encoder"), + ("Vision Encoder", "clip_vision"), + ("CLIPVision", "clip_vision"), + ("ControlNet", "controlnet"), + ], + ) + def test_explicit_file_pick_maps_all_known_types(self, selected, expected): + assert ( + resolve_other_download_sub_type("Other", selected_file_type=selected) + == expected + ) + + @pytest.mark.parametrize( + "model_type,expected", + [ + ("VAE", "vae"), + ("Upscaler", "upscaler"), + ("TextEncoder", "text_encoder"), + ("CLIP", "text_encoder"), + ("CLIPVision", "clip_vision"), + ("Controlnet", "controlnet"), + ], + ) + def test_model_type_mapping(self, model_type, expected): + assert resolve_other_download_sub_type(model_type) == expected + + def test_model_type_beats_unmappable_file_pick(self): + """An explicit pick whose file type does not map (e.g. plain 'Model') + falls through to model.type.""" + assert ( + resolve_other_download_sub_type( + "TextEncoder", selected_file_type="Model" + ) + == "text_encoder" + ) + + def test_bundled_component_files_never_override_model_type(self): + """Anti-misrouting: a TextEncoder model bundling a VAE component file + must stay text_encoder — file types are a fallback, not an override.""" + assert ( + resolve_other_download_sub_type( + "TextEncoder", file_types=["Model", "VAE"] + ) + == "text_encoder" + ) + assert ( + resolve_other_download_sub_type( + "Controlnet", file_types=["Model", "Text Encoder"] + ) + == "controlnet" + ) + + def test_file_type_fallback_when_model_type_unmapped(self): + """model.type 'Other' (or retired values) maps to nothing, so the + first mappable file type decides.""" + assert ( + resolve_other_download_sub_type("Other", file_types=["Model", "Upscaler"]) + == "upscaler" + ) + + def test_file_type_fallback_for_civarchive_payload(self): + """CivArchive-shaped payload: same fields, same decision path.""" + assert ( + resolve_other_download_sub_type( + "Other", + file_types=["Config", "Text Encoder"], + ) + == "text_encoder" + ) + + @pytest.mark.parametrize("model_type", ["Other", "", "SomethingNew"]) + def test_undecidable_returns_none(self, model_type): + assert ( + resolve_other_download_sub_type(model_type, file_types=["Model"]) is None + ) + assert resolve_other_download_sub_type(model_type) is None + + def test_model_type_matching_is_case_insensitive(self): + assert resolve_other_download_sub_type("vAe") == "vae" diff --git a/tests/services/test_example_images_other_dispatch.py b/tests/services/test_example_images_other_dispatch.py new file mode 100644 index 00000000..15744eae --- /dev/null +++ b/tests/services/test_example_images_other_dispatch.py @@ -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() diff --git a/tests/services/test_other_scanner.py b/tests/services/test_other_scanner.py index 63c4dc3a..14882acd 100644 --- a/tests/services/test_other_scanner.py +++ b/tests/services/test_other_scanner.py @@ -303,9 +303,13 @@ class TestOtherModelMetadataFromCivitai: def _build(self, civitai_type: str) -> OtherModelMetadata: return OtherModelMetadata.from_civitai_info( { - "type": civitai_type, "baseModel": "SDXL", - "model": {"name": "Model", "tags": ["tag"], "description": "desc"}, + "model": { + "name": "Model", + "tags": ["tag"], + "description": "desc", + "type": civitai_type, + }, }, {"name": "model.safetensors", "sizeKB": 1, "hashes": {"SHA256": "AB"}}, "/tmp/model.safetensors", @@ -329,6 +333,20 @@ class TestOtherModelMetadataFromCivitai: 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'.""" diff --git a/tests/services/test_settings_manager.py b/tests/services/test_settings_manager.py index 980de6e8..9d4dc2be 100644 --- a/tests/services/test_settings_manager.py +++ b/tests/services/test_settings_manager.py @@ -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.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"] == {} From fc3b2d7c13d6e838a19e155a60293da297031ee4 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 15:56:47 +0800 Subject: [PATCH 6/7] feat(frontend): enable downloads on Other page and default_other_roots settings UI --- locales/de.json | 11 ++ locales/en.json | 11 ++ locales/es.json | 11 ++ locales/fr.json | 11 ++ locales/he.json | 11 ++ locales/ja.json | 11 ++ locales/ko.json | 11 ++ locales/ru.json | 11 ++ locales/zh-CN.json | 11 ++ locales/zh-TW.json | 11 ++ static/js/api/apiConfig.js | 1 + static/js/api/otherApi.js | 38 ++++ .../js/components/controls/OtherControls.js | 6 + static/js/managers/DownloadManager.js | 75 ++++++- static/js/managers/SettingsManager.js | 69 +++++++ static/js/state/index.js | 2 + templates/components/controls.html | 2 - .../components/modals/settings/library.html | 28 +++ .../managers/downloadManager.routing.test.js | 183 ++++++++++++++++++ .../managers/settingsManager.library.test.js | 177 +++++++++++++++++ 20 files changed, 682 insertions(+), 9 deletions(-) diff --git a/locales/de.json b/locales/de.json index db83bcbe..eadb5d06 100644 --- a/locales/de.json +++ b/locales/de.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest", "defaultEmbeddingRoot": "Embedding-Stammordner", "defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest", + "defaultVaeRoot": "[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", "recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}", "unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}", "embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})", "mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht", "mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}", diff --git a/locales/en.json b/locales/en.json index c5e45b63..1f32af8f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves", "defaultEmbeddingRoot": "Embedding Root", "defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves", + "defaultVaeRoot": "VAE Root", + "defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves", + "defaultUpscalerRoot": "Upscaler Root", + "defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves", + "defaultTextEncoderRoot": "Text Encoder Root", + "defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves", + "defaultClipVisionRoot": "CLIP Vision Root", + "defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves", + "defaultControlnetRoot": "ControlNet Root", + "defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves", "recipesPath": "Recipes Storage Path", "recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Failed to load checkpoint roots: {message}", "unetRootsFailed": "Failed to load diffusion model roots: {message}", "embeddingRootsFailed": "Failed to load embedding roots: {message}", + "otherRootsFailed": "Failed to load other model roots: {message}", "mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})", "mappingsCleared": "Base model path mappings cleared", "mappingSaveFailed": "Failed to save base model mappings: {message}", diff --git a/locales/es.json b/locales/es.json index 7d32f6ac..fe83b1f9 100644 --- a/locales/es.json +++ b/locales/es.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos", "defaultEmbeddingRoot": "Raíz de embedding", "defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos", + "defaultVaeRoot": "[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", "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", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}", "unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}", "embeddingRootsFailed": "Error al cargar raíces de embedding: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})", "mappingsCleared": "Mapeos de rutas de modelo base limpiados", "mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}", diff --git a/locales/fr.json b/locales/fr.json index 9d3464f3..dc69d10c 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -535,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", "defaultEmbeddingRoot": "Racine Embedding", "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", "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", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}", "unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}", "embeddingRootsFailed": "Échec du chargement des racines embedding : {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})", "mappingsCleared": "Mappages de chemin de modèle de base effacés", "mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}", diff --git a/locales/he.json b/locales/he.json index d0da7ac1..cb91c56a 100644 --- a/locales/he.json +++ b/locales/he.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות", "defaultEmbeddingRoot": "תיקיית שורש 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": "נתיב אחסון מתכונים", "recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}", "unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}", "embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})", "mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו", "mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}", diff --git a/locales/ja.json b/locales/ja.json index afc1edbb..69339323 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定", "defaultEmbeddingRoot": "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": "レシピ保存先", "recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}", "unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}", "embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)", "mappingsCleared": "ベースモデルパスマッピングがクリアされました", "mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}", diff --git a/locales/ko.json b/locales/ko.json index b32d16da..4215737e 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다", "defaultEmbeddingRoot": "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": "레시피 저장 경로", "recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}", "unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}", "embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)", "mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다", "mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}", diff --git a/locales/ru.json b/locales/ru.json index bbdbd5a7..6bf627b4 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений", "defaultEmbeddingRoot": "Корневая папка 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": "Путь хранения рецептов", "recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}", "unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}", "embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})", "mappingsCleared": "Сопоставления путей базовых моделей очищены", "mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 817bdd54..c3f19342 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录", "defaultEmbeddingRoot": "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": "配方存储路径", "recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}", "unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}", "embeddingRootsFailed": "加载 Embedding 根目录失败:{message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "基础模型路径映射已更新({count} 条映射)", "mappingsCleared": "基础模型路径映射已清除", "mappingSaveFailed": "保存基础模型映射失败:{message}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index a79b4c45..2b917517 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -535,6 +535,16 @@ "defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄", "defaultEmbeddingRoot": "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": "配方儲存路徑", "recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。", "recipesPathPlaceholder": "/path/to/recipes", @@ -2342,6 +2352,7 @@ "checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}", "unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}", "embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}", + "otherRootsFailed": "[TODO: Translate] Failed to load other model roots: {message}", "mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)", "mappingsCleared": "基礎模型路徑對應已清除", "mappingSaveFailed": "儲存基礎模型對應失敗:{message}", diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index d1bce2cc..7fd6d508 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -146,6 +146,7 @@ export const MODEL_SPECIFIC_ENDPOINTS = { }, [MODEL_TYPES.OTHER]: { metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`, + roots_by_subtype: `/api/lm/${MODEL_TYPES.OTHER}/roots_by_subtype`, } }; diff --git a/static/js/api/otherApi.js b/static/js/api/otherApi.js index eecf72d2..d42425c0 100644 --- a/static/js/api/otherApi.js +++ b/static/js/api/otherApi.js @@ -4,4 +4,42 @@ 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; + } + } } diff --git a/static/js/components/controls/OtherControls.js b/static/js/components/controls/OtherControls.js index e7a19388..c9bb6d60 100644 --- a/static/js/components/controls/OtherControls.js +++ b/static/js/components/controls/OtherControls.js @@ -2,6 +2,7 @@ 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 @@ -39,6 +40,11 @@ export class OtherControls extends PageControls { return await getModelApiClient().fetchCivitaiMetadata(); }, + // Add show download modal functionality + showDownloadModal: () => { + downloadManager.showDownloadModal(); + }, + toggleBulkMode: () => { if (window.bulkManager) { window.bulkManager.toggleBulkMode(); diff --git a/static/js/managers/DownloadManager.js b/static/js/managers/DownloadManager.js index 6831ee64..885becef 100644 --- a/static/js/managers/DownloadManager.js +++ b/static/js/managers/DownloadManager.js @@ -8,6 +8,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js'; import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { translate } from '../utils/i18nHelpers.js'; +import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js'; import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js'; import { formatFileSize } from '../utils/formatters.js'; import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js'; @@ -956,11 +957,17 @@ export class DownloadManager { try { this._isDiffusionModel = await this._resolveIsDiffusionModel(); + this._otherSubType = await this._resolveOtherSubType(); let rootsData; if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') { rootsData = await this.apiClient.fetchModelRoots('diffusion_model'); + } else if (this.apiClient.modelType === 'other' && this._otherSubType) { + rootsData = await this.apiClient.fetchModelRoots(this._otherSubType); } else { + // An undecidable other sub_type (null) intentionally lands + // here: fetchModelRoots() lists all other roots so the user + // can pick manually. rootsData = await this.apiClient.fetchModelRoots(); } const modelRoot = document.getElementById('modelRoot'); @@ -968,19 +975,29 @@ export class DownloadManager { `` ).join(''); - const singularType = this._isDiffusionModel - ? 'unet' - : this.apiClient.modelType.replace(/s$/, ''); - const defaultRootKey = `default_${singularType}_root`; - const defaultRoot = state.global.settings[defaultRootKey]; - console.log(`Default root for ${singularType}:`, defaultRoot); + let defaultRoot; + let subtypeDisplay; + if (this.apiClient.modelType === 'other') { + const otherDefaultRoots = state.global.settings.default_other_roots || {}; + defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : ''; + subtypeDisplay = this._otherSubType + ? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType) + : this.apiClient.apiConfig.config.displayName; + } else { + const singularType = this._isDiffusionModel + ? 'unet' + : this.apiClient.modelType.replace(/s$/, ''); + const defaultRootKey = `default_${singularType}_root`; + defaultRoot = state.global.settings[defaultRootKey]; + subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName; + } + console.log('Default root:', defaultRoot); console.log('Available roots:', rootsData.roots); if (defaultRoot && rootsData.roots.includes(defaultRoot)) { console.log(`Setting default root: ${defaultRoot}`); modelRoot.value = defaultRoot; } - const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName; document.getElementById('modelRootLabel').textContent = translate('modals.download.selectTypeRoot', { type: subtypeDisplay }); @@ -1065,6 +1082,50 @@ export class DownloadManager { return localFileTypeCheck; } + /** + * Resolve which other-page sub_type (vae/upscaler/text_encoder/ + * clip_vision/controlnet) this download routes to. The backend owns the + * routing rule (explicit file pick first, model.type next, file.type + * fallback), so the location step sends both the picked file's type + * (selected_file_type) and the version's full file-type list and lets + * the backend apply its priority chain. Returns null when the sub_type + * cannot be decided; the location step then lists all other roots for + * manual selection instead of guessing a folder. + */ + async _resolveOtherSubType() { + // Only other-page downloads route by sub_type; without version + // metadata (e.g. Hugging Face downloads) there is nothing to route on. + if (this.apiClient.modelType !== 'other' + || (!this.selectedFile && !this.currentVersion)) { + return null; + } + + try { + const fileTypes = (this.currentVersion?.files || []).map(f => f.type); + const response = await fetch(DOWNLOAD_ENDPOINTS.routing, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: 'other', + base_model: this.currentVersion?.baseModel || '', + file_types: fileTypes, + ...(this.selectedFile + ? { selected_file_type: this.selectedFile.type } + : {}), + }), + }); + if (!response.ok) { + throw new Error(`routing endpoint returned ${response.status}`); + } + const data = await response.json(); + return data.sub_type || null; + } catch (error) { + console.warn('[download] other routing endpoint unavailable, ' + + 'falling back to manual root selection:', error); + return null; + } + } + loadDefaultPathSetting() { const modelType = this.apiClient.modelType; const storageKey = `use_default_path_${modelType}`; diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index ec5f2dc9..5c4ba1f0 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1153,6 +1153,9 @@ export class SettingsManager { // Load default unet root await this.loadUnetRoots(); + // Load default other-model roots (per sub_type) + await this.loadOtherRoots(); + // Load extra folder paths 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() { const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot'); 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. * Shared entry point for the settings modal segmented control and the diff --git a/static/js/state/index.js b/static/js/state/index.js index e20b82d4..f54858f3 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -24,6 +24,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ default_lora_root: '', default_checkpoint_root: '', default_embedding_root: '', + default_other_roots: {}, recipes_path: '', base_model_path_mappings: {}, download_path_templates: {}, @@ -72,6 +73,7 @@ export function createDefaultSettings() { base_model_path_mappings: {}, download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, + default_other_roots: {}, }; } diff --git a/templates/components/controls.html b/templates/components/controls.html index ef83941b..9a7dc1db 100644 --- a/templates/components/controls.html +++ b/templates/components/controls.html @@ -80,14 +80,12 @@
- {% if page_id != 'other' %}
{% endif %} - {% endif %} {% if page_id == 'recipes' %}
diff --git a/templates/components/modals/settings/library.html b/templates/components/modals/settings/library.html index a1522790..b639d859 100644 --- a/templates/components/modals/settings/library.html +++ b/templates/components/modals/settings/library.html @@ -38,6 +38,34 @@ {{ 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') }} + + {% 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 %} +
+
+
+ +
+
+ +
+
+
+ {% endfor %}
diff --git a/tests/frontend/managers/downloadManager.routing.test.js b/tests/frontend/managers/downloadManager.routing.test.js index c4e7ab59..6f7eaf20 100644 --- a/tests/frontend/managers/downloadManager.routing.test.js +++ b/tests/frontend/managers/downloadManager.routing.test.js @@ -56,6 +56,7 @@ vi.mock(SUMMARY_MODULE, () => ({ })); const { DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE); +const { state } = await import(STATE_MODULE); describe('DownloadManager._resolveIsDiffusionModel', () => { let manager; @@ -144,3 +145,185 @@ describe('DownloadManager._resolveIsDiffusionModel', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); + +describe('DownloadManager._resolveOtherSubType', () => { + let manager; + let fetchMock; + + beforeEach(() => { + manager = new DownloadManager(); + manager.apiClient = { modelType: 'other' }; + manager.selectedFile = null; + manager.selectedFiles = []; + manager.currentVersion = null; + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + function mockRoutingResponse(data, ok = true) { + fetchMock.mockResolvedValue({ + ok, + status: ok ? 200 : 500, + json: async () => data, + }); + } + + it('returns the backend sub_type for other downloads', async () => { + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'VAE' }] }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' }); + + expect(await manager._resolveOtherSubType()).toBe('vae'); + + expect(fetchMock).toHaveBeenCalledWith('/api/lm/download/routing', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model_type: 'other', + base_model: 'Flux.1 D', + file_types: ['VAE'], + }), + }); + }); + + it('sends selected_file_type plus all version file types when a file is selected', async () => { + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] }; + manager.selectedFile = { type: 'VAE' }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' }); + + expect(await manager._resolveOtherSubType()).toBe('vae'); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect(body.selected_file_type).toBe('VAE'); + expect(body.file_types).toEqual(['Model', 'VAE']); + }); + + it('omits selected_file_type when no file is selected', async () => { + manager.currentVersion = { baseModel: 'Flux.1 D', files: [{ type: 'Model' }, { type: 'VAE' }] }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: 'vae' }); + + await manager._resolveOtherSubType(); + const body = JSON.parse(fetchMock.mock.calls[0][1].body); + expect('selected_file_type' in body).toBe(false); + expect(body.file_types).toEqual(['Model', 'VAE']); + }); + + it('returns null when the backend cannot decide a sub_type', async () => { + manager.currentVersion = { baseModel: 'SDXL 1.0', files: [{ type: 'Model' }] }; + mockRoutingResponse({ success: true, root_kind: 'other', sub_type: null }); + + expect(await manager._resolveOtherSubType()).toBeNull(); + }); + + it('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 = ` + + + + `; + 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'); + }); +}); diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js index 115db1eb..88e59439 100644 --- a/tests/frontend/managers/settingsManager.library.test.js +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -24,6 +24,7 @@ vi.mock('../../../static/js/state/index.js', () => { }, createDefaultSettings: () => ({ 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', () => { it('dispatches lm:recipes-layout-changed without recalculating the old scroller', async () => { const manager = createManager(); From 1dbdf5b00c4a821679fde2da0b9991bdcbe4c538 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Sat, 12 Sep 2026 15:56:47 +0800 Subject: [PATCH 7/7] docs: mark Phase 2 implemented in other-models plan --- docs/plans/other-models-page.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/other-models-page.md b/docs/plans/other-models-page.md index 97d038aa..0aadb4f8 100644 --- a/docs/plans/other-models-page.md +++ b/docs/plans/other-models-page.md @@ -1,6 +1,6 @@ # 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 designed** (§9, decisions pending owner sign-off). +**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