mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 11:11:26 -03:00
Compare commits
44 Commits
v1.2.2
...
84146b62fd
| Author | SHA1 | Date | |
|---|---|---|---|
| 84146b62fd | |||
| adeb40bfff | |||
| 8a21837ca2 | |||
| 3302147a43 | |||
| 4d87ae7637 | |||
| b1a653f18f | |||
| 6fe0543d2e | |||
| 931dfbe1d3 | |||
| b5c1331911 | |||
| 37f2cba72d | |||
| 0e789cb38c | |||
| f3b3393a16 | |||
| 480a3f4ea5 | |||
| 69a62d739c | |||
| 28fbb86dce | |||
| f88fe2665c | |||
| 3592eab48c | |||
| 1dbdf5b00c | |||
| fc3b2d7c13 | |||
| f2a7297cb9 | |||
| 57729375b6 | |||
| fa7ce725c1 | |||
| 27da7b3ca3 | |||
| 3070838a42 | |||
| fe160134d0 | |||
| 6d3f82976f | |||
| 91b2735dad | |||
| 3112869a21 | |||
| aa630bf85b | |||
| e0052cd237 | |||
| 3cdc5ba7a2 | |||
| 04485e384f | |||
| a03dc4002f | |||
| cc9d3bff42 | |||
| 2672b3331b | |||
| 4963bf2b2e | |||
| 51cad6f852 | |||
| 1b5cbbbaa0 | |||
| e747946f7a | |||
| 53fa22f39c | |||
| 82b34097fb | |||
| a7995db009 | |||
| 5ae4aef30e | |||
| 08023f0cd9 |
@@ -166,7 +166,7 @@ The system runs in two modes:
|
|||||||
|
|
||||||
### Model Types & Routes
|
### Model Types & Routes
|
||||||
|
|
||||||
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*` patterns
|
- API endpoints follow `/loras/*`, `/checkpoints/*`, `/embeddings/*`, `/other/*` patterns
|
||||||
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
- Route registrars organize endpoints by domain: `ModelRouteRegistrar`, `RecipeRouteRegistrar`, etc.
|
||||||
- Request handlers in `py/routes/handlers/` implement route logic
|
- Request handlers in `py/routes/handlers/` implement route logic
|
||||||
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
- All routes use aiohttp, return `web.json_response` or `web.Response`
|
||||||
@@ -190,6 +190,8 @@ The system runs in two modes:
|
|||||||
|
|
||||||
- `py/config.py` manages folder paths for models and handles symlink mappings
|
- `py/config.py` manages folder paths for models and handles symlink mappings
|
||||||
- Auto-saves paths to `settings.json` in ComfyUI mode
|
- Auto-saves paths to `settings.json` in ComfyUI mode
|
||||||
|
- `settings.json.example` is intentionally minimal (see Important Notes); all
|
||||||
|
other defaults live in `DEFAULT_SETTINGS` (`py/services/settings_manager.py`)
|
||||||
|
|
||||||
### Frontend UI Architecture
|
### Frontend UI Architecture
|
||||||
|
|
||||||
@@ -250,6 +252,12 @@ If a cross-layer issue ever needs a live server, the sandboxed helpers live in
|
|||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- ALWAYS use English for comments (per copilot-instructions.md)
|
- ALWAYS use English for comments (per copilot-instructions.md)
|
||||||
|
- **`settings.json.example` must stay minimal**: only `use_portable_settings`,
|
||||||
|
`civitai_api_key`, and the four core `folder_paths` keys (`loras`,
|
||||||
|
`checkpoints`, `unet`, `embeddings`). Do NOT add optional/default keys
|
||||||
|
(model-category folders, `default_*_root`, `auto_organize_exclusions`, etc.)
|
||||||
|
to this file unless the user explicitly asks for it. Defaults belong in
|
||||||
|
`DEFAULT_SETTINGS` in `py/services/settings_manager.py`.
|
||||||
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
- Run `python scripts/sync_translation_keys.py` after adding UI strings to `locales/en.json`
|
||||||
- Symlinks require normalized paths.
|
- Symlinks require normalized paths.
|
||||||
**Business paths vs real paths**: All stored paths and operation routing use the
|
**Business paths vs real paths**: All stored paths and operation routing use the
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ This document is the canonical set of conventions for translating LoRA Manager U
|
|||||||
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
It applies to **human translators and AI agents** alike. Read it before editing anything in
|
||||||
`locales/`.
|
`locales/`.
|
||||||
|
|
||||||
Source of truth: `locales/en.json` (10 locales, 1810 leaf keys; all locales share the exact
|
Source of truth: `locales/en.json` (10 locales, 1982 leaf keys; all locales share the exact
|
||||||
same key structure).
|
same key structure).
|
||||||
|
|
||||||
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
||||||
@@ -13,6 +13,26 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
|
|||||||
> stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales
|
> stale-text, and untranslated-block fixes described in §2–§6 were applied across all locales
|
||||||
> (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**,
|
> (commits `3c3ac49f` … `fd1227d3`). The tables below are now the **normative target state**,
|
||||||
> not a to-do list — future edits should preserve these renderings and only add what is new.
|
> not a to-do list — future edits should preserve these renderings and only add what is new.
|
||||||
|
>
|
||||||
|
> **Status (2026-09, Other Models):** the `other` model type (VAE / Upscaler / Text Encoder /
|
||||||
|
> CLIP Vision / ControlNet) and the Other Models opt-in toggles added 36 new keys; all of them
|
||||||
|
> are now translated in all 9 locales (terminology in §2 "Other Models feature"). There are no
|
||||||
|
> remaining `[TODO: Translate]` placeholders in any locale.
|
||||||
|
>
|
||||||
|
> **Status (2026-09, revision):** `other.disabled.description`, `banners.otherModels.content` and
|
||||||
|
> `settings.folderSettings.enableOtherModelsHelp` were refreshed in `en.json` to name all five
|
||||||
|
> sub_types (they had listed four, which read as "these are what enabling manages") and
|
||||||
|
> re-translated in all 9 locales in the same pass. `clip_vision` and `controlnet` are now both
|
||||||
|
> opt-in, so the first two describe **capability** and the third the **master switch**, not the
|
||||||
|
> default set — keep all three enumerating the full five (`VAE / upscaler / text encoder /
|
||||||
|
> CLIP vision / ControlNet` in `en`; locale slash-list casing follows each file's existing
|
||||||
|
> `VAE / Upscaler / Text Encoder / …` style, de compounds as `CLIP-Vision- und ControlNet-Ordner`).
|
||||||
|
>
|
||||||
|
> **Status (2026-09, "no folders found" state):** the Other Models page gained an *enabled but
|
||||||
|
> nothing to scan* empty state with 6 new keys (`other.noPaths.*`); translated in all 9 locales
|
||||||
|
> in the same pass. The `folder_paths` JSON snippet shown in that state lives in
|
||||||
|
> `templates/other.html`, **not** in the locale files, so it is never translated — only the
|
||||||
|
> surrounding prose is. Terminology added in §2.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -222,6 +242,56 @@ and must be normalized. `en` = keep the English word as-is.
|
|||||||
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
| hash | 哈希 (哈希值 variant OK) | 雜湊 ✓ |
|
||||||
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
|
| register | 你 (fix 5×您 → 你) | 您 (fix 18×你 → 您) |
|
||||||
|
|
||||||
|
### Other Models feature (VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet)
|
||||||
|
|
||||||
|
The `other` model type exposes five sub_types. They are **model-type names**, so they follow
|
||||||
|
R3 and stay in Latin in every locale. The `settings.folderSettings.subType*` values are
|
||||||
|
therefore **intentionally byte-identical to `en.json`** (same precedent as
|
||||||
|
`settings.priorityTags.modelTypes` / `checkpoints.modelTypes.checkpoint`) — a §6 sweep must
|
||||||
|
not "fix" them.
|
||||||
|
|
||||||
|
| Term | Rendering | Note |
|
||||||
|
|---|---|---|
|
||||||
|
| VAE | `VAE` everywhere | acronym, always upper-case |
|
||||||
|
| Upscaler | `Upscaler` everywhere | CivitAI `ModelType` name |
|
||||||
|
| Text Encoder | `Text Encoder` everywhere | de compounds as `Text-Encoder-Stammordner` |
|
||||||
|
| CLIP Vision | `CLIP Vision` everywhere | de compounds as `CLIP-Vision-Stammordner` |
|
||||||
|
| ControlNet | `ControlNet` everywhere | brand casing, capital N |
|
||||||
|
|
||||||
|
In prose these names sit next to localized nouns the same way `Diffusion Model` does
|
||||||
|
(zh `VAE 根目录`, ja `VAEルート`, ko `VAE 루트`, ru `Корневая папка VAE`).
|
||||||
|
|
||||||
|
**"Other Models" is the page/feature name, not a model type — translate it:**
|
||||||
|
|
||||||
|
| Locale | `other.title` | `header.navigation.other` |
|
||||||
|
|---|---|---|
|
||||||
|
| fr | Autres modèles | Autres |
|
||||||
|
| zh-CN | 其他模型 | 其他 |
|
||||||
|
| zh-TW | 其他模型 | 其他 |
|
||||||
|
| ja | その他のモデル | その他 |
|
||||||
|
| ko | 기타 모델 | 기타 |
|
||||||
|
| de | Weitere Modelle | Andere |
|
||||||
|
| es | Otros modelos | Otros |
|
||||||
|
| ru | Другие модели | Другое |
|
||||||
|
| he | מודלים אחרים | אחרים |
|
||||||
|
|
||||||
|
`settings.folderSettings.otherSubTypes` ("Managed Types") must name **model** types, matching
|
||||||
|
each locale's `header.filter.modelTypes` rendering (zh `管理的模型类型`, ja `管理するモデルタイプ`,
|
||||||
|
de `Verwaltete Modelltypen`, …).
|
||||||
|
|
||||||
|
The "no folders found" empty state (`other.noPaths.*`) uses two phrases that must stay
|
||||||
|
consistent whenever that copy is edited. `folder key` means the `folder_paths` key name
|
||||||
|
(`vae`, `upscale_models`, … — Latin per the table above); `on disk` means the folder must
|
||||||
|
physically exist:
|
||||||
|
|
||||||
|
| Phrase | Rendering |
|
||||||
|
|---|---|
|
||||||
|
| folder key | zh-CN 文件夹键 · zh-TW 資料夾鍵 · ja フォルダーキー · ko 폴더 키 · fr clé de dossier · de Ordnerschlüssel · es clave de carpeta · ru ключ папки · he מפתח תיקייה |
|
||||||
|
| on disk | zh-CN 在磁盘上 · zh-TW 在磁碟上 · ja ディスク上 · ko 디스크에 · fr sur le disque · de auf dem Datenträger · es en el disco · ru на диске · he בדיסק |
|
||||||
|
|
||||||
|
`settings.json` and `ComfyUI` stay verbatim in every locale; "reload this page" / "restart
|
||||||
|
LoRA Manager" reuse each locale's existing restart wording (`settings.extraFolderPaths.*`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
## 3. Cross-cutting confusion hot-spots (must-fix list)
|
||||||
@@ -312,8 +382,9 @@ blocks are translated** in every locale: `recipes.batchImport.*` + `toast.recipe
|
|||||||
The only values that remain intentionally identical to `en.json` are non-translatable:
|
The only values that remain intentionally identical to `en.json` are non-translatable:
|
||||||
URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`),
|
URL/path placeholders (`https://…`, `C:/…`), numeric presets (`5 (1080p), 6 (2K), 8 (4K)`),
|
||||||
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
|
example token lists (`character, concept, style(toon|toon_style)`), service/provider names
|
||||||
(`CivitAI → CivArchive → Archive DB`), and the external playlist title
|
(`CivitAI → CivArchive → Archive DB`), model-type names (`settings.priorityTags.modelTypes.*`,
|
||||||
(`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
`settings.folderSettings.subTypeVae` … `subTypeControlnet` — see §2), and the external playlist
|
||||||
|
title (`help.updateVlogs.playlistTitle`, de: translated to "LoRA Manager-Update-Playlist").
|
||||||
|
|
||||||
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
|
Rule for `uiHelpers.workflow.noPromptTargets`: the second line (`Mark as → Send Prompt
|
||||||
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
|
Target`) quotes literal ComfyUI context-menu items — keep those menu labels in English in
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
# Plan: "Other Models" Page — Unified Management for VAE / Upscaler / Text Encoder / etc.
|
||||||
|
|
||||||
|
**Status:** v2 — **Phase 1 implemented** (2026-09-12, commits `27da7b3c` backend + `fa7ce725` frontend; verified live against a running ComfyUI instance: scan/hash/sub_type-derivation/fetch/previews all green). **Phase 2 implemented** (2026-09-12, per §9 design; full pytest + vitest green). **Phase 3 implemented** (§11: opt-in management toggles; default off). **i18n done** (2026-09-13): all 36 new keys translated in the 9 non-English locales — the `[TODO: Translate]` placeholders left by the sync script during development are gone (see `docs/i18n-translation-guidelines.md` §2, "Other Models feature"). **Default set revised (pre-release):** only `vae` / `upscaler` / `text_encoder` are managed by default — `clip_vision` and `controlnet` are both opt-in (§2, §11.1.1).
|
||||||
|
**Scope (Phase 1):** scan + manage (list, search, filter, tags, folders, preview, rename, move, delete/exclude, CivitAI metadata fetch) for a new model type `other`, exposed as a new web page. **Phase 2 (§9):** one-click download from CivitAI for these types.
|
||||||
|
|
||||||
|
## 1. Goal
|
||||||
|
|
||||||
|
Today the manager supports three model types:
|
||||||
|
|
||||||
|
| page | model_type | sub_types |
|
||||||
|
|---|---|---|
|
||||||
|
| `/loras` | `lora` | `lora`, `locon`, `dora` |
|
||||||
|
| `/checkpoints` | `checkpoint` | `checkpoint`, `diffusion_model` |
|
||||||
|
| `/embeddings` | `embedding` | `embedding` |
|
||||||
|
|
||||||
|
Add a fourth page that manages "everything else" — VAE, upscalers, text encoders / CLIP, CLIP vision, optionally ControlNet — with a folder→sub_type mapping table so new ComfyUI folder categories can be added later by configuration, not code.
|
||||||
|
|
||||||
|
## 2. Locked Decisions
|
||||||
|
|
||||||
|
1. **Architecture: one scanner + one service + one page, sub_type derived by location.**
|
||||||
|
Replicates the checkpoint pattern (`CheckpointScanner` aggregates `checkpoints` + `unet` roots and derives `checkpoint` vs `diffusion_model` from the root containing the file, `py/services/checkpoint_scanner.py:384-415`). One `OtherScanner` aggregates all enabled folder roots; `resolve_sub_type_for_path()` maps each root to a sub_type. No per-category scanners.
|
||||||
|
|
||||||
|
2. **Naming: internal `model_type = "other"`, route prefix `/other`, page id `other`.**
|
||||||
|
- `misc` is rejected: `py/routes/misc_routes.py` already owns that name for system/settings routes (`/api/lm/settings`, `/api/lm/doctor/*`).
|
||||||
|
- `components` is rejected: `templates/components/` and `static/js/components/` directories would make `components.html` / `components.js` confusing neighbors.
|
||||||
|
- `other` matches CivitAI's `Other` fallback type semantics. The **display name** is an i18n string (`other.title`, e.g. "Other Models") and can be renamed later without touching code.
|
||||||
|
|
||||||
|
3. **sub_type values:** snake_case, aligned with CivitAI `ModelType` semantics:
|
||||||
|
|
||||||
|
| sub_type | ComfyUI `folder_paths` key(s) | CivitAI ModelType | enabled by default |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `vae` | `vae` | `VAE` | yes |
|
||||||
|
| `upscaler` | `upscale_models` | `Upscaler` | yes |
|
||||||
|
| `text_encoder` | `text_encoders`, `clip` (legacy) | `TextEncoder` (CLIP is retired upstream) | yes |
|
||||||
|
| `clip_vision` | `clip_vision` | `CLIPVision` | no (mapping present, opt-in) |
|
||||||
|
| `controlnet` | `controlnet` | `Controlnet` | no (mapping present, opt-in) |
|
||||||
|
|
||||||
|
New folder categories = one line in the mapping table (see §4.1).
|
||||||
|
|
||||||
|
**Why only three are on by default** (revised in Phase 3, before release):
|
||||||
|
VAE, upscalers and text encoders are dependency-style assets every pipeline
|
||||||
|
needs, and "which one am I actually using" is the recurring problem they
|
||||||
|
solve. `clip_vision` and `controlnet` are workflow-driven instead
|
||||||
|
(IPAdapter/SVD image conditioning; per-workflow ControlNet variants), and
|
||||||
|
ControlNet libraries routinely run to dozens of files, so both are treated
|
||||||
|
symmetrically as opt-in. Enumerating all five as "the default set" was not
|
||||||
|
defensible on demand breadth alone.
|
||||||
|
|
||||||
|
4. **Phase 1 = scan/manage only.** Downloads from CivitAI (`download_manager.py` type mapping, default-root settings keys, download routing) are Phase 2 (§9). CivitAI **metadata fetch** for existing files IS in Phase 1 (hash-based lookup is type-agnostic; only the type-validation hook needs new values).
|
||||||
|
|
||||||
|
5. **Out of scope (default off, revisit later):** usage statistics buckets, recipe matching (`recipe_scanner.py` only merges lora+checkpoint scanners), statistics page, embeddings re-classification (stays its own page — merging would be a breaking change).
|
||||||
|
|
||||||
|
## 3. Why This Works With Minimal Churn
|
||||||
|
|
||||||
|
- `ModelScanner` (`py/services/model_scanner.py:93`) is specialized entirely via constructor params (`model_type`, `model_class`, `file_extensions`) + optional hooks (`adjust_metadata`, `adjust_cached_entry`, `resolve_sub_type_for_path`, `model_scanner.py:1429-1443`).
|
||||||
|
- `BaseModelService` subclasses can be one method (`EmbeddingService` implements only `format_response`, `py/services/embedding_service.py:12`).
|
||||||
|
- Routes: `ModelServiceFactory.register_model_type()` (`py/services/model_service_factory.py:120-136`) + `COMMON_ROUTE_DEFINITIONS` (`py/routes/model_route_registrar.py:23-149`) generate the full `/api/lm/{prefix}/*` surface (~50 endpoints) plus the `GET /{prefix}` page route.
|
||||||
|
- `PersistentModelCache` (`py/services/persistent_model_cache.py:526-606`) is a single `models` table keyed `(model_type, file_path)` with `model_type` as free text — **zero schema change**.
|
||||||
|
- Frontend `apiConfig.js` (`static/js/api/apiConfig.js:51`) generates all endpoints from the model-type string; `ModelCard.js:670-675` renders the sub_type badge from data; the checkpoints page already demonstrates the "one page, multiple sub_types" filter (`header.html:298`).
|
||||||
|
|
||||||
|
## 4. Backend Changes
|
||||||
|
|
||||||
|
### 4.1 New constants — `py/utils/constants.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
# folder_paths key -> sub_type; single source of truth for extensibility
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES = {
|
||||||
|
"vae": "vae",
|
||||||
|
"upscale_models": "upscaler",
|
||||||
|
"text_encoders": "text_encoder",
|
||||||
|
"clip": "text_encoder", # legacy ComfyUI key
|
||||||
|
"clip_vision": "clip_vision",
|
||||||
|
"controlnet": "controlnet",
|
||||||
|
}
|
||||||
|
DEFAULT_OTHER_MODEL_FOLDERS = ("vae", "upscale_models", "text_encoders", "clip", "clip_vision")
|
||||||
|
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
|
||||||
|
# CivitAI model.type values accepted for this page (fetch-metadata validation)
|
||||||
|
VALID_OTHER_CIVITAI_TYPES = {"vae", "upscaler", "textencoder", "clipvision", "controlnet", "other"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:90`) if user-model queries should include these types.
|
||||||
|
|
||||||
|
### 4.2 New files (mirror the embedding/checkpoint implementations)
|
||||||
|
|
||||||
|
1. **`py/utils/models.py`** — add `OtherModelMetadata(BaseModelMetadata)`: default `sub_type="vae"` placeholder overridden by scanner hook; `from_civitai_info` mapping CivitAI types → our sub_types (`TextEncoder`→`text_encoder`, `CLIPVision`→`clip_vision`, `Upscaler`→`upscaler`, `VAE`→`vae`, `Controlnet`→`controlnet`, else `other`-ish fallback to folder-derived sub_type).
|
||||||
|
2. **`py/services/other_scanner.py`** — `OtherScanner(ModelScanner)`:
|
||||||
|
- `model_type="other"`, extensions: reuse the checkpoint set (`safetensors/pt/pt2/bin/pth/pkl/sft/gguf`).
|
||||||
|
- `get_model_roots()`: iterate `OTHER_MODEL_FOLDER_SUBTYPES` ∩ enabled keys, pull each from `config` (§4.3); dedupe; build `root → sub_type` map (normalized abspaths; multiple keys may share a sub_type).
|
||||||
|
- Implement all three hooks like `CheckpointScanner` (`checkpoint_scanner.py:384-415`): `resolve_sub_type_for_path` by longest-prefix root match, `adjust_metadata`, `adjust_cached_entry` (sub_type is re-derived on cache load, never persisted).
|
||||||
|
- **Lazy hashing, checkpoint-style**: text encoders (T5-XXL ≈ 10 GB) make eager sha256 painful. Copy the `hash_status="pending"` + singleflight `calculate_hash_for_model` pattern from `CheckpointScanner`.
|
||||||
|
3. **`py/services/other_model_service.py`** — `OtherModelService(BaseModelService)`, `format_response` only (no usage_count, like `EmbeddingService`).
|
||||||
|
4. **`py/routes/other_routes.py`** — `OtherRoutes(BaseModelRoutes)`, `template_name="other.html"`, hooks:
|
||||||
|
- `_validate_civitai_model_type` → `VALID_OTHER_CIVITAI_TYPES`
|
||||||
|
- `_get_expected_model_types`, `_parse_specific_params` (no type-specific download params in Phase 1)
|
||||||
|
- `initialize_services()` on `app.on_startup` pulling `ServiceRegistry.get_other_scanner()`.
|
||||||
|
|
||||||
|
### 4.3 `py/config.py`
|
||||||
|
|
||||||
|
- New `other_roots` property: for each enabled key in `OTHER_MODEL_FOLDER_SUBTYPES`, `folder_paths.get_folder_paths(key)` (plugin mode) — standalone mode needs nothing new: `MockFolderPaths` (`standalone.py:66-105`) already serves arbitrary keys from `settings.json.folder_paths`.
|
||||||
|
- Follow the existing per-type recipe: an `_prepare_other_paths()` (dedupe + symlink registration; also **cross-scanner overlap detection** — warn if an `other` root is already covered by checkpoints/unet/embedding roots, mirroring the checkpoint/unet overlap check).
|
||||||
|
- Wire into: `_apply_library_paths`, `_symlink_roots()`, `_rebuild_preview_roots()` (hard requirement — preview images are served per registered root), `save_folder_paths_to_settings()`.
|
||||||
|
|
||||||
|
### 4.4 Existing-file edits (the "type string scatter" — each is a small branch/entry)
|
||||||
|
|
||||||
|
| file | change |
|
||||||
|
|---|---|
|
||||||
|
| `py/services/model_service_factory.py:120` | register `("other", OtherModelService, OtherRoutes)` in `register_default_model_types()` |
|
||||||
|
| `py/services/service_registry.py` | add `get_other_scanner()` (mirror `:297` `get_embedding_scanner`) |
|
||||||
|
| `py/services/model_scanner.py:67` | `PAGE_TYPE_MAP['other'] = 'other'` (WebSocket progress) |
|
||||||
|
| `py/services/base_model_service.py:896-906` | `get_model_types()` branch → `VALID_OTHER_SUB_TYPES` |
|
||||||
|
| `py/lora_manager.py` | `_initialize_services` scanner task list (`:219-242`), `_cleanup` cancel list (`:463`), `_cleanup_backup_files` roots (`:327-330`) |
|
||||||
|
| `py/routes/handlers/misc_handlers.py` | `scanner_getters` (`:657-661`) + `scanner_factories` (`:757-759`) so Doctor / init-status / refresh-all see the new scanner |
|
||||||
|
| `py/services/pending_delete_service.py` | `_PAGE_TYPE` map (`:57-61`) + scanner getter list (`:983-985`) |
|
||||||
|
| `py/metadata_ops/__init__.py:36-38` | `SCANNER_TYPE_MAP['other']` |
|
||||||
|
| `settings.json.example` | document optional `folder_paths` keys: `vae`, `upscale_models`, `text_encoders`, `clip_vision` |
|
||||||
|
|
||||||
|
**Explicitly NOT touched in Phase 1:** `py/services/download_manager.py`, `py/services/download_routing.py`, `py/services/settings_manager.py` default-root keys, `py/routes/stats_routes.py`, `py/utils/usage_stats.py`, `py/services/recipe_scanner.py`, `py/metadata_collector/`, `py/nodes/`.
|
||||||
|
|
||||||
|
**Zero-change confirmations (verified):** `PersistentModelCache`, `ModelUpdateService`, `DownloadedVersionHistoryService`, `MetadataSyncService` + provider chain (type-agnostic hash lookups), `ModelFileService` / `ModelMoveService` / `ModelLifecycleService` (scanner + model_type injected), `ModelCache` / `ModelHashIndex`, `AutoV3BackfillService`.
|
||||||
|
|
||||||
|
## 5. Frontend Changes
|
||||||
|
|
||||||
|
1. **`static/js/api/apiConfig.js`** — `MODEL_TYPES.OTHER = 'other'`; `MODEL_CONFIG.other` entry (displayName, singularName, `supportsMove`, `supportsBulkOperations`; no letter filter); endpoints come free from `getApiEndpoints()` (`:51`).
|
||||||
|
2. **`static/js/api/otherApi.js`** — thin `OtherApiClient extends BaseModelApiClient` (mirror `embeddingApi.js`); register in `modelApiFactory.js`.
|
||||||
|
3. **`static/js/other.js`** — page entry (mirror `embeddings.js`): `appCore.initialize()` + `createPageControls('other')` + `initializePageFeatures()` + `ModelDuplicatesManager` + `initActiveFiltersSync('other')`.
|
||||||
|
4. **Controls & context menu** — `OtherControls extends PageControls` and `OtherContextMenu` (start from the embedding variants — the smallest); add branches in the two factories (`components/controls/index.js:15`, `components/ContextMenu/index.js:15`). Context-menu template block lives in `templates/other.html` (`{% block additional_components %}`, the checkpoints/embeddings pattern — do NOT touch the shared `context_menu.html`).
|
||||||
|
5. **`templates/other.html`** — copy `embeddings.html`: same content blocks (controls + breadcrumb + duplicates banner + folder sidebar + `#modelGrid`), `data-page="other"`, main script `/loras_static/js/other.js`.
|
||||||
|
6. **`templates/components/header.html`** — nav entry (`:23-43`, active when `request.path.startswith('/other')`); enable the `modelTypes` sub_type filter panel for `other` (`:298-305` pattern from checkpoints); check search-options panel conditions (`:199-224`).
|
||||||
|
7. **`static/js/utils/constants.js`** — `MODEL_SUBTYPE_ABBREVIATIONS` (`:115`): `vae→VAE`, `upscaler→UPS`, `text_encoder→TE`, `clip_vision→CV`, `controlnet→CN`; matching `MODEL_SUBTYPE_DISPLAY_NAMES` (`:99`). (Unknown fallback already uppercases 4 chars, but explicit mappings read better.)
|
||||||
|
8. **`static/js/core.js:110` `getPageType()`** — verify `data-page="other"` flows through `state.pages` generically; add only if the page list is enumerated anywhere.
|
||||||
|
9. No change to `web/comfyui/top_menu_extension.js` (it opens `/loras`; page-to-page nav is the header bar).
|
||||||
|
|
||||||
|
## 6. i18n
|
||||||
|
|
||||||
|
- `locales/en.json`: add `other.title` (e.g. "Other Models") + minimal `other.contextMenu.*` / `other.modelTypes.*` keys; reuse `modelCard.*`, `loras.contextMenu.*`, `common.*` wherever possible (the established pattern — checkpoints/embeddings already reuse lora keys).
|
||||||
|
- Run `python scripts/sync_translation_keys.py`; leave `[TODO: Translate]` placeholders in other locales (per `docs/i18n-translation-guidelines.md` §7 — do not translate proactively).
|
||||||
|
|
||||||
|
## 7. Testing
|
||||||
|
|
||||||
|
Follow existing conventions (`pytest.ini`, `tests/frontend/` vitest):
|
||||||
|
|
||||||
|
1. **Backend (pytest, async where needed):**
|
||||||
|
- `OtherScanner` root aggregation + `resolve_sub_type_for_path` (file under `vae/` root → `vae`; `text_encoders` and legacy `clip` both → `text_encoder`; disabled `controlnet` root not scanned).
|
||||||
|
- Cache round-trip: sub_type re-derived via `adjust_cached_entry` (not persisted).
|
||||||
|
- Lazy hash: `hash_status="pending"` default; `calculate_hash_for_model` singleflight.
|
||||||
|
- `OtherRoutes` registration smoke test: `/api/lm/other/...` endpoints exist; `_validate_civitai_model_type` accepts `vae`/`upscaler`/`textencoder`, rejects `lora`.
|
||||||
|
- Config: `other_roots` in both modes (mock `folder_paths`, and standalone `settings.json.folder_paths`).
|
||||||
|
2. **Frontend (vitest + jsdom, `tests/frontend/`):**
|
||||||
|
- `apiConfig`: `getApiEndpoints('other')` URL shapes; `modelApiFactory` returns the Other client.
|
||||||
|
- `ModelCard` badge rendering for new sub_types.
|
||||||
|
- `createPageControls('other')` / `createPageContextMenu('other')` factories.
|
||||||
|
3. **Manual UI verification by the user** (per AGENTS.md — no sandbox/browser automation): page loads, scans a real library, sub_type filter + badges, context menu actions.
|
||||||
|
|
||||||
|
## 8. Execution Order
|
||||||
|
|
||||||
|
1. `constants.py` + `OtherModelMetadata` + `config.py` roots
|
||||||
|
2. `OtherScanner` (+ registry, factory, `PAGE_TYPE_MAP`) → scanner unit tests green
|
||||||
|
3. `OtherModelService` + `OtherRoutes` + handler/registrar wiring + `lora_manager.py` lifecycle → route tests green
|
||||||
|
4. Doctor/pending-delete/metadata-ops scatter entries
|
||||||
|
5. Template + header nav + frontend API/controls/context-menu/card badges → vitest green
|
||||||
|
6. i18n keys + sync script
|
||||||
|
7. `pytest` + `npm test` full runs; hand to user for manual UI check
|
||||||
|
|
||||||
|
## 9. Phase 2 Detailed Design — CivitAI Downloads for `other`
|
||||||
|
|
||||||
|
Designed 2026-09-12 against the Phase-1 code on this branch; decisions marked **[locked]** follow the same recommendations the feature owner approved for Phase 1.
|
||||||
|
|
||||||
|
### 9.1 Download pipeline touch points
|
||||||
|
|
||||||
|
Flow: `POST /api/lm/download-model` (`py/routes/model_route_registrar.py:104`; GET variant `:105` for the browser extension) → `ModelDownloadHandler.download_model` (`model_handlers.py:1740`) → `DownloadModelUseCase.execute` → `DownloadCoordinator.schedule_download` → `DownloadManager.download_from_civitai` (`download_manager.py:386`) → `_execute_original_download` (`:1415`). Inside, seven scatter points need an `other` branch:
|
||||||
|
|
||||||
|
1. **Type map** (`:1496-1507`): accept `model.type.lower() in VALID_OTHER_CIVITAI_TYPES` → `model_type = "other"` (reuses the Phase-1 set, incl. `"other"` itself).
|
||||||
|
2. **Early version-exists gate** (`:1436-1463`): add `other_scanner.check_model_version_exists`.
|
||||||
|
3. **File-level exists gate** (`:1640-1655` → `_find_local_file_entry` `:320-346` → `_get_scanner_for_model_type` `:230-236`): add explicit `other` branch. **Trap**: the function currently falls through to the lora scanner for unknown types — `"other"` would silently dedupe against loras. Also narrow the fall-through to `"lora"` only / raise on unknown.
|
||||||
|
4. **Version-level fallback gate** (`:1656-1688`): add `elif model_type == "other"`.
|
||||||
|
5. **Default-root selection** (`:1690-1727`): for `other`, first resolve sub_type (§9.2), then read `default_other_roots[sub_type]` (§9.3); if sub_type is undecidable or no default root configured → error guiding the user to pick a folder explicitly.
|
||||||
|
6. **Metadata class selection** (`:1909-1928`) + `_build_metadata_for_resume` (`:969-981`): add `OtherModelMetadata.from_civitai_info` branches.
|
||||||
|
7. **Post-download cache write** (`_execute_download_pipeline` `:2622-2679`): add `other` scanner branch; `adjust_metadata` re-derives sub_type from the on-disk root automatically. `_get_supported_extensions_for_type` (`:2720-2744`): `other` reuses the checkpoint extension set.
|
||||||
|
|
||||||
|
Hooks: `_record_downloaded_version_history` (model_type is free text — zero change); `_sync_downloaded_version` (`:1984` → scanner dispatch `:2130-2135`) add `other`; `py/utils/example_images_download_manager.py` scanner dispatch at `:411-421`, `:591-601`, `:1089+` — add `other` at all three (silent no-scanner otherwise).
|
||||||
|
|
||||||
|
Path templates: `get_download_path_template("other")` is unset, so `other` resolves to a **flat** layout (empty template) — downloads land directly under the resolved sub_type root. This is deliberate: other-model roots are already split per sub_type (`default_other_roots`), and `priority_tags` has no `other` entry, so `{first_tag}` would fall back to an arbitrary CivitAI tag and scatter files into unstable folders. Users who want nesting can still set `download_path_templates["other"]` in `settings.json`. See `DEFAULT_DOWNLOAD_PATH_TEMPLATES` (`py/utils/constants.py`) and `DEFAULT_PATH_TEMPLATES` (`static/js/utils/constants.js`).
|
||||||
|
|
||||||
|
### 9.2 File-level routing (model.type / file.type → sub_type) **[locked]**
|
||||||
|
|
||||||
|
Table-driven, mirroring Phase 1. New in `py/utils/constants.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
|
||||||
|
"VAE": "vae", "Upscaler": "upscaler", "Text Encoder": "text_encoder",
|
||||||
|
"Vision Encoder": "clip_vision", "CLIPVision": "clip_vision",
|
||||||
|
"ControlNet": "controlnet",
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`download_routing.py` gains `resolve_other_download_sub_type(civitai_model_type, file_types, selected_file_type=None)` with fixed priority:
|
||||||
|
|
||||||
|
1. **Explicit user file pick** (`file_params` from #1058's `_resolve_target_file`) — if the picked file's type maps, it wins even when model.type is `Checkpoint`.
|
||||||
|
2. **model.type** via the existing `CIVITAI_TYPE_TO_OTHER_SUB_TYPE` (`constants.py:120-127`).
|
||||||
|
3. **file.type fallback** — only when model.type maps to nothing (e.g. model.type `Other` or retired `CLIP`). MUST NOT override a mapped model.type: checkpoint models routinely bundle VAE/Text Encoder component files, and unconditional file-type routing would misroute them.
|
||||||
|
4. Still undecidable → `None`; `use_default_paths` errors and the UI offers all other roots for manual selection.
|
||||||
|
|
||||||
|
HTTP: extend `DownloadRoutingHandler.get_download_routing` (`download_routing_handlers.py:23`) with an `other` branch returning `{root_kind: "other", sub_type: ...}`; add `GET /api/lm/other/roots_by_subtype` in `OtherRoutes.setup_specific_routes` (data from `config._prepare_other_paths`'s per-key roots, aggregating `text_encoders` + legacy `clip` under `text_encoder`).
|
||||||
|
|
||||||
|
### 9.3 Settings: single dict key `default_other_roots` **[locked]**
|
||||||
|
|
||||||
|
Rejected: four flat keys (`default_vae_root`…) — each flat key costs ~13 touch points in `settings_manager.py` (defaults `:82-85`, `_check_and_auto_set` `:890-895`, `set()` `:1621-1628`, `_update_active_library_entry` `:738-805`, upsert/create signatures `:1953-2132`, `_build_library_payload` `:552-612`, `_sync_active_library_to_root` `:519-547`, three library constructors, frontend `DEFAULT_SETTINGS_BASE`), repeated per future sub_type.
|
||||||
|
|
||||||
|
Chosen: one mapping key `default_other_roots: {sub_type: path}`, copying the `extra_folder_paths` precedent (generic Mapping handling at `:533-535`, `:573-578`, `:763-767`). `_check_and_auto_set` generalizes to per-sub_type candidates (union over that sub_type's folder keys — `text_encoder` → `text_encoders` + `clip`). `set()` validates keys against `VALID_OTHER_SUB_TYPES`.
|
||||||
|
|
||||||
|
Also fix the Phase-1 omission: add `"other_scanner"` to `_notify_library_change` (`:2150-2156`) and `_notify_model_name_display_change` (`:1795-1800`) — otherwise switching libraries leaves the other page stale.
|
||||||
|
|
||||||
|
### 9.4 Settings UI
|
||||||
|
|
||||||
|
- `templates/components/modals/settings/library.html:34-40`: sub_type selectors after the existing four `setting_select`s (Jinja loop; controlnet selector only when `enabled_other_folders` includes it). Dict-subkey save helper `saveOtherRootSetting(subType, value)` alongside the flat `saveSelectSetting`.
|
||||||
|
- `static/js/managers/SettingsManager.js:1547-1697`: `loadOtherRoots()` mirroring `loadUnetRoots()`, fed by `/api/lm/other/roots_by_subtype`; current values from `state.global.settings.default_other_roots`. `state/index.js:24` `DEFAULT_SETTINGS_BASE` += `default_other_roots: {}`.
|
||||||
|
- Optional: one `other` row in the download-path-template block (`library.html:153-211`).
|
||||||
|
- i18n: `settings.folderSettings.*` keys into `locales/en.json` + sync script; other locales keep `[TODO: Translate]`.
|
||||||
|
- Settings GET (`misc_handlers.py:1528-1536`) already returns all non-sensitive keys — new key reaches the frontend for free.
|
||||||
|
|
||||||
|
### 9.5 Frontend download entry
|
||||||
|
|
||||||
|
- `templates/components/controls.html:83`: drop the `page_id != 'other'` exclusion on the download button (keyboard shortcut D self-enables via `PageControls.js:196-198`).
|
||||||
|
- `OtherControls.js:22-55`: add `showDownloadModal: () => downloadManager.showDownloadModal()` (mirror `EmbeddingsControls.js:43-45`).
|
||||||
|
- `DownloadManager.js` `proceedToLocationContent` (`:955-1017`): add `_resolveOtherSubType()` (mirror `_resolveIsDiffusionModel` `:1026`): selected file type → `/api/lm/download/routing` → `otherApiClient.fetchModelRoots(subType)` (new); default-root preselect reads `default_other_roots[subType]` instead of `` `default_${singularType}_root` `` (`:974`). Undecidable → list all other roots (`/api/lm/other/roots`) for manual pick; an explicit save_dir skips backend default-root logic, so the two paths cannot disagree.
|
||||||
|
- `ModelVersionsTab` download buttons are modelType-generic and already work via `getModelApiClient('other')`; context menu has no CivitAI download entry — no change.
|
||||||
|
- Version-list type validation (`get_civitai_versions` → `_validate_civitai_model_type`) already accepts `VALID_OTHER_CIVITAI_TYPES` from Phase 1.
|
||||||
|
|
||||||
|
### 9.6 CivitAI type mapping decisions **[locked]**
|
||||||
|
|
||||||
|
- Download accepts exactly `VALID_OTHER_CIVITAI_TYPES` (`VAE, Upscaler, TextEncoder, CLIP, CLIPVision, Controlnet, Other`) — reuse the Phase-1 tables; do NOT create new ones.
|
||||||
|
- Extend `CIVITAI_USER_MODEL_TYPES` (`constants.py:133-137`) with the 7 aliases, and point them at the other scanner / `"other"` history bucket in `misc_handlers.py` (`type_scanner_map` `:2793-2797`, `downloaded_version_map` `:2821-2827`) — otherwise creator pages silently filter these models while downloads claim support.
|
||||||
|
- Fix (small Phase-1 bug): `OtherModelMetadata.from_civitai_info` (`py/utils/models.py:343`) reads `version_info.get("type")`, but the type lives at `version["model"]["type"]` — the mapping never fires and always degrades to the placeholder. Read `version_info.get("model", {}).get("type")` instead. (`CheckpointMetadata:290` has the same shape; leave it alone here.)
|
||||||
|
|
||||||
|
### 9.7 Tests
|
||||||
|
|
||||||
|
Existing base: `tests/services/test_download_manager_basic.py` (incl. `test_download_rejects_unsupported_model_type` `:1336`), `test_download_manager_error.py`, `test_download_manager_concurrent.py`, `tests/integration/test_download_flow.py`, `tests/services/test_settings_manager.py`; frontend `tests/frontend/managers/downloadManager.routing.test.js`, `settingsManager.library.test.js`.
|
||||||
|
|
||||||
|
Add: (1) `resolve_other_download_sub_type` unit tests — every priority tier, bundled-component anti-misrouting, undecidable → None, civarchive-shaped payload; (2) download_manager — six model.types accepted → other scanner (mock), unknown still rejected, no lora-scanner fall-through, per-sub_type default roots + unconfigured error, resume metadata, extension set; (3) settings_manager — `default_other_roots` defaults/auto-set (incl. text_encoder dual-key union)/library sync/upsert passthrough/illegal sub_type rejection; (4) routes — `/api/lm/download/routing` other branch, `roots_by_subtype` shape; (5) example-images dispatch accepts `other` (3 sites); (6) vitest — `_resolveOtherSubType` + root select + default preselect, `loadOtherRoots`; (7) user-models existsLocally for VAE.
|
||||||
|
|
||||||
|
### 9.8 Phase 2 file list
|
||||||
|
|
||||||
|
Backend: `py/utils/constants.py`, `py/services/download_routing.py`, `py/routes/handlers/download_routing_handlers.py`, `py/services/download_manager.py`, `py/utils/example_images_download_manager.py`, `py/services/settings_manager.py`, `py/utils/models.py`, `py/routes/other_routes.py`, `py/routes/handlers/misc_handlers.py`, `settings.json.example`.
|
||||||
|
Frontend/templates: `templates/components/controls.html`, `static/js/components/controls/OtherControls.js`, `static/js/managers/DownloadManager.js`, `static/js/api/otherApi.js`, `templates/components/modals/settings/library.html`, `static/js/managers/SettingsManager.js`, `static/js/state/index.js`, `locales/en.json` + sync.
|
||||||
|
|
||||||
|
## 10. Risks / Open Questions
|
||||||
|
|
||||||
|
- **Root overlap**: a user may point `text_encoders` at a directory already scanned as checkpoints/unet. Realpath dedup inside one scanner won't catch cross-scanner overlap → the `_prepare_other_paths` overlap warning (§4.3) is the mitigation; duplicate cards across pages are cosmetic, not corrupting (cache keyed by `(model_type, file_path)`).
|
||||||
|
- **Huge text encoders + lazy hash**: CivitAI fetch for a pending-hash model must trigger on-demand hash like checkpoints do — verify that flow (`calculate_hash_for_model`) is reachable from the `other` routes' fetch-metadata handler.
|
||||||
|
- **Retired CivitAI types**: `CLIP`/`CLIPVision` are retired upstream (grandfathered for existing models); metadata fetch must tolerate both retired and current types — `VALID_OTHER_CIVITAI_TYPES` includes them deliberately.
|
||||||
|
- **Standalone users** must add the new `folder_paths` keys to `settings.json` themselves; document in `settings.json.example` and the feature doc.
|
||||||
|
- **Page display name** is i18n-only; if "Other Models" tests poorly, rename `other.title` without code changes.
|
||||||
|
|
||||||
|
### Phase 2 risks
|
||||||
|
|
||||||
|
- **Bundled component files**: checkpoint models routinely ship VAE/Text Encoder component files — file.type routing must stay a fallback (or explicit user pick), never an override (§9.2 priority is load-bearing; test it).
|
||||||
|
- **`_get_scanner_for_model_type` lora fall-through** (`download_manager.py:236`): without an explicit `other` branch, dedupe checks run against the lora scanner — the most insidious trap in Phase 2.
|
||||||
|
- **text_encoder dual folder keys** (`text_encoders` + legacy `clip`): default-root candidates, `roots_by_subtype`, and auto-set must all merge both keys; miss one and the default-root dropdown comes up empty.
|
||||||
|
- **Undecidable sub_type** (model.type `Other` + unknown file types): must error and ask, never silently default to the vae folder.
|
||||||
|
- **Lazy hash after download**: downloads carry CivitAI SHA256 (no recompute needed) — ensure the post-download cache write doesn't leave `hash_status="pending"`, or the next metadata fetch re-hashes a 10 GB file.
|
||||||
|
- **CivArchive source**: same `_execute_original_download` path, same payload shape — cover it once in tests.
|
||||||
|
|
||||||
|
## 11. Phase 3 — Opt-in Management Toggles (implemented)
|
||||||
|
|
||||||
|
Designed 2026-09-13 against the Phase-1/2 code. Other Models is **opt-in**: after
|
||||||
|
Phase 3 the feature ships disabled, so no other-model folder is scanned and the
|
||||||
|
page shows an "enable" empty state until the user turns it on.
|
||||||
|
|
||||||
|
### 11.1 Settings (global, not per-library)
|
||||||
|
|
||||||
|
| key | type | default | meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `enable_other_models` | bool | `false` | master switch |
|
||||||
|
| `enabled_other_sub_types` | list[str] | `["vae","upscaler","text_encoder"]` | allow-list; `clip_vision` and `controlnet` are opt-in (see §2) |
|
||||||
|
|
||||||
|
`enabled_other_folders` (the unreleased, additive, no-UI backend key) was removed
|
||||||
|
and replaced by the sub_type-level allow-list; there is no migration because the
|
||||||
|
feature never shipped. `text_encoder` expands to `text_encoders` + legacy `clip`
|
||||||
|
via `OTHER_SUB_TYPE_FOLDER_KEYS`.
|
||||||
|
|
||||||
|
The default allow-list lives on five surfaces that must stay in sync:
|
||||||
|
`DEFAULT_ENABLED_OTHER_SUB_TYPES` (`py/utils/constants.py`), `DEFAULT_SETTINGS`
|
||||||
|
(`py/services/settings_manager.py`), the two `DEFAULT_SETTINGS_BASE` /
|
||||||
|
`createDefaultSettings` lists (`static/js/state/index.js`), the
|
||||||
|
`updateOtherModelsControls()` fallback (`static/js/managers/SettingsManager.js`)
|
||||||
|
and the server-rendered Jinja fallback
|
||||||
|
(`templates/components/modals/settings/library.html`).
|
||||||
|
|
||||||
|
### 11.1.1 Legacy key handling in `Config._init_other_paths`
|
||||||
|
|
||||||
|
ComfyUI's `folder_paths` rewrites legacy names before every access (`clip` →
|
||||||
|
`text_encoders`, `unet` → `diffusion_models`) and registers both legacy
|
||||||
|
directories under the canonical key, so `get_folder_paths("clip")` returns
|
||||||
|
exactly the same list as `get_folder_paths("text_encoders")`. Querying both keys
|
||||||
|
made the overlap guard fire twice with `please fix your path configuration` for a
|
||||||
|
configuration the user cannot fix. `Config._collapse_legacy_folder_keys()` now
|
||||||
|
drops a key when the host exposes `map_legacy` and resolves it to another queried
|
||||||
|
key, and `_prepare_other_paths()` downgrades a same-`sub_type` duplicate to
|
||||||
|
`debug` (a cross-`sub_type` collision still warns). In standalone mode
|
||||||
|
`MockFolderPaths` has no `map_legacy` and its keys are independent
|
||||||
|
`settings.json` entries, so every key is still queried there.
|
||||||
|
|
||||||
|
`settings.json.example` intentionally stays minimal (only `use_portable_settings`,
|
||||||
|
`civitai_api_key`, and the four core `folder_paths` keys: `loras`, `checkpoints`,
|
||||||
|
`unet`, `embeddings`). Optional keys — including the other-model folder paths and
|
||||||
|
`enable_other_models` — are NOT documented there; they live in `DEFAULT_SETTINGS`
|
||||||
|
and reach the user's `settings.json` on demand. This supersedes the Phase-1/Phase-2
|
||||||
|
notes that proposed adding the other-model folder keys to the example.
|
||||||
|
|
||||||
|
### 11.2 Behaviour matrix
|
||||||
|
|
||||||
|
| state | scan | nav / `/other` | other downloads | `default_other_roots` | Doctor / refresh-all |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| master off | nothing (`other_roots == []`) | nav entry hidden (`nav-item--hidden`); `/other` still renders the disabled empty state + Enable button; one-time dismissible announcement banner on first visit | rejected | preserved, never auto-set | scanner skipped |
|
||||||
|
| sub_type off | that sub_type's folder keys excluded | page keeps working, type disappears from data | auto-routing refused (manual folder still allowed) | preserved, not preselected | normal |
|
||||||
|
| all on (after enabling) | Phase-1/2 behaviour | normal | normal | normal | normal |
|
||||||
|
|
||||||
|
### 11.3 Backend touch points
|
||||||
|
|
||||||
|
- `py/utils/constants.py` — `DEFAULT_ENABLED_OTHER_SUB_TYPES`, `OTHER_SUB_TYPE_FOLDER_KEYS`, `normalize_other_sub_types`.
|
||||||
|
- `py/config.py` — `_get_enabled_other_folder_keys()` is the single scan gate (master switch + allow-list); new `refresh_other_roots()` rebuilds roots + preview roots on toggle.
|
||||||
|
- `py/services/settings_manager.py` — new defaults, `set()` normalization, `is_other_models_enabled()` / `get_enabled_other_sub_types()` / `is_other_sub_type_enabled()`, and `_apply_other_model_settings_change()` which reapplies config and calls `other_scanner.on_library_changed(reconcile=True)`.
|
||||||
|
- `py/services/model_scanner.py` — `_should_keep_cached_entry()` hydration hook (default keep) plus `on_library_changed(reconcile=...)` / `initialize_in_background(reconcile=...)`; the hook filters `raw_data` and the hash/autov3 index rows.
|
||||||
|
- `py/services/other_scanner.py` — drops persisted entries whose folder is no longer a managed root (sub_type is location-derived, so config is the source of truth).
|
||||||
|
- `py/routes/other_routes.py` — `_validate_civitai_model_type` rejects everything while off / mapped-but-disabled sub_types; `_get_page_context_provider()` injects `other_disabled` into the template.
|
||||||
|
- `py/routes/handlers/model_handlers.py` + `base_model_routes.py` — optional `page_context_provider` hook on `ModelPageView`.
|
||||||
|
- `py/routes/handlers/download_routing_handlers.py` — returns `{sub_type: None, disabled: true, reason}` instead of guessing.
|
||||||
|
- `py/services/download_manager.py` — rejects other-type downloads while off; disabled sub_type refuses default-path routing with a "pick a folder" error.
|
||||||
|
- `py/routes/handlers/misc_handlers.py` — Doctor / init-status / refresh-all skip the other scanner while off (`_active_scanner_factories` / `_active_scanner_getters`).
|
||||||
|
- `py/services/pending_delete_service.py` — deliberately untouched: the scanner stays registered so staged deletes still merge.
|
||||||
|
|
||||||
|
### 11.4 Frontend
|
||||||
|
|
||||||
|
Discoverability: the nav entry is hidden while the feature is off, and three
|
||||||
|
lightweight surfaces replace it — a one-time announcement banner, the download
|
||||||
|
toast, and the settings toggle itself.
|
||||||
|
|
||||||
|
- `templates/components/header.html` + `static/css/components/header.css` — `nav-item--hidden` class (server-rendered when off, client-toggled after enabling) and the `fa-shapes` icon.
|
||||||
|
- `templates/other.html` — `other_disabled` branch in `content` + `main_script`; page-scoped CSS for the empty state.
|
||||||
|
- `static/js/other_disabled.js` — boots `appCore` (shared header) and delegates to the shared enable helper.
|
||||||
|
- `static/js/utils/otherModels.js` — shared `enableOtherModels()` (POST settings + reload) and `openOtherModelsSettings()` (settings modal on the Library section); used by the disabled page, the banner and the download modal.
|
||||||
|
- `static/js/managers/BannerService.js` — `other-models-announcement` banner (only when off and not dismissed; `priority: 0`, dismissal persisted via `dismissed_banners`) with Enable / Open Settings actions; `removeOtherModelsAnnouncement()` drops it without persisting a dismissal.
|
||||||
|
- `templates/components/modals/settings/library.html` + `SettingsManager.updateOtherModelsControls()` / `saveEnabledOtherSubTypes()` / `updateOtherModelsNavVisibility()` — master toggle + five sub_type checkboxes; unchecked/disabled sub_types have their default-root select disabled.
|
||||||
|
- `static/js/managers/DownloadManager.js` — a disabled routing answer surfaces a `showActionToast` with an "Enable Other Models" action (opening settings) and falls back to manual selection.
|
||||||
|
- i18n: `settings.folderSettings.*`, `other.disabled.*` and `banners.otherModels.*` keys in `locales/en.json` + `scripts/sync_translation_keys.py` (other locales keep `[TODO: Translate]`).
|
||||||
|
|
||||||
|
### 11.5 Cache consistency
|
||||||
|
|
||||||
|
- Disabling purges rows from the in-memory view at hydration time (the
|
||||||
|
`_should_keep_cached_entry` hook) and from SQLite on the reconcile triggered by
|
||||||
|
the toggle; the `.metadata.json` sidecars survive, so re-enabling rescans
|
||||||
|
without recomputing hashes (critical for multi-GB text encoders).
|
||||||
|
- Enabling triggers a reconcile so newly managed roots are scanned immediately.
|
||||||
|
- Editing `settings.json` while the server is stopped is still covered by the
|
||||||
|
hydration hook, so disabled types never appear after a restart.
|
||||||
|
|
||||||
|
### 11.6 Tests
|
||||||
|
|
||||||
|
Backend: opt-in fixtures added to the other-related suites; new coverage for
|
||||||
|
"default off scans nothing", per-sub_type gating, routing/download rejection,
|
||||||
|
`_should_keep_cached_entry`, settings normalization and `other_disabled` page
|
||||||
|
context. Frontend: `updateOtherModelsControls` / `saveEnabledOtherSubTypes` and
|
||||||
|
the disabled-page enable flow.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Reconcile 的 Windows 大小写回退分支 - 待验证清单
|
||||||
|
|
||||||
|
> **状态**: 待 Windows 环境验证 | **创建日期**: 2026-09-11
|
||||||
|
> **相关文件**: `py/services/model_scanner.py` (`ModelScanner._reconcile_cache`)
|
||||||
|
> **相关历史**: #871 (`76ee59cd`, 路径重叠去重)、#1108 (按文件夹扫描的需求)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
|
||||||
|
Refresh 按钮走的是 `_reconcile_cache()`(快速增量对账)。2026-09-11 做了一轮性能优化,把两处"预防性"的
|
||||||
|
realpath 全量遍历改成按需触发(详见下方"已完成")。优化后,一次零变更 Refresh 在 5 万文件库上从
|
||||||
|
~1400 ms 降到 ~120 ms。
|
||||||
|
|
||||||
|
清理过程中发现**唯一一处遗留的可疑点**:Windows 专属的大小写不敏感回退分支。它无法在 Linux 上验证,
|
||||||
|
因此单独记录,留待 Windows 机器上确认。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待验证分支(现状)
|
||||||
|
|
||||||
|
`py/services/model_scanner.py` 中 `_reconcile_cache()` 的 walk 循环内:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Try case-insensitive match on Windows
|
||||||
|
if os.name == 'nt':
|
||||||
|
lower_path = file_path.lower()
|
||||||
|
matched = False
|
||||||
|
for cached_path in cached_paths: # 每个未命中文件都全量扫一遍缓存
|
||||||
|
if cached_path.lower() == lower_path:
|
||||||
|
found_paths.add(cached_path)
|
||||||
|
matched = True
|
||||||
|
break
|
||||||
|
if matched:
|
||||||
|
continue
|
||||||
|
```
|
||||||
|
|
||||||
|
它排在精确匹配(`file_path in cached_paths`)和 realpath 别名匹配之后,只有**未命中**的文件才会走到。
|
||||||
|
|
||||||
|
### 为什么可疑
|
||||||
|
|
||||||
|
1. **可能不可达**:Windows 上 `os.path.realpath()` 会返回磁盘上的真实大小写,因此"缓存路径大小写与磁盘
|
||||||
|
不一致"的情形,理论上已经被上一步的 realpath 别名匹配覆盖。若如此,这段就是纯冗余代码。
|
||||||
|
2. **一旦可达就是 O(N×M)**:每个未命中文件都要遍历全部 `cached_paths` 做小写比较。若某种路径写法让
|
||||||
|
整个库都变成"未命中"(例如缓存里的盘符/大小写形式与 walk 结果系统性不一致),一次 Refresh 会退化
|
||||||
|
成 文件数 × 缓存条目数 次字符串比较,比真实 IO 还贵。
|
||||||
|
3. **没有测试覆盖**:`tests/services/test_model_scanner.py` 没有任何针对该分支的用例(它在 Linux 上
|
||||||
|
被 `os.name == 'nt'` 短路,无法覆盖)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 待办
|
||||||
|
|
||||||
|
- [ ] **验证可达性**:在 Windows 上构造"缓存路径与磁盘真实大小写不一致"的场景,确认 realpath 别名匹配
|
||||||
|
是否已经命中,即上面的 `if os.name == 'nt'` 分支是否还有进入的必要。
|
||||||
|
- [ ] **若不可达 / 冗余**:删除该分支,并在删除处留注释说明 realpath 已覆盖大小写归一(附验证记录)。
|
||||||
|
- [ ] **若可达**:保留语义但改成 O(1)——预先构建一次 `lower_path -> cached_path` 映射(与
|
||||||
|
`cached_real_paths` 同样按需、懒构建),把内层全量扫描换成一次字典查询。
|
||||||
|
- [ ] **补一个 Windows-only 的回归测试**(`pytest.mark.skipif(os.name != "nt", ...)`),锁定最终结论。
|
||||||
|
- [ ] 把验证结论回填到本文件,并同步更新状态行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验证方法(Windows)
|
||||||
|
|
||||||
|
1. **构造不一致的大小写**:让缓存里的 `file_path` 与磁盘实际路径大小写不同(例如改过盘符/目录大小写,
|
||||||
|
或从另一台机器迁移了 `settings.json` 与持久化缓存),然后在 UI 点 Refresh。
|
||||||
|
2. **看后端日志判据**:
|
||||||
|
- 若 realpath 已覆盖 → 日志应显示 `Cache reconciliation completed in X seconds. Added 0, removed 0 models.`,
|
||||||
|
且**没有** `Found N new files to process` / `Processing <path>`。
|
||||||
|
- 若回退分支在起作用 → 同样应该是 `Added 0, removed 0`(因为 `found_paths` 被补上),这是"分支可达"
|
||||||
|
的证据;反之若出现大量 `Processing ...` 并重新 hash,说明连回退分支也没命中,问题更严重
|
||||||
|
(缓存路径被当成了新文件 + 旧条目被删)。
|
||||||
|
3. **跑测试**:`python -m pytest tests/services/test_model_scanner.py -k reconcile`(该文件在 Windows 上会
|
||||||
|
真实执行 `os.name == 'nt'` 分支)。
|
||||||
|
4. **量化**:如果需要,可在 `_reconcile_cache` 里临时插桩统计该分支的进入次数与内层迭代次数,确认是否为 0。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 已完成(本轮优化,供对照)
|
||||||
|
|
||||||
|
同一次清理里已经落地并验证的部分(Linux,5 万文件库):
|
||||||
|
|
||||||
|
- `cached_real_paths` 别名映射改为**首次未命中时**懒构建(原来每次 Refresh 都对全部缓存条目算一次 realpath)。
|
||||||
|
- 每个文件的 `realpath` 移到精确命中检查**之后**(原来对每个文件都算,命中即丢弃)。
|
||||||
|
- `get_model_roots()` 在新增文件处理阶段只快照一次(原来每个新文件重读一次)。
|
||||||
|
- 全量去重 pass 加了 O(1) 前置判断(`cached_size_before != len(cached_paths) or total_added > 0`),
|
||||||
|
零变更且缓存干净时跳过;快照本身含重复路径时仍会自愈。
|
||||||
|
|
||||||
|
结果:零变更 Refresh 5 万文件 **~1400 ms → ~120 ms**;根目录顺序/符号链接别名翻转场景仍是
|
||||||
|
`re-processed=0`(不重新读 metadata、不重新 hash)。测试:`tests/services/test_model_scanner.py`
|
||||||
|
47 项、全量后端 2567 项全部通过。
|
||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Checkpoint-Name kopieren",
|
"copyCheckpointName": "Checkpoint-Name kopieren",
|
||||||
"copyEmbeddingName": "Embedding-Name kopieren",
|
"copyEmbeddingName": "Embedding-Name kopieren",
|
||||||
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
"embeddingNameCopied": "Embedding-Syntax kopiert",
|
||||||
|
"modelNameCopied": "Modellname kopiert",
|
||||||
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
"sendCheckpointToWorkflow": "An ComfyUI senden",
|
||||||
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
"sendEmbeddingToWorkflow": "An ComfyUI senden"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "Rezepte lokalen Modellen neu zuordnen",
|
"label": "Rezepte lokalen Modellen neu zuordnen",
|
||||||
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
"loading": "Rezepte werden lokalen Modellen neu zugeordnet...",
|
||||||
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
"success": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
||||||
"successErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
|
||||||
"allFailed": "Zuordnung fehlgeschlagen für {failures} von {total} Rezepten",
|
|
||||||
"noMatch": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
|
||||||
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
"cancelled": "Zuordnung abgebrochen. {recipes} Rezepte aktualisiert ({entries} Einträge)",
|
||||||
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
"error": "Zuordnung der Rezepte fehlgeschlagen: {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "Rezepte",
|
"recipes": "Rezepte",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "Andere",
|
||||||
"statistics": "Statistiken"
|
"statistics": "Statistiken"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultUnetRootHelp": "Legen Sie den Standard-Diffusion-Modell-(UNET)-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
"defaultEmbeddingRoot": "Embedding-Stammordner",
|
||||||
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
"defaultEmbeddingRootHelp": "Legen Sie den Standard-Embedding-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultVaeRoot": "VAE-Stammordner",
|
||||||
|
"defaultVaeRootHelp": "Legen Sie den Standard-VAE-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultUpscalerRoot": "Upscaler-Stammordner",
|
||||||
|
"defaultUpscalerRootHelp": "Legen Sie den Standard-Upscaler-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultTextEncoderRoot": "Text-Encoder-Stammordner",
|
||||||
|
"defaultTextEncoderRootHelp": "Legen Sie den Standard-Text-Encoder-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultClipVisionRoot": "CLIP-Vision-Stammordner",
|
||||||
|
"defaultClipVisionRootHelp": "Legen Sie den Standard-CLIP-Vision-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"defaultControlnetRoot": "ControlNet-Stammordner",
|
||||||
|
"defaultControlnetRootHelp": "Legen Sie den Standard-ControlNet-Stammordner für Downloads, Importe und Verschiebungen fest",
|
||||||
|
"enableOtherModels": "Verwaltung weiterer Modelle",
|
||||||
|
"enableOtherModelsHelp": "Wenn deaktiviert, werden VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Ordner nicht gescannt, die Seite für weitere Modelle bleibt deaktiviert und diese Modelltypen können nicht heruntergeladen werden.",
|
||||||
|
"otherSubTypes": "Verwaltete Modelltypen",
|
||||||
|
"otherSubTypesHelp": "Wählen Sie, welche Kategorien weiterer Modelle gescannt und auf der Seite für weitere Modelle angezeigt werden.",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "Rezepte-Speicherpfad",
|
"recipesPath": "Rezepte-Speicherpfad",
|
||||||
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
"recipesPathHelp": "Optionales benutzerdefiniertes Verzeichnis für gespeicherte Rezepte. Leer lassen, um den recipes-Ordner im ersten LoRA-Stammverzeichnis zu verwenden.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding-Modelle"
|
"title": "Embedding-Modelle"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Weitere Modelle",
|
||||||
|
"disabled": {
|
||||||
|
"title": "Die Verwaltung weiterer Modelle ist deaktiviert",
|
||||||
|
"description": "Aktivieren Sie die Option, um VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien zu scannen und zu verwalten und sie von CivitAI herunterzuladen.",
|
||||||
|
"enableButton": "Weitere Modelle aktivieren",
|
||||||
|
"hint": "Sie können die verwalteten Modelltypen später unter Einstellungen > Bibliothek ändern.",
|
||||||
|
"enableFailed": "Aktivierung weiterer Modelle fehlgeschlagen",
|
||||||
|
"downloadBlocked": "Die Verwaltung weiterer Modelle ist für diesen Modelltyp deaktiviert. Aktivieren Sie sie unter Einstellungen > Bibliothek, um diese Datei herunterzuladen.",
|
||||||
|
"enableAction": "Weitere Modelle aktivieren"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "Keine Ordner für weitere Modelle gefunden",
|
||||||
|
"descriptionStandalone": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die unten stehenden Ordnerpfade zu settings.json hinzu und starten Sie LoRA Manager neu.",
|
||||||
|
"hintStandalone": "Nur die oben aufgeführten Ordnerschlüssel werden gescannt; nicht benötigte Schlüssel können weggelassen werden.",
|
||||||
|
"descriptionComfyUI": "Die Verwaltung weiterer Modelle ist aktiviert, aber keiner der konfigurierten Modellordner existiert auf dem Datenträger. Fügen Sie die entsprechenden Modellordner zu Ihren ComfyUI-Modellpfaden hinzu und laden Sie diese Seite neu.",
|
||||||
|
"hintComfyUI": "Weitere Modelle werden aus den Ordnern vae, upscale_models, text_encoders, clip_vision und controlnet von ComfyUI gelesen.",
|
||||||
|
"openSettings": "Einstellungen öffnen"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Stammverzeichnis",
|
"modelRoot": "Stammverzeichnis",
|
||||||
"collapseAll": "Alle Ordner einklappen",
|
"collapseAll": "Alle Ordner einklappen",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
"note": "Dateien werden mit Standard-Pfad-Vorlagen heruntergeladen. Dies kann je nach Anzahl der LoRAs eine Weile dauern.",
|
||||||
"downloadButton": "{count} LoRA(s) herunterladen"
|
"downloadButton": "{count} LoRA(s) herunterladen"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "Rezepte neu zuordnen",
|
||||||
|
"messageGlobal": "Alle Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||||
|
"messageSingle": "Dieses Rezept wird mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||||
|
"messageBulk": "{count} ausgewählte Rezepte werden mit Ihrer lokalen Modellbibliothek abgeglichen.",
|
||||||
|
"relaxedLabel": "Fehlende Modelle auch per Dateiname neu verbinden",
|
||||||
|
"relaxedDescription": "Diese Modelle könnten auch per Download behoben werden — der Download ist genauer. Übereinstimmungen verknüpfen möglicherweise eine andere Version; sie werden zur Überprüfung aufgelistet und können rückgängig gemacht werden.",
|
||||||
|
"confirmButton": "Neu zuordnen"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "Rückgängig",
|
||||||
|
"undone": "Rückgängig gemacht",
|
||||||
|
"undoFailed": "Rückgängigmachen der Neuordnung fehlgeschlagen: {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "Zusammenfassung der Neuordnung",
|
||||||
|
"successMessage": "{entries} Einträge zugeordnet",
|
||||||
|
"failed": "Neuordnung fehlgeschlagen",
|
||||||
|
"completedWithWarnings": "Neuordnung abgeschlossen — Überprüfung empfohlen",
|
||||||
|
"cancelledNote": "Der Vorgang wurde vorzeitig abgebrochen — die Zahlen sind unvollständig.",
|
||||||
|
"statMatched": "Zugeordnete Einträge",
|
||||||
|
"statReview": "Zu überprüfen",
|
||||||
|
"statUnresolved": "Nicht zugeordnet",
|
||||||
|
"statErrors": "Fehler",
|
||||||
|
"reviewSection": "Dateinamen-Übereinstimmungen zur Überprüfung ({count})",
|
||||||
|
"columnRecipe": "Rezept",
|
||||||
|
"columnEntry": "Eintrag",
|
||||||
|
"columnFile": "Zugeordnete Datei",
|
||||||
|
"columnUndo": "Rückgängig",
|
||||||
|
"copyReport": "Bericht kopieren",
|
||||||
|
"close": "Schließen",
|
||||||
|
"scope_global": "Alle Rezepte",
|
||||||
|
"scope_bulk": "Ausgewählte Rezepte",
|
||||||
|
"scope_single": "Einzelnes Rezept"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Lokale Beispielbilder",
|
"title": "Lokale Beispielbilder",
|
||||||
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
"message": "Keine lokalen Beispielbilder für dieses Modell gefunden. Ansichtsoptionen:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Embedding Manager wird initialisiert",
|
"title": "Embedding Manager wird initialisiert",
|
||||||
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
"message": "Embedding-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Manager für weitere Modelle wird initialisiert",
|
||||||
|
"message": "Modell-Cache wird gescannt und aufgebaut. Dies kann einige Minuten dauern..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Rezept Manager wird initialisiert",
|
"title": "Rezept Manager wird initialisiert",
|
||||||
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
"message": "Rezepte werden geladen und verarbeitet. Dies kann einige Minuten dauern..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
|
||||||
"created": "Rezept erfolgreich erstellt",
|
"created": "Rezept erfolgreich erstellt",
|
||||||
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
|
||||||
|
"unresolvableMarkedForReconnect": "{count} nicht auflösbare Einträge markiert — sie können jetzt mit einem lokalen LoRA neu verbunden werden.",
|
||||||
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
|
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
|
||||||
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
"noNextRecipe": "Kein weiteres Rezept verfügbar",
|
||||||
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
"batchImportBrowseFailed": "Ordner konnte nicht durchsucht werden: {message}",
|
||||||
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
"batchImportDirectorySelected": "Verzeichnis ausgewählt: {path}",
|
||||||
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
"noRecipesSelected": "Keine Rezepte ausgewählt",
|
||||||
"rematchComplete": "{entries} Einträge in {recipes} Rezepten zugeordnet",
|
|
||||||
"rematchCompleteErrors": "{entries} Einträge in {recipes} Rezepten zugeordnet, {failures} fehlgeschlagen",
|
|
||||||
"rematchAllFailed": "Zuordnung fehlgeschlagen für {failures} von {total} ausgewählten Rezepten",
|
|
||||||
"rematchUnmatched": "Keine lokale Übereinstimmung für {entries} Einträge in {recipes} Rezepten gefunden",
|
|
||||||
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
"rematchSkipped": "Keine Zuordnung für die {total} ausgewählten Rezepte erforderlich",
|
||||||
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
"rematchFailed": "Zuordnung der ausgewählten Rezepte fehlgeschlagen: {message}",
|
||||||
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
"reimporting": "Rezept wird aus Quelle neu importiert...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
"checkpointRootsFailed": "Fehler beim Laden der Checkpoint-Stammverzeichnisse: {message}",
|
||||||
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
"unetRootsFailed": "Fehler beim Laden der Diffusion-Modell-Stammverzeichnisse: {message}",
|
||||||
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
"embeddingRootsFailed": "Fehler beim Laden der Embedding-Stammverzeichnisse: {message}",
|
||||||
|
"otherRootsFailed": "Fehler beim Laden der Stammverzeichnisse weiterer Modelle: {message}",
|
||||||
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
"mappingsUpdated": "Basismodell-Pfad-Zuordnungen aktualisiert ({count})",
|
||||||
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
"mappingsCleared": "Basismodell-Pfad-Zuordnungen gelöscht",
|
||||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "Cache wird neu aufgebaut...",
|
"rebuilding": "Cache wird neu aufgebaut...",
|
||||||
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
|
"rebuildFailed": "Fehler beim Neuaufbau des Caches: {error}",
|
||||||
"retry": "Wiederholen"
|
"retry": "Wiederholen"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "Die Verwaltung weiterer Modelle ist verfügbar",
|
||||||
|
"content": "Scannen und verwalten Sie VAE-, Upscaler-, Text-Encoder-, CLIP-Vision- und ControlNet-Dateien und laden Sie sie von CivitAI herunter, alles auf einer eigenen Seite.",
|
||||||
|
"enable": "Weitere Modelle aktivieren",
|
||||||
|
"openSettings": "Einstellungen öffnen"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Copy checkpoint name",
|
"copyCheckpointName": "Copy checkpoint name",
|
||||||
"copyEmbeddingName": "Copy embedding name",
|
"copyEmbeddingName": "Copy embedding name",
|
||||||
"embeddingNameCopied": "Embedding syntax copied",
|
"embeddingNameCopied": "Embedding syntax copied",
|
||||||
|
"modelNameCopied": "Model name copied",
|
||||||
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
"sendCheckpointToWorkflow": "Send to ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
"sendEmbeddingToWorkflow": "Send to ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "Rematch recipes to local models",
|
"label": "Rematch recipes to local models",
|
||||||
"loading": "Rematching recipes to local models...",
|
"loading": "Rematching recipes to local models...",
|
||||||
"success": "Matched {entries} entries across {recipes} recipes",
|
"success": "Matched {entries} entries across {recipes} recipes",
|
||||||
"successErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
|
||||||
"allFailed": "Rematch failed for {failures} of {total} recipes",
|
|
||||||
"noMatch": "No local match found for {entries} entries in {recipes} recipes",
|
|
||||||
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
"cancelled": "Rematch cancelled. {recipes} recipes updated ({entries} entries).",
|
||||||
"error": "Recipe rematch failed: {message}"
|
"error": "Recipe rematch failed: {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "Other",
|
||||||
"statistics": "Stats"
|
"statistics": "Stats"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
"defaultUnetRootHelp": "Set default diffusion model (UNET) root directory for downloads, imports and moves",
|
||||||
"defaultEmbeddingRoot": "Embedding Root",
|
"defaultEmbeddingRoot": "Embedding Root",
|
||||||
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
"defaultEmbeddingRootHelp": "Set default embedding root directory for downloads, imports and moves",
|
||||||
|
"defaultVaeRoot": "VAE Root",
|
||||||
|
"defaultVaeRootHelp": "Set default VAE root directory for downloads, imports and moves",
|
||||||
|
"defaultUpscalerRoot": "Upscaler Root",
|
||||||
|
"defaultUpscalerRootHelp": "Set default upscaler root directory for downloads, imports and moves",
|
||||||
|
"defaultTextEncoderRoot": "Text Encoder Root",
|
||||||
|
"defaultTextEncoderRootHelp": "Set default text encoder root directory for downloads, imports and moves",
|
||||||
|
"defaultClipVisionRoot": "CLIP Vision Root",
|
||||||
|
"defaultClipVisionRootHelp": "Set default CLIP vision root directory for downloads, imports and moves",
|
||||||
|
"defaultControlnetRoot": "ControlNet Root",
|
||||||
|
"defaultControlnetRootHelp": "Set default ControlNet root directory for downloads, imports and moves",
|
||||||
|
"enableOtherModels": "Other Models Management",
|
||||||
|
"enableOtherModelsHelp": "When off, VAE / upscaler / text encoder / CLIP vision / ControlNet folders are not scanned, the Other Models page stays disabled, and these model types cannot be downloaded.",
|
||||||
|
"otherSubTypes": "Managed Types",
|
||||||
|
"otherSubTypesHelp": "Choose which other-model categories are scanned and shown on the Other Models page.",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "Recipes Storage Path",
|
"recipesPath": "Recipes Storage Path",
|
||||||
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
"recipesPathHelp": "Optional custom directory for stored recipes. Leave empty to use the first LoRA root's recipes folder.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding Models"
|
"title": "Embedding Models"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Other Models",
|
||||||
|
"disabled": {
|
||||||
|
"title": "Other Models management is off",
|
||||||
|
"description": "Enable it to scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files, and to download them from CivitAI.",
|
||||||
|
"enableButton": "Enable Other Models",
|
||||||
|
"hint": "You can change the managed model types later in Settings > Library.",
|
||||||
|
"enableFailed": "Failed to enable Other Models",
|
||||||
|
"downloadBlocked": "Other Models management is disabled for this model type. Enable it in Settings > Library to download this file.",
|
||||||
|
"enableAction": "Enable Other Models"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "No other-model folders found",
|
||||||
|
"descriptionStandalone": "Other Models management is on, but none of the configured model folders exist on disk. Add the folder paths below to settings.json and restart LoRA Manager.",
|
||||||
|
"hintStandalone": "Only the folder keys listed above are scanned; keys you do not need can be omitted.",
|
||||||
|
"descriptionComfyUI": "Other Models management is on, but none of the configured model folders exist on disk. Add the matching model folders to your ComfyUI model paths, then reload this page.",
|
||||||
|
"hintComfyUI": "Other models are read from ComfyUI's vae, upscale_models, text_encoders, clip_vision and controlnet folders.",
|
||||||
|
"openSettings": "Open Settings"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Root",
|
"modelRoot": "Root",
|
||||||
"collapseAll": "Collapse All Folders",
|
"collapseAll": "Collapse All Folders",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
|
"note": "Files will be downloaded using default path templates. This may take a while depending on the number of LoRAs.",
|
||||||
"downloadButton": "Download {count} LoRA(s)"
|
"downloadButton": "Download {count} LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "Rematch Recipes",
|
||||||
|
"messageGlobal": "All recipes will be scanned against your local model library.",
|
||||||
|
"messageSingle": "This recipe will be scanned against your local model library.",
|
||||||
|
"messageBulk": "{count} selected recipe(s) will be scanned against your local model library.",
|
||||||
|
"relaxedLabel": "Also reconnect missing models by file name",
|
||||||
|
"relaxedDescription": "These models could also be fixed by downloading — download is more accurate. Matches may link a different version; they'll be listed for review and can be undone.",
|
||||||
|
"confirmButton": "Rematch"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "Undo",
|
||||||
|
"undone": "Undone",
|
||||||
|
"undoFailed": "Failed to undo rematch: {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "Rematch Summary",
|
||||||
|
"successMessage": "Matched {entries} entries",
|
||||||
|
"failed": "Rematch failed",
|
||||||
|
"completedWithWarnings": "Rematch completed — review recommended",
|
||||||
|
"cancelledNote": "Run cancelled before completion — counts are partial.",
|
||||||
|
"statMatched": "Matched entries",
|
||||||
|
"statReview": "Needs review",
|
||||||
|
"statUnresolved": "Unresolved",
|
||||||
|
"statErrors": "Errors",
|
||||||
|
"reviewSection": "Filename matches to review ({count})",
|
||||||
|
"columnRecipe": "Recipe",
|
||||||
|
"columnEntry": "Entry",
|
||||||
|
"columnFile": "Matched file",
|
||||||
|
"columnUndo": "Undo",
|
||||||
|
"copyReport": "Copy Report",
|
||||||
|
"close": "Close",
|
||||||
|
"scope_global": "All recipes",
|
||||||
|
"scope_bulk": "Selected recipes",
|
||||||
|
"scope_single": "Single recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Local Example Images",
|
"title": "Local Example Images",
|
||||||
"message": "No local example images found for this model. View options:",
|
"message": "No local example images found for this model. View options:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Initializing Embedding Manager",
|
"title": "Initializing Embedding Manager",
|
||||||
"message": "Scanning and building embedding cache. This may take a few minutes..."
|
"message": "Scanning and building embedding cache. This may take a few minutes..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Initializing Other Models Manager",
|
||||||
|
"message": "Scanning and building model cache. This may take a few minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Initializing Recipe Manager",
|
"title": "Initializing Recipe Manager",
|
||||||
"message": "Loading and processing recipes. This may take a few minutes..."
|
"message": "Loading and processing recipes. This may take a few minutes..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "Missing required data to create recipe",
|
"createMissingData": "Missing required data to create recipe",
|
||||||
"created": "Recipe created successfully",
|
"created": "Recipe created successfully",
|
||||||
"noMissingLoras": "No missing LoRAs to download",
|
"noMissingLoras": "No missing LoRAs to download",
|
||||||
|
"unresolvableMarkedForReconnect": "{count} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.",
|
||||||
"noPreviousRecipe": "No previous recipe available",
|
"noPreviousRecipe": "No previous recipe available",
|
||||||
"noNextRecipe": "No next recipe available",
|
"noNextRecipe": "No next recipe available",
|
||||||
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
"missingLorasInfoFailed": "Failed to get information for missing LoRAs",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
"batchImportBrowseFailed": "Failed to browse directory: {message}",
|
||||||
"batchImportDirectorySelected": "Directory selected: {path}",
|
"batchImportDirectorySelected": "Directory selected: {path}",
|
||||||
"noRecipesSelected": "No recipes selected",
|
"noRecipesSelected": "No recipes selected",
|
||||||
"rematchComplete": "Matched {entries} entries across {recipes} recipes",
|
|
||||||
"rematchCompleteErrors": "Matched {entries} entries across {recipes} recipes, {failures} failed",
|
|
||||||
"rematchAllFailed": "Rematch failed for {failures} of {total} selected recipes",
|
|
||||||
"rematchUnmatched": "No local match found for {entries} entries in {recipes} recipes",
|
|
||||||
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
"rematchSkipped": "No rematch needed for any of the {total} selected recipes",
|
||||||
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
"rematchFailed": "Failed to rematch selected recipes: {message}",
|
||||||
"reimporting": "Re-importing recipe from source...",
|
"reimporting": "Re-importing recipe from source...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
"checkpointRootsFailed": "Failed to load checkpoint roots: {message}",
|
||||||
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
"unetRootsFailed": "Failed to load diffusion model roots: {message}",
|
||||||
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
"embeddingRootsFailed": "Failed to load embedding roots: {message}",
|
||||||
|
"otherRootsFailed": "Failed to load other model roots: {message}",
|
||||||
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
"mappingsUpdated": "Base model path mappings updated ({count} mapping{plural})",
|
||||||
"mappingsCleared": "Base model path mappings cleared",
|
"mappingsCleared": "Base model path mappings cleared",
|
||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "Rebuilding cache...",
|
"rebuilding": "Rebuilding cache...",
|
||||||
"rebuildFailed": "Failed to rebuild cache: {error}",
|
"rebuildFailed": "Failed to rebuild cache: {error}",
|
||||||
"retry": "Retry"
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "Other Models Management is available",
|
||||||
|
"content": "Scan and manage VAE, upscaler, text encoder, CLIP vision and ControlNet files — and download them from CivitAI — from one dedicated page.",
|
||||||
|
"enable": "Enable Other Models",
|
||||||
|
"openSettings": "Open Settings"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Copiar nombre del checkpoint",
|
"copyCheckpointName": "Copiar nombre del checkpoint",
|
||||||
"copyEmbeddingName": "Copiar nombre del embedding",
|
"copyEmbeddingName": "Copiar nombre del embedding",
|
||||||
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
"embeddingNameCopied": "Sintaxis de embedding copiada",
|
||||||
|
"modelNameCopied": "Nombre del modelo copiado",
|
||||||
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
"sendCheckpointToWorkflow": "Enviar a ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
"sendEmbeddingToWorkflow": "Enviar a ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "Reasociar recetas con modelos locales",
|
"label": "Reasociar recetas con modelos locales",
|
||||||
"loading": "Reasociando recetas con modelos locales...",
|
"loading": "Reasociando recetas con modelos locales...",
|
||||||
"success": "{entries} entradas asociadas en {recipes} recetas",
|
"success": "{entries} entradas asociadas en {recipes} recetas",
|
||||||
"successErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
|
||||||
"allFailed": "Falló la reasociación de {failures} de {total} recetas",
|
|
||||||
"noMatch": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
|
||||||
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
"cancelled": "Reasociación cancelada. {recipes} recetas actualizadas ({entries} entradas)",
|
||||||
"error": "Falló la reasociación de recetas: {message}"
|
"error": "Falló la reasociación de recetas: {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "Recetas",
|
"recipes": "Recetas",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "Otros",
|
||||||
"statistics": "Estadísticas"
|
"statistics": "Estadísticas"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
"defaultUnetRootHelp": "Establecer el directorio raíz predeterminado de Diffusion Model (UNET) para descargas, importaciones y movimientos",
|
||||||
"defaultEmbeddingRoot": "Raíz de embedding",
|
"defaultEmbeddingRoot": "Raíz de embedding",
|
||||||
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
"defaultEmbeddingRootHelp": "Establecer el directorio raíz predeterminado de embedding para descargas, importaciones y movimientos",
|
||||||
|
"defaultVaeRoot": "Raíz de VAE",
|
||||||
|
"defaultVaeRootHelp": "Establecer el directorio raíz predeterminado de VAE para descargas, importaciones y movimientos",
|
||||||
|
"defaultUpscalerRoot": "Raíz de Upscaler",
|
||||||
|
"defaultUpscalerRootHelp": "Establecer el directorio raíz predeterminado de Upscaler para descargas, importaciones y movimientos",
|
||||||
|
"defaultTextEncoderRoot": "Raíz de Text Encoder",
|
||||||
|
"defaultTextEncoderRootHelp": "Establecer el directorio raíz predeterminado de Text Encoder para descargas, importaciones y movimientos",
|
||||||
|
"defaultClipVisionRoot": "Raíz de CLIP Vision",
|
||||||
|
"defaultClipVisionRootHelp": "Establecer el directorio raíz predeterminado de CLIP Vision para descargas, importaciones y movimientos",
|
||||||
|
"defaultControlnetRoot": "Raíz de ControlNet",
|
||||||
|
"defaultControlnetRootHelp": "Establecer el directorio raíz predeterminado de ControlNet para descargas, importaciones y movimientos",
|
||||||
|
"enableOtherModels": "Gestión de otros modelos",
|
||||||
|
"enableOtherModelsHelp": "Cuando está desactivado, las carpetas VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet no se escanean, la página Otros modelos permanece desactivada y estos tipos de modelos no se pueden descargar.",
|
||||||
|
"otherSubTypes": "Tipos de modelos gestionados",
|
||||||
|
"otherSubTypesHelp": "Elige qué categorías de otros modelos se escanean y se muestran en la página Otros modelos.",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "Ruta de almacenamiento de recetas",
|
"recipesPath": "Ruta de almacenamiento de recetas",
|
||||||
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
"recipesPathHelp": "Directorio personalizado opcional para las recetas guardadas. Déjalo vacío para usar la carpeta recipes del primer directorio raíz de LoRA.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modelos embedding"
|
"title": "Modelos embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Otros modelos",
|
||||||
|
"disabled": {
|
||||||
|
"title": "La gestión de otros modelos está desactivada",
|
||||||
|
"description": "Actívala para escanear y gestionar archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y para descargarlos desde CivitAI.",
|
||||||
|
"enableButton": "Activar otros modelos",
|
||||||
|
"hint": "Puedes cambiar los tipos de modelos gestionados más adelante en Configuración > Biblioteca.",
|
||||||
|
"enableFailed": "No se pudieron activar los otros modelos",
|
||||||
|
"downloadBlocked": "La gestión de otros modelos está desactivada para este tipo de modelo. Actívala en Configuración > Biblioteca para descargar este archivo.",
|
||||||
|
"enableAction": "Activar otros modelos"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "No se encontraron carpetas de otros modelos",
|
||||||
|
"descriptionStandalone": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las rutas de carpetas de abajo a settings.json y reinicia LoRA Manager.",
|
||||||
|
"hintStandalone": "Solo se escanean las claves de carpeta listadas arriba; las claves que no necesites puedes omitirlas.",
|
||||||
|
"descriptionComfyUI": "La gestión de otros modelos está activada, pero ninguna de las carpetas de modelos configuradas existe en el disco. Añade las carpetas de modelos correspondientes a tus rutas de modelos de ComfyUI y recarga esta página.",
|
||||||
|
"hintComfyUI": "Los otros modelos se leen de las carpetas vae, upscale_models, text_encoders, clip_vision y controlnet de ComfyUI.",
|
||||||
|
"openSettings": "Abrir configuración"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Raíz",
|
"modelRoot": "Raíz",
|
||||||
"collapseAll": "Colapsar todas las carpetas",
|
"collapseAll": "Colapsar todas las carpetas",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
|
"note": "Los archivos se descargarán usando las plantillas de ruta predeterminadas. Esto puede tomar un tiempo dependiendo del número de LoRAs.",
|
||||||
"downloadButton": "Descargar {count} LoRA(s)"
|
"downloadButton": "Descargar {count} LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "Reasociar recetas",
|
||||||
|
"messageGlobal": "Se escanearán todas las recetas contra tu biblioteca local de modelos.",
|
||||||
|
"messageSingle": "Se escaneará esta receta contra tu biblioteca local de modelos.",
|
||||||
|
"messageBulk": "Se escanearán {count} receta(s) seleccionada(s) contra tu biblioteca local de modelos.",
|
||||||
|
"relaxedLabel": "Reconectar también los modelos faltantes por nombre de archivo",
|
||||||
|
"relaxedDescription": "Estos modelos también se pueden corregir descargándolos; la descarga es más precisa. Las coincidencias pueden enlazar una versión diferente; se listarán para su revisión y se pueden deshacer.",
|
||||||
|
"confirmButton": "Reasociar"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "Deshacer",
|
||||||
|
"undone": "Deshecho",
|
||||||
|
"undoFailed": "No se pudo deshacer la reasociación: {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "Resumen de la reasociación",
|
||||||
|
"successMessage": "{entries} entradas asociadas",
|
||||||
|
"failed": "Falló la reasociación",
|
||||||
|
"completedWithWarnings": "Reasociación completada — se recomienda revisar",
|
||||||
|
"cancelledNote": "Ejecución cancelada antes de completarse — los recuentos son parciales.",
|
||||||
|
"statMatched": "Entradas asociadas",
|
||||||
|
"statReview": "Por revisar",
|
||||||
|
"statUnresolved": "Sin coincidencia",
|
||||||
|
"statErrors": "Errores",
|
||||||
|
"reviewSection": "Coincidencias por nombre de archivo para revisar ({count})",
|
||||||
|
"columnRecipe": "Receta",
|
||||||
|
"columnEntry": "Entrada",
|
||||||
|
"columnFile": "Archivo coincidente",
|
||||||
|
"columnUndo": "Deshacer",
|
||||||
|
"copyReport": "Copiar informe",
|
||||||
|
"close": "Cerrar",
|
||||||
|
"scope_global": "Todas las recetas",
|
||||||
|
"scope_bulk": "Recetas seleccionadas",
|
||||||
|
"scope_single": "Receta individual"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Imágenes de ejemplo locales",
|
"title": "Imágenes de ejemplo locales",
|
||||||
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
"message": "No se encontraron imágenes de ejemplo locales para este modelo. Opciones de visualización:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Inicializando gestor de embedding",
|
"title": "Inicializando gestor de embedding",
|
||||||
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
"message": "Escaneando y construyendo caché de embedding. Esto puede tomar unos minutos..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Inicializando el gestor de otros modelos",
|
||||||
|
"message": "Escaneando y construyendo la caché de modelos. Esto puede tomar unos minutos..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Inicializando gestor de recetas",
|
"title": "Inicializando gestor de recetas",
|
||||||
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
"message": "Cargando y procesando recetas. Esto puede tomar unos minutos..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "Faltan datos necesarios para crear la receta",
|
"createMissingData": "Faltan datos necesarios para crear la receta",
|
||||||
"created": "Receta creada exitosamente",
|
"created": "Receta creada exitosamente",
|
||||||
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
"noMissingLoras": "No hay LoRAs faltantes para descargar",
|
||||||
|
"unresolvableMarkedForReconnect": "Se marcaron {count} entrada(s) no resoluble(s) — ahora se pueden reconectar a un LoRA local.",
|
||||||
"noPreviousRecipe": "No hay receta anterior disponible",
|
"noPreviousRecipe": "No hay receta anterior disponible",
|
||||||
"noNextRecipe": "No hay siguiente receta disponible",
|
"noNextRecipe": "No hay siguiente receta disponible",
|
||||||
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
"batchImportBrowseFailed": "No se pudo examinar el directorio: {message}",
|
||||||
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
"batchImportDirectorySelected": "Directorio seleccionado: {path}",
|
||||||
"noRecipesSelected": "No se han seleccionado recetas",
|
"noRecipesSelected": "No se han seleccionado recetas",
|
||||||
"rematchComplete": "{entries} entradas asociadas en {recipes} recetas",
|
|
||||||
"rematchCompleteErrors": "{entries} entradas asociadas en {recipes} recetas, {failures} fallidas",
|
|
||||||
"rematchAllFailed": "Falló la reasociación de {failures} de {total} recetas seleccionadas",
|
|
||||||
"rematchUnmatched": "No se encontró coincidencia local para {entries} entradas en {recipes} recetas",
|
|
||||||
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
"rematchSkipped": "Ninguna de las {total} recetas seleccionadas necesita reasociación",
|
||||||
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
"rematchFailed": "Falló la reasociación de las recetas seleccionadas: {message}",
|
||||||
"reimporting": "Reimportando receta desde origen...",
|
"reimporting": "Reimportando receta desde origen...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
"checkpointRootsFailed": "Error al cargar raíces de checkpoint: {message}",
|
||||||
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
"unetRootsFailed": "Error al cargar raíces de Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
"embeddingRootsFailed": "Error al cargar raíces de embedding: {message}",
|
||||||
|
"otherRootsFailed": "Error al cargar raíces de otros modelos: {message}",
|
||||||
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
"mappingsUpdated": "Mapeos de rutas de modelo base actualizados ({count} mapeo{plural})",
|
||||||
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
"mappingsCleared": "Mapeos de rutas de modelo base limpiados",
|
||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "Reconstruyendo caché...",
|
"rebuilding": "Reconstruyendo caché...",
|
||||||
"rebuildFailed": "Error al reconstruir la caché: {error}",
|
"rebuildFailed": "Error al reconstruir la caché: {error}",
|
||||||
"retry": "Reintentar"
|
"retry": "Reintentar"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "La gestión de otros modelos ya está disponible",
|
||||||
|
"content": "Escanea y gestiona archivos VAE, Upscaler, Text Encoder, CLIP Vision y ControlNet, y descárgalos desde CivitAI, todo desde una página dedicada.",
|
||||||
|
"enable": "Activar otros modelos",
|
||||||
|
"openSettings": "Abrir configuración"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Copier le nom du checkpoint",
|
"copyCheckpointName": "Copier le nom du checkpoint",
|
||||||
"copyEmbeddingName": "Copier le nom de l'embedding",
|
"copyEmbeddingName": "Copier le nom de l'embedding",
|
||||||
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
"embeddingNameCopied": "Syntaxe dembedding copiée",
|
||||||
|
"modelNameCopied": "Nom du modèle copié",
|
||||||
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
"sendCheckpointToWorkflow": "Envoyer vers ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
"sendEmbeddingToWorkflow": "Envoyer vers ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "Réassocier les Recipes aux modèles locaux",
|
"label": "Réassocier les Recipes aux modèles locaux",
|
||||||
"loading": "Réassociation des Recipes aux modèles locaux...",
|
"loading": "Réassociation des Recipes aux modèles locaux...",
|
||||||
"success": "{entries} entrées associées dans {recipes} Recipes",
|
"success": "{entries} entrées associées dans {recipes} Recipes",
|
||||||
"successErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
|
||||||
"allFailed": "Échec de la réassociation de {failures} Recipes sur {total}",
|
|
||||||
"noMatch": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
|
||||||
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
"cancelled": "Réassociation annulée. {recipes} Recipes mises à jour ({entries} entrées)",
|
||||||
"error": "Échec de la réassociation des Recipes : {message}"
|
"error": "Échec de la réassociation des Recipes : {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "Recipes",
|
"recipes": "Recipes",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "Autres",
|
||||||
"statistics": "Statistiques"
|
"statistics": "Statistiques"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
"defaultUnetRootHelp": "Définir le répertoire racine Diffusion Model (UNET) par défaut pour les téléchargements, imports et déplacements",
|
||||||
"defaultEmbeddingRoot": "Racine Embedding",
|
"defaultEmbeddingRoot": "Racine Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
"defaultEmbeddingRootHelp": "Définir le répertoire racine embedding par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultVaeRoot": "Racine VAE",
|
||||||
|
"defaultVaeRootHelp": "Définir le répertoire racine VAE par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultUpscalerRoot": "Racine Upscaler",
|
||||||
|
"defaultUpscalerRootHelp": "Définir le répertoire racine Upscaler par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultTextEncoderRoot": "Racine Text Encoder",
|
||||||
|
"defaultTextEncoderRootHelp": "Définir le répertoire racine Text Encoder par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultClipVisionRoot": "Racine CLIP Vision",
|
||||||
|
"defaultClipVisionRootHelp": "Définir le répertoire racine CLIP Vision par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"defaultControlnetRoot": "Racine ControlNet",
|
||||||
|
"defaultControlnetRootHelp": "Définir le répertoire racine ControlNet par défaut pour les téléchargements, imports et déplacements",
|
||||||
|
"enableOtherModels": "Gestion des autres modèles",
|
||||||
|
"enableOtherModelsHelp": "Lorsque cette option est désactivée, les dossiers VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet ne sont pas analysés, la page Autres modèles reste désactivée et ces types de modèles ne peuvent pas être téléchargés.",
|
||||||
|
"otherSubTypes": "Types de modèles gérés",
|
||||||
|
"otherSubTypesHelp": "Choisissez les catégories d’autres modèles analysées et affichées sur la page Autres modèles.",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "Chemin de stockage des Recipes",
|
"recipesPath": "Chemin de stockage des Recipes",
|
||||||
"recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.",
|
"recipesPathHelp": "Dossier personnalisé facultatif pour les Recipes enregistrées. Laissez vide pour utiliser le dossier recipes de la première racine LoRA.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Modèles Embedding"
|
"title": "Modèles Embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Autres modèles",
|
||||||
|
"disabled": {
|
||||||
|
"title": "La gestion des autres modèles est désactivée",
|
||||||
|
"description": "Activez-la pour analyser et gérer les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et pour les télécharger depuis CivitAI.",
|
||||||
|
"enableButton": "Activer les autres modèles",
|
||||||
|
"hint": "Vous pourrez modifier les types de modèles gérés plus tard dans Paramètres > Bibliothèque.",
|
||||||
|
"enableFailed": "Échec de l’activation des autres modèles",
|
||||||
|
"downloadBlocked": "La gestion des autres modèles est désactivée pour ce type de modèle. Activez-la dans Paramètres > Bibliothèque pour télécharger ce fichier.",
|
||||||
|
"enableAction": "Activer les autres modèles"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "Aucun dossier d’autres modèles trouvé",
|
||||||
|
"descriptionStandalone": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les chemins de dossiers ci-dessous à settings.json, puis redémarrez LoRA Manager.",
|
||||||
|
"hintStandalone": "Seules les clés de dossiers listées ci-dessus sont analysées ; les clés inutiles peuvent être omises.",
|
||||||
|
"descriptionComfyUI": "La gestion des autres modèles est activée, mais aucun des dossiers de modèles configurés n’existe sur le disque. Ajoutez les dossiers de modèles correspondants à vos chemins de modèles ComfyUI, puis rechargez cette page.",
|
||||||
|
"hintComfyUI": "Les autres modèles sont lus depuis les dossiers vae, upscale_models, text_encoders, clip_vision et controlnet de ComfyUI.",
|
||||||
|
"openSettings": "Ouvrir les paramètres"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Racine",
|
"modelRoot": "Racine",
|
||||||
"collapseAll": "Réduire tous les dossiers",
|
"collapseAll": "Réduire tous les dossiers",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
|
"note": "Les fichiers seront téléchargés en utilisant les modèles de chemins par défaut. Cela peut prendre un certain temps selon le nombre de LoRAs.",
|
||||||
"downloadButton": "Télécharger {count} LoRA(s)"
|
"downloadButton": "Télécharger {count} LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "Réassocier les Recipes",
|
||||||
|
"messageGlobal": "Toutes les Recipes seront analysées par rapport à votre bibliothèque de modèles locale.",
|
||||||
|
"messageSingle": "Cette Recipe sera analysée par rapport à votre bibliothèque de modèles locale.",
|
||||||
|
"messageBulk": "{count} Recipes sélectionnées seront analysées par rapport à votre bibliothèque de modèles locale.",
|
||||||
|
"relaxedLabel": "Reconnecter aussi les modèles manquants par nom de fichier",
|
||||||
|
"relaxedDescription": "Ces modèles peuvent aussi être corrigés par téléchargement — le téléchargement est plus précis. Les correspondances peuvent associer une version différente ; elles seront listées pour vérification et peuvent être annulées.",
|
||||||
|
"confirmButton": "Réassocier"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "Annuler",
|
||||||
|
"undone": "Annulé",
|
||||||
|
"undoFailed": "Échec de l'annulation de la réassociation : {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "Résumé de la réassociation",
|
||||||
|
"successMessage": "{entries} entrées associées",
|
||||||
|
"failed": "Échec de la réassociation",
|
||||||
|
"completedWithWarnings": "Réassociation terminée — vérification recommandée",
|
||||||
|
"cancelledNote": "Exécution annulée avant la fin — les décomptes sont partiels.",
|
||||||
|
"statMatched": "Entrées associées",
|
||||||
|
"statReview": "À vérifier",
|
||||||
|
"statUnresolved": "Sans correspondance",
|
||||||
|
"statErrors": "Erreurs",
|
||||||
|
"reviewSection": "Correspondances par nom de fichier à vérifier ({count})",
|
||||||
|
"columnRecipe": "Recipe",
|
||||||
|
"columnEntry": "Entrée",
|
||||||
|
"columnFile": "Fichier correspondant",
|
||||||
|
"columnUndo": "Annuler",
|
||||||
|
"copyReport": "Copier le rapport",
|
||||||
|
"close": "Fermer",
|
||||||
|
"scope_global": "Toutes les Recipes",
|
||||||
|
"scope_bulk": "Recipes sélectionnées",
|
||||||
|
"scope_single": "Une seule Recipe"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Images d'exemple locales",
|
"title": "Images d'exemple locales",
|
||||||
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
"message": "Aucune image d'exemple locale trouvée pour ce modèle. Options d'affichage :",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Initialisation du gestionnaire Embedding",
|
"title": "Initialisation du gestionnaire Embedding",
|
||||||
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
"message": "Scan et construction du cache embedding. Cela peut prendre quelques minutes..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Initialisation du gestionnaire Autres modèles",
|
||||||
|
"message": "Analyse et construction du cache de modèles. Cela peut prendre quelques minutes..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Initialisation du gestionnaire de recipes",
|
"title": "Initialisation du gestionnaire de recipes",
|
||||||
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
"message": "Chargement et traitement des recipes. Cela peut prendre quelques minutes..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
"createMissingData": "Données requises manquantes pour créer le Recipe",
|
||||||
"created": "Recipe créé avec succès",
|
"created": "Recipe créé avec succès",
|
||||||
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
"noMissingLoras": "Aucun LoRA manquant à télécharger",
|
||||||
|
"unresolvableMarkedForReconnect": "{count} entrées irrésolubles marquées — elles peuvent maintenant être reconnectées à un LoRA local.",
|
||||||
"noPreviousRecipe": "Aucune Recipe précédente",
|
"noPreviousRecipe": "Aucune Recipe précédente",
|
||||||
"noNextRecipe": "Aucune Recipe suivante",
|
"noNextRecipe": "Aucune Recipe suivante",
|
||||||
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
"batchImportBrowseFailed": "Échec de la navigation dans le dossier : {message}",
|
||||||
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
"batchImportDirectorySelected": "Dossier sélectionné : {path}",
|
||||||
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
"noRecipesSelected": "Aucune Recipe sélectionnée",
|
||||||
"rematchComplete": "{entries} entrées associées dans {recipes} Recipes",
|
|
||||||
"rematchCompleteErrors": "{entries} entrées associées dans {recipes} Recipes, {failures} échecs",
|
|
||||||
"rematchAllFailed": "Échec de la réassociation de {failures} Recipes sélectionnées sur {total}",
|
|
||||||
"rematchUnmatched": "Aucune correspondance locale trouvée pour {entries} entrées dans {recipes} Recipes",
|
|
||||||
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
"rematchSkipped": "Aucune des {total} Recipes sélectionnées ne nécessite de réassociation",
|
||||||
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
"rematchFailed": "Échec de la réassociation des Recipes sélectionnées : {message}",
|
||||||
"reimporting": "Ré-import de la Recipe depuis la source...",
|
"reimporting": "Ré-import de la Recipe depuis la source...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
"checkpointRootsFailed": "Échec du chargement des racines checkpoint : {message}",
|
||||||
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
"unetRootsFailed": "Échec du chargement des racines Diffusion Model : {message}",
|
||||||
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
"embeddingRootsFailed": "Échec du chargement des racines embedding : {message}",
|
||||||
|
"otherRootsFailed": "Échec du chargement des racines des autres modèles : {message}",
|
||||||
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
"mappingsUpdated": "Mappages de chemin de modèle de base mis à jour ({count} mappage{plural})",
|
||||||
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
"mappingsCleared": "Mappages de chemin de modèle de base effacés",
|
||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "Reconstruction du cache...",
|
"rebuilding": "Reconstruction du cache...",
|
||||||
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
|
"rebuildFailed": "Échec de la reconstruction du cache : {error}",
|
||||||
"retry": "Réessayer"
|
"retry": "Réessayer"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "La gestion des autres modèles est disponible",
|
||||||
|
"content": "Analysez et gérez les fichiers VAE, Upscaler, Text Encoder, CLIP Vision et ControlNet, et téléchargez-les depuis CivitAI, le tout depuis une page dédiée.",
|
||||||
|
"enable": "Activer les autres modèles",
|
||||||
|
"openSettings": "Ouvrir les paramètres"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "העתק שם Checkpoint",
|
"copyCheckpointName": "העתק שם Checkpoint",
|
||||||
"copyEmbeddingName": "העתק שם Embedding",
|
"copyEmbeddingName": "העתק שם Embedding",
|
||||||
"embeddingNameCopied": "תחביר Embedding הועתק",
|
"embeddingNameCopied": "תחביר Embedding הועתק",
|
||||||
|
"modelNameCopied": "שם המודל הועתק",
|
||||||
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
"sendCheckpointToWorkflow": "שלח ל-ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
"sendEmbeddingToWorkflow": "שלח ל-ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
"label": "התאמה מחדש של מתכונים למודלים מקומיים",
|
||||||
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
"loading": "מתבצעת התאמה מחדש של מתכונים למודלים מקומיים...",
|
||||||
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
"success": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
||||||
"successErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
|
||||||
"allFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים",
|
|
||||||
"noMatch": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
|
||||||
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
"cancelled": "ההתאמה בוטלה. עודכנו {recipes} מתכונים ({entries} פריטים)",
|
||||||
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
"error": "ההתאמה מחדש של המתכונים נכשלה: {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "מתכונים",
|
"recipes": "מתכונים",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "אחרים",
|
||||||
"statistics": "סטטיסטיקה"
|
"statistics": "סטטיסטיקה"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
"defaultUnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Diffusion Model (UNET) להורדות, ייבוא והעברות",
|
||||||
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
"defaultEmbeddingRoot": "תיקיית שורש Embedding",
|
||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
|
"defaultVaeRoot": "תיקיית שורש VAE",
|
||||||
|
"defaultVaeRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של VAE להורדות, ייבוא והעברות",
|
||||||
|
"defaultUpscalerRoot": "תיקיית שורש Upscaler",
|
||||||
|
"defaultUpscalerRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Upscaler להורדות, ייבוא והעברות",
|
||||||
|
"defaultTextEncoderRoot": "תיקיית שורש Text Encoder",
|
||||||
|
"defaultTextEncoderRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של Text Encoder להורדות, ייבוא והעברות",
|
||||||
|
"defaultClipVisionRoot": "תיקיית שורש CLIP Vision",
|
||||||
|
"defaultClipVisionRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של CLIP Vision להורדות, ייבוא והעברות",
|
||||||
|
"defaultControlnetRoot": "תיקיית שורש ControlNet",
|
||||||
|
"defaultControlnetRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של ControlNet להורדות, ייבוא והעברות",
|
||||||
|
"enableOtherModels": "ניהול מודלים אחרים",
|
||||||
|
"enableOtherModelsHelp": "כשהאפשרות כבויה, תיקיות VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet אינן נסרקות, עמוד המודלים האחרים נשאר מושבת ולא ניתן להוריד סוגי מודלים אלה.",
|
||||||
|
"otherSubTypes": "סוגי מודלים מנוהלים",
|
||||||
|
"otherSubTypesHelp": "בחר אילו קטגוריות של מודלים אחרים ייסרקו ויוצגו בעמוד המודלים האחרים.",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "נתיב אחסון מתכונים",
|
"recipesPath": "נתיב אחסון מתכונים",
|
||||||
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
"recipesPathHelp": "ספרייה מותאמת אישית אופציונלית למתכונים שנשמרו. השאר ריק כדי להשתמש בתיקיית recipes של שורש LoRA הראשון.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "מודלי Embedding"
|
"title": "מודלי Embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "מודלים אחרים",
|
||||||
|
"disabled": {
|
||||||
|
"title": "ניהול המודלים האחרים כבוי",
|
||||||
|
"description": "הפעל כדי לסרוק ולנהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, ולהוריד אותם מ-CivitAI.",
|
||||||
|
"enableButton": "הפעל מודלים אחרים",
|
||||||
|
"hint": "ניתן לשנות את סוגי המודלים המנוהלים מאוחר יותר בהגדרות > ספרייה.",
|
||||||
|
"enableFailed": "הפעלת המודלים האחרים נכשלה",
|
||||||
|
"downloadBlocked": "ניהול המודלים האחרים מושבת עבור סוג מודל זה. הפעל אותו בהגדרות > ספרייה כדי להוריד קובץ זה.",
|
||||||
|
"enableAction": "הפעל מודלים אחרים"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "לא נמצאו תיקיות של מודלים אחרים",
|
||||||
|
"descriptionStandalone": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את נתיבי התיקיות שלמטה ל-settings.json והפעל מחדש את LoRA Manager.",
|
||||||
|
"hintStandalone": "רק מפתחות התיקיות המפורטים למעלה נסרקים; ניתן להשמיט מפתחות שאינך צריך.",
|
||||||
|
"descriptionComfyUI": "ניהול המודלים האחרים פועל, אך אף אחת מתיקיות המודלים המוגדרות אינה קיימת בדיסק. הוסף את תיקיות המודלים המתאימות לנתיבי המודלים של ComfyUI וטען מחדש עמוד זה.",
|
||||||
|
"hintComfyUI": "מודלים אחרים נקראים מתיקיות vae, upscale_models, text_encoders, clip_vision ו-controlnet של ComfyUI.",
|
||||||
|
"openSettings": "פתח הגדרות"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "שורש",
|
"modelRoot": "שורש",
|
||||||
"collapseAll": "כווץ את כל התיקיות",
|
"collapseAll": "כווץ את כל התיקיות",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
"note": "הקבצים יורדו באמצעות תבניות נתיב ברירת מחדל. זה עשוי לקחת זמן בהתאם למספר ה-LoRAs.",
|
||||||
"downloadButton": "הורד {count} LoRA(s)"
|
"downloadButton": "הורד {count} LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "התאמה מחדש של מתכונים",
|
||||||
|
"messageGlobal": "כל המתכונים ייסרקו מול ספריית המודלים המקומית שלך.",
|
||||||
|
"messageSingle": "מתכון זה ייסרק מול ספריית המודלים המקומית שלך.",
|
||||||
|
"messageBulk": "{count} מתכונים שנבחרו ייסרקו מול ספריית המודלים המקומית שלך.",
|
||||||
|
"relaxedLabel": "חבר מחדש גם מודלים חסרים לפי שם קובץ",
|
||||||
|
"relaxedDescription": "אפשר לתקן את המודלים האלה גם על ידי הורדה — ההורדה מדויקת יותר. ההתאמות עשויות לקשר לגרסה אחרת; הן יוצגו לסקירה וניתן לבטל אותן.",
|
||||||
|
"confirmButton": "התאם מחדש"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "בטל",
|
||||||
|
"undone": "בוטל",
|
||||||
|
"undoFailed": "ביטול ההתאמה מחדש נכשל: {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "סיכום התאמה מחדש",
|
||||||
|
"successMessage": "הותאמו {entries} פריטים",
|
||||||
|
"failed": "ההתאמה מחדש נכשלה",
|
||||||
|
"completedWithWarnings": "ההתאמה מחדש הושלמה — מומלץ לסקור",
|
||||||
|
"cancelledNote": "ההתאמה בוטלה לפני שהסתיימה — המספרים חלקיים.",
|
||||||
|
"statMatched": "פריטים שהותאמו",
|
||||||
|
"statReview": "טעוני סקירה",
|
||||||
|
"statUnresolved": "ללא התאמה",
|
||||||
|
"statErrors": "שגיאות",
|
||||||
|
"reviewSection": "התאמות לפי שם קובץ לסקירה ({count})",
|
||||||
|
"columnRecipe": "מתכון",
|
||||||
|
"columnEntry": "פריט",
|
||||||
|
"columnFile": "הקובץ שהותאם",
|
||||||
|
"columnUndo": "בטל",
|
||||||
|
"copyReport": "העתק דוח",
|
||||||
|
"close": "סגור",
|
||||||
|
"scope_global": "כל המתכונים",
|
||||||
|
"scope_bulk": "מתכונים שנבחרו",
|
||||||
|
"scope_single": "מתכון בודד"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "תמונות דוגמה מקומיות",
|
"title": "תמונות דוגמה מקומיות",
|
||||||
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
"message": "לא נמצאו תמונות דוגמה מקומיות למודל זה. אפשרויות צפייה:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "מאתחל מנהל Embedding",
|
"title": "מאתחל מנהל Embedding",
|
||||||
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
"message": "סורק ובונה מטמון embedding. זה עשוי לקחת מספר דקות..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "מאתחל את מנהל המודלים האחרים",
|
||||||
|
"message": "סורק ובונה מטמון מודלים. זה עשוי לקחת מספר דקות..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "מאתחל מנהל מתכונים",
|
"title": "מאתחל מנהל מתכונים",
|
||||||
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
"message": "טוען ומעבד מתכונים. זה עשוי לקחת מספר דקות..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
|
||||||
"created": "המתכון נוצר בהצלחה",
|
"created": "המתכון נוצר בהצלחה",
|
||||||
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
"noMissingLoras": "אין LoRAs חסרים להורדה",
|
||||||
|
"unresolvableMarkedForReconnect": "{count} פריטים שלא ניתן לפתור סומנו — עכשיו ניתן לחבר אותם מחדש ל-LoRA מקומי.",
|
||||||
"noPreviousRecipe": "אין מתכון קודם זמין",
|
"noPreviousRecipe": "אין מתכון קודם זמין",
|
||||||
"noNextRecipe": "אין מתכון נוסף זמין",
|
"noNextRecipe": "אין מתכון נוסף זמין",
|
||||||
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
"batchImportBrowseFailed": "לא ניתן היה לעיין בתיקייה: {message}",
|
||||||
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
"batchImportDirectorySelected": "נבחרה תיקייה: {path}",
|
||||||
"noRecipesSelected": "לא נבחרו מתכונים",
|
"noRecipesSelected": "לא נבחרו מתכונים",
|
||||||
"rematchComplete": "הותאמו {entries} פריטים ב־{recipes} מתכונים",
|
|
||||||
"rematchCompleteErrors": "הותאמו {entries} פריטים ב־{recipes} מתכונים, {failures} נכשלו",
|
|
||||||
"rematchAllFailed": "ההתאמה נכשלה עבור {failures} מתוך {total} מתכונים שנבחרו",
|
|
||||||
"rematchUnmatched": "לא נמצאה התאמה מקומית עבור {entries} פריטים ב־{recipes} מתכונים",
|
|
||||||
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
"rematchSkipped": "אין צורך בהתאמה עבור {total} המתכונים שנבחרו",
|
||||||
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
"rematchFailed": "ההתאמה מחדש של המתכונים שנבחרו נכשלה: {message}",
|
||||||
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
"reimporting": "מייבא מתכון מחדש מהמקור...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
"checkpointRootsFailed": "טעינת שורשי checkpoint נכשלה: {message}",
|
||||||
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
"unetRootsFailed": "טעינת שורשי Diffusion Model נכשלה: {message}",
|
||||||
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
"embeddingRootsFailed": "טעינת שורשי embedding נכשלה: {message}",
|
||||||
|
"otherRootsFailed": "טעינת שורשי המודלים האחרים נכשלה: {message}",
|
||||||
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
"mappingsUpdated": "מיפויי נתיבי מודל בסיס עודכנו ({count})",
|
||||||
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
"mappingsCleared": "מיפויי נתיבי מודל בסיס נוקו",
|
||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "בונה מחדש את המטמון...",
|
"rebuilding": "בונה מחדש את המטמון...",
|
||||||
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
"rebuildFailed": "נכשלה בניית המטמון מחדש: {error}",
|
||||||
"retry": "נסה שוב"
|
"retry": "נסה שוב"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "ניהול המודלים האחרים זמין",
|
||||||
|
"content": "סרוק ונהל קבצי VAE, Upscaler, Text Encoder, CLIP Vision ו-ControlNet, והורד אותם מ-CivitAI — מהעמוד הייעודי.",
|
||||||
|
"enable": "הפעל מודלים אחרים",
|
||||||
|
"openSettings": "פתח הגדרות"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Checkpoint名をコピー",
|
"copyCheckpointName": "Checkpoint名をコピー",
|
||||||
"copyEmbeddingName": "embedding名をコピー",
|
"copyEmbeddingName": "embedding名をコピー",
|
||||||
"embeddingNameCopied": "Embedding構文をコピーしました",
|
"embeddingNameCopied": "Embedding構文をコピーしました",
|
||||||
|
"modelNameCopied": "モデル名をコピーしました",
|
||||||
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
"sendCheckpointToWorkflow": "ComfyUIに送信",
|
||||||
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
"sendEmbeddingToWorkflow": "ComfyUIに送信"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "レシピをローカルモデルに再マッチング",
|
"label": "レシピをローカルモデルに再マッチング",
|
||||||
"loading": "レシピをローカルモデルに再マッチングしています...",
|
"loading": "レシピをローカルモデルに再マッチングしています...",
|
||||||
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
"success": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
||||||
"successErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
|
||||||
"allFailed": "{total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
|
||||||
"noMatch": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
|
||||||
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
"cancelled": "再マッチングをキャンセルしました。{recipes} 件のレシピを更新({entries} エントリ)",
|
||||||
"error": "レシピの再マッチングに失敗しました:{message}"
|
"error": "レシピの再マッチングに失敗しました:{message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "レシピ",
|
"recipes": "レシピ",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "その他",
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
"defaultUnetRootHelp": "ダウンロード、インポート、移動用のデフォルトDiffusion Model (UNET)ルートディレクトリを設定",
|
||||||
"defaultEmbeddingRoot": "Embeddingルート",
|
"defaultEmbeddingRoot": "Embeddingルート",
|
||||||
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
"defaultEmbeddingRootHelp": "ダウンロード、インポート、移動用のデフォルトembeddingルートディレクトリを設定",
|
||||||
|
"defaultVaeRoot": "VAEルート",
|
||||||
|
"defaultVaeRootHelp": "ダウンロード、インポート、移動用のデフォルトVAEルートディレクトリを設定",
|
||||||
|
"defaultUpscalerRoot": "Upscalerルート",
|
||||||
|
"defaultUpscalerRootHelp": "ダウンロード、インポート、移動用のデフォルトUpscalerルートディレクトリを設定",
|
||||||
|
"defaultTextEncoderRoot": "Text Encoderルート",
|
||||||
|
"defaultTextEncoderRootHelp": "ダウンロード、インポート、移動用のデフォルトText Encoderルートディレクトリを設定",
|
||||||
|
"defaultClipVisionRoot": "CLIP Visionルート",
|
||||||
|
"defaultClipVisionRootHelp": "ダウンロード、インポート、移動用のデフォルトCLIP Visionルートディレクトリを設定",
|
||||||
|
"defaultControlnetRoot": "ControlNetルート",
|
||||||
|
"defaultControlnetRootHelp": "ダウンロード、インポート、移動用のデフォルトControlNetルートディレクトリを設定",
|
||||||
|
"enableOtherModels": "その他のモデル管理",
|
||||||
|
"enableOtherModelsHelp": "オフにすると、VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet フォルダーはスキャンされず、その他のモデルページは無効のままになり、これらのモデルタイプはダウンロードできません。",
|
||||||
|
"otherSubTypes": "管理するモデルタイプ",
|
||||||
|
"otherSubTypesHelp": "その他のモデルページでスキャンおよび表示するカテゴリを選択します。",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "レシピ保存先",
|
"recipesPath": "レシピ保存先",
|
||||||
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
"recipesPathHelp": "保存済みレシピ用の任意のカスタムディレクトリです。空欄にすると最初のLoRAルートのrecipesフォルダーを使用します。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embeddingモデル"
|
"title": "Embeddingモデル"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "その他のモデル",
|
||||||
|
"disabled": {
|
||||||
|
"title": "その他のモデル管理はオフです",
|
||||||
|
"description": "有効にすると VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||||
|
"enableButton": "その他のモデルを有効にする",
|
||||||
|
"hint": "管理するモデルタイプは後で「設定 > ライブラリ」で変更できます。",
|
||||||
|
"enableFailed": "その他のモデルの有効化に失敗しました",
|
||||||
|
"downloadBlocked": "このモデルタイプではその他のモデル管理が無効です。このファイルをダウンロードするには「設定 > ライブラリ」で有効にしてください。",
|
||||||
|
"enableAction": "その他のモデルを有効にする"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "その他のモデルのフォルダーが見つかりません",
|
||||||
|
"descriptionStandalone": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。以下のフォルダーパスをsettings.jsonに追加し、LoRA Managerを再起動してください。",
|
||||||
|
"hintStandalone": "スキャンされるのは上記のフォルダーキーのみです。不要なキーは省略できます。",
|
||||||
|
"descriptionComfyUI": "その他のモデル管理はオンですが、設定されたモデルフォルダーがディスク上に存在しません。該当するモデルフォルダーをComfyUIのモデルパスに追加し、このページを再読み込みしてください。",
|
||||||
|
"hintComfyUI": "その他のモデルは、ComfyUIのvae、upscale_models、text_encoders、clip_vision、controlnetフォルダーから読み込まれます。",
|
||||||
|
"openSettings": "設定を開く"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "ルート",
|
"modelRoot": "ルート",
|
||||||
"collapseAll": "すべてのフォルダを折りたたむ",
|
"collapseAll": "すべてのフォルダを折りたたむ",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
"note": "ファイルはデフォルトのパステンプレートを使用してダウンロードされます。LoRA の数によっては時間がかかる場合があります。",
|
||||||
"downloadButton": "{count} 個の LoRA をダウンロード"
|
"downloadButton": "{count} 個の LoRA をダウンロード"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "レシピの再マッチング",
|
||||||
|
"messageGlobal": "すべてのレシピをローカルのモデルライブラリと照合します。",
|
||||||
|
"messageSingle": "このレシピをローカルのモデルライブラリと照合します。",
|
||||||
|
"messageBulk": "選択した {count} 件のレシピをローカルのモデルライブラリと照合します。",
|
||||||
|
"relaxedLabel": "見つからないモデルもファイル名で再接続する",
|
||||||
|
"relaxedDescription": "これらのモデルはダウンロードでも修正できます(ダウンロードの方が正確です)。マッチにより別バージョンが関連付けられる場合があります。マッチした項目は確認用に一覧表示され、元に戻すことができます。",
|
||||||
|
"confirmButton": "再マッチング"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "元に戻す",
|
||||||
|
"undone": "元に戻しました",
|
||||||
|
"undoFailed": "再マッチングを元に戻せませんでした:{message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "再マッチングの概要",
|
||||||
|
"successMessage": "{entries} エントリをマッチングしました",
|
||||||
|
"failed": "再マッチングに失敗しました",
|
||||||
|
"completedWithWarnings": "再マッチングは完了しましたが、要確認の項目があります",
|
||||||
|
"cancelledNote": "完了前に実行がキャンセルされたため、件数は一部のみです。",
|
||||||
|
"statMatched": "マッチしたエントリ",
|
||||||
|
"statReview": "要確認",
|
||||||
|
"statUnresolved": "マッチなし",
|
||||||
|
"statErrors": "エラー",
|
||||||
|
"reviewSection": "確認が必要なファイル名マッチ({count})",
|
||||||
|
"columnRecipe": "レシピ",
|
||||||
|
"columnEntry": "エントリ",
|
||||||
|
"columnFile": "マッチしたファイル",
|
||||||
|
"columnUndo": "元に戻す",
|
||||||
|
"copyReport": "レポートをコピー",
|
||||||
|
"close": "閉じる",
|
||||||
|
"scope_global": "すべてのレシピ",
|
||||||
|
"scope_bulk": "選択したレシピ",
|
||||||
|
"scope_single": "単一のレシピ"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "ローカル例画像",
|
"title": "ローカル例画像",
|
||||||
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
"message": "このモデルのローカル例画像が見つかりませんでした。表示オプション:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Embedding Managerを初期化中",
|
"title": "Embedding Managerを初期化中",
|
||||||
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
"message": "embeddingキャッシュをスキャンして構築中。数分かかる場合があります..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "その他のモデルマネージャーを初期化中",
|
||||||
|
"message": "モデルキャッシュをスキャンして構築中です。数分かかる場合があります..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "レシピマネージャーを初期化中",
|
"title": "レシピマネージャーを初期化中",
|
||||||
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
"message": "レシピを読み込んで処理中。数分かかる場合があります..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
"createMissingData": "レシピ作成に必要なデータが不足しています",
|
||||||
"created": "レシピを作成しました",
|
"created": "レシピを作成しました",
|
||||||
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
"noMissingLoras": "ダウンロードする不足LoRAがありません",
|
||||||
|
"unresolvableMarkedForReconnect": "解決できないエントリを {count} 件マークしました — ローカルの LoRA に再接続できるようになりました。",
|
||||||
"noPreviousRecipe": "前のレシピがありません",
|
"noPreviousRecipe": "前のレシピがありません",
|
||||||
"noNextRecipe": "次のレシピがありません",
|
"noNextRecipe": "次のレシピがありません",
|
||||||
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
"batchImportBrowseFailed": "フォルダを参照できませんでした: {message}",
|
||||||
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
"batchImportDirectorySelected": "選択されたフォルダ: {path}",
|
||||||
"noRecipesSelected": "レシピが選択されていません",
|
"noRecipesSelected": "レシピが選択されていません",
|
||||||
"rematchComplete": "{recipes} 件のレシピで {entries} エントリをマッチングしました",
|
|
||||||
"rematchCompleteErrors": "{recipes} 件のレシピで {entries} エントリをマッチングしました({failures} 件失敗)",
|
|
||||||
"rematchAllFailed": "選択した {total} 件中 {failures} 件のレシピの再マッチングに失敗しました",
|
|
||||||
"rematchUnmatched": "{recipes} 件のレシピで {entries} エントリのローカルマッチが見つかりませんでした",
|
|
||||||
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
"rematchSkipped": "選択した {total} 件のレシピは再マッチングの必要がありませんでした",
|
||||||
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
"rematchFailed": "選択したレシピの再マッチングに失敗しました:{message}",
|
||||||
"reimporting": "ソースからレシピを再インポート中...",
|
"reimporting": "ソースからレシピを再インポート中...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
"checkpointRootsFailed": "Checkpointルートの読み込みに失敗しました:{message}",
|
||||||
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
"unetRootsFailed": "Diffusion Modelルートの読み込みに失敗しました:{message}",
|
||||||
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
"embeddingRootsFailed": "embeddingルートの読み込みに失敗しました:{message}",
|
||||||
|
"otherRootsFailed": "その他のモデルルートの読み込みに失敗しました:{message}",
|
||||||
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
"mappingsUpdated": "ベースモデルパスマッピングが更新されました({count} マッピング)",
|
||||||
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
"mappingsCleared": "ベースモデルパスマッピングがクリアされました",
|
||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "キャッシュを再構築中...",
|
"rebuilding": "キャッシュを再構築中...",
|
||||||
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
"rebuildFailed": "キャッシュの再構築に失敗しました: {error}",
|
||||||
"retry": "再試行"
|
"retry": "再試行"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "その他のモデル管理が利用可能になりました",
|
||||||
|
"content": "専用ページで VAE、Upscaler、Text Encoder、CLIP Vision、ControlNet の各ファイルをスキャン・管理し、CivitAI からダウンロードできます。",
|
||||||
|
"enable": "その他のモデルを有効にする",
|
||||||
|
"openSettings": "設定を開く"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Checkpoint 이름 복사",
|
"copyCheckpointName": "Checkpoint 이름 복사",
|
||||||
"copyEmbeddingName": "Embedding 이름 복사",
|
"copyEmbeddingName": "Embedding 이름 복사",
|
||||||
"embeddingNameCopied": "Embedding 구문 복사됨",
|
"embeddingNameCopied": "Embedding 구문 복사됨",
|
||||||
|
"modelNameCopied": "모델 이름 복사됨",
|
||||||
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
"sendCheckpointToWorkflow": "ComfyUI로 전송",
|
||||||
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
"sendEmbeddingToWorkflow": "ComfyUI로 전송"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "레시피를 로컬 모델에 다시 매칭",
|
"label": "레시피를 로컬 모델에 다시 매칭",
|
||||||
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
"loading": "레시피를 로컬 모델에 다시 매칭하는 중...",
|
||||||
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
"success": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
||||||
"successErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
|
||||||
"allFailed": "{total}개 레시피 중 {failures}개 재매칭 실패",
|
|
||||||
"noMatch": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
|
||||||
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
"cancelled": "재매칭이 취소되었습니다. {recipes}개 레시피 업데이트됨({entries}개 항목)",
|
||||||
"error": "레시피 재매칭 실패: {message}"
|
"error": "레시피 재매칭 실패: {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "레시피",
|
"recipes": "레시피",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "기타",
|
||||||
"statistics": "통계"
|
"statistics": "통계"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
"defaultUnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Diffusion Model (UNET) 루트 디렉토리를 설정합니다",
|
||||||
"defaultEmbeddingRoot": "Embedding 루트",
|
"defaultEmbeddingRoot": "Embedding 루트",
|
||||||
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
"defaultEmbeddingRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Embedding 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultVaeRoot": "VAE 루트",
|
||||||
|
"defaultVaeRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 VAE 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultUpscalerRoot": "Upscaler 루트",
|
||||||
|
"defaultUpscalerRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Upscaler 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultTextEncoderRoot": "Text Encoder 루트",
|
||||||
|
"defaultTextEncoderRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 Text Encoder 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultClipVisionRoot": "CLIP Vision 루트",
|
||||||
|
"defaultClipVisionRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 CLIP Vision 루트 디렉토리를 설정합니다",
|
||||||
|
"defaultControlnetRoot": "ControlNet 루트",
|
||||||
|
"defaultControlnetRootHelp": "다운로드, 가져오기 및 이동을 위한 기본 ControlNet 루트 디렉토리를 설정합니다",
|
||||||
|
"enableOtherModels": "기타 모델 관리",
|
||||||
|
"enableOtherModelsHelp": "끄면 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 폴더를 스캔하지 않으며, 기타 모델 페이지가 비활성화된 상태로 유지되고 이러한 모델 유형은 다운로드할 수 없습니다.",
|
||||||
|
"otherSubTypes": "관리할 모델 유형",
|
||||||
|
"otherSubTypesHelp": "기타 모델 페이지에서 스캔하고 표시할 카테고리를 선택합니다.",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "레시피 저장 경로",
|
"recipesPath": "레시피 저장 경로",
|
||||||
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
"recipesPathHelp": "저장된 레시피를 위한 선택적 사용자 지정 디렉터리입니다. 비워 두면 첫 번째 LoRA 루트의 recipes 폴더를 사용합니다.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 모델"
|
"title": "Embedding 모델"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "기타 모델",
|
||||||
|
"disabled": {
|
||||||
|
"title": "기타 모델 관리가 꺼져 있습니다",
|
||||||
|
"description": "활성화하면 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔하고 관리하며 CivitAI에서 다운로드할 수 있습니다.",
|
||||||
|
"enableButton": "기타 모델 활성화",
|
||||||
|
"hint": "관리할 모델 유형은 나중에 설정 > 라이브러리에서 변경할 수 있습니다.",
|
||||||
|
"enableFailed": "기타 모델 활성화 실패",
|
||||||
|
"downloadBlocked": "이 모델 유형에 대해서는 기타 모델 관리가 비활성화되어 있습니다. 이 파일을 다운로드하려면 설정 > 라이브러리에서 활성화하세요.",
|
||||||
|
"enableAction": "기타 모델 활성화"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "기타 모델 폴더를 찾을 수 없습니다",
|
||||||
|
"descriptionStandalone": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 아래 폴더 경로를 settings.json에 추가한 뒤 LoRA Manager를 재시작하세요.",
|
||||||
|
"hintStandalone": "위에 나열된 폴더 키만 스캔됩니다. 필요 없는 키는 생략할 수 있습니다.",
|
||||||
|
"descriptionComfyUI": "기타 모델 관리가 켜져 있지만, 설정된 모델 폴더가 디스크에 존재하지 않습니다. 해당 모델 폴더를 ComfyUI 모델 경로에 추가한 뒤 이 페이지를 새로 고침하세요.",
|
||||||
|
"hintComfyUI": "기타 모델은 ComfyUI의 vae, upscale_models, text_encoders, clip_vision, controlnet 폴더에서 읽어옵니다.",
|
||||||
|
"openSettings": "설정 열기"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "루트",
|
"modelRoot": "루트",
|
||||||
"collapseAll": "모든 폴더 접기",
|
"collapseAll": "모든 폴더 접기",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
"note": "파일은 기본 경로 템플릿을 사용하여 다운로드됩니다. LoRA의 수에 따라 다소 시간이 걸릴 수 있습니다.",
|
||||||
"downloadButton": "{count}개 LoRA 다운로드"
|
"downloadButton": "{count}개 LoRA 다운로드"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "레시피 재매칭",
|
||||||
|
"messageGlobal": "모든 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||||
|
"messageSingle": "이 레시피를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||||
|
"messageBulk": "선택한 레시피 {count}개를 로컬 모델 라이브러리와 대조하여 검사합니다.",
|
||||||
|
"relaxedLabel": "누락된 모델도 파일 이름으로 다시 연결",
|
||||||
|
"relaxedDescription": "이 모델들은 다운로드로도 해결할 수 있으며 다운로드가 더 정확합니다. 매칭 시 모델의 다른 버전이 연결될 수 있으며, 검토용으로 목록에 표시되고 실행 취소할 수 있습니다.",
|
||||||
|
"confirmButton": "재매칭"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "실행 취소",
|
||||||
|
"undone": "실행 취소됨",
|
||||||
|
"undoFailed": "재매칭 실행 취소 실패: {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "재매칭 요약",
|
||||||
|
"successMessage": "{entries}개 항목이 매칭되었습니다",
|
||||||
|
"failed": "재매칭 실패",
|
||||||
|
"completedWithWarnings": "재매칭이 완료되었습니다 — 검토가 권장됩니다",
|
||||||
|
"cancelledNote": "완료 전에 실행이 취소되었습니다 — 집계는 부분적입니다.",
|
||||||
|
"statMatched": "매칭된 항목",
|
||||||
|
"statReview": "검토 필요",
|
||||||
|
"statUnresolved": "매칭 없음",
|
||||||
|
"statErrors": "오류",
|
||||||
|
"reviewSection": "검토할 파일 이름 매칭 ({count})",
|
||||||
|
"columnRecipe": "레시피",
|
||||||
|
"columnEntry": "항목",
|
||||||
|
"columnFile": "매칭된 파일",
|
||||||
|
"columnUndo": "실행 취소",
|
||||||
|
"copyReport": "보고서 복사",
|
||||||
|
"close": "닫기",
|
||||||
|
"scope_global": "모든 레시피",
|
||||||
|
"scope_bulk": "선택한 레시피",
|
||||||
|
"scope_single": "단일 레시피"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "로컬 예시 이미지",
|
"title": "로컬 예시 이미지",
|
||||||
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
"message": "이 모델의 로컬 예시 이미지를 찾을 수 없습니다. 보기 옵션:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Embedding Manager 초기화 중",
|
"title": "Embedding Manager 초기화 중",
|
||||||
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "Embedding 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "기타 모델 관리자 초기화 중",
|
||||||
|
"message": "모델 캐시를 스캔하고 구축하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "레시피 매니저 초기화 중",
|
"title": "레시피 매니저 초기화 중",
|
||||||
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
"message": "레시피를 로딩하고 처리하고 있습니다. 몇 분이 걸릴 수 있습니다..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
|
||||||
"created": "레시피가 생성되었습니다",
|
"created": "레시피가 생성되었습니다",
|
||||||
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
|
||||||
|
"unresolvableMarkedForReconnect": "해석할 수 없는 항목 {count}개가 표시되었습니다 — 이제 로컬 LoRA에 다시 연결할 수 있습니다.",
|
||||||
"noPreviousRecipe": "이전 레시피가 없습니다",
|
"noPreviousRecipe": "이전 레시피가 없습니다",
|
||||||
"noNextRecipe": "다음 레시피가 없습니다",
|
"noNextRecipe": "다음 레시피가 없습니다",
|
||||||
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
"batchImportBrowseFailed": "폴더를 찾아보지 못했습니다: {message}",
|
||||||
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
"batchImportDirectorySelected": "선택한 폴더: {path}",
|
||||||
"noRecipesSelected": "선택한 레시피가 없습니다",
|
"noRecipesSelected": "선택한 레시피가 없습니다",
|
||||||
"rematchComplete": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다",
|
|
||||||
"rematchCompleteErrors": "{recipes}개 레시피에서 {entries}개 항목이 매칭되었습니다. {failures}개 실패",
|
|
||||||
"rematchAllFailed": "선택한 {total}개 레시피 중 {failures}개 재매칭 실패",
|
|
||||||
"rematchUnmatched": "{recipes}개 레시피에서 {entries}개 항목의 로컬 매칭을 찾지 못했습니다",
|
|
||||||
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
"rematchSkipped": "선택한 {total}개 레시피는 재매칭이 필요하지 않습니다",
|
||||||
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
"rematchFailed": "선택한 레시피 재매칭 실패: {message}",
|
||||||
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
"reimporting": "소스에서 레시피를 다시 가져오는 중...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
"checkpointRootsFailed": "Checkpoint 루트 로딩 실패: {message}",
|
||||||
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
"unetRootsFailed": "Diffusion Model 루트 로딩 실패: {message}",
|
||||||
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
"embeddingRootsFailed": "Embedding 루트 로딩 실패: {message}",
|
||||||
|
"otherRootsFailed": "기타 모델 루트 로딩 실패: {message}",
|
||||||
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
"mappingsUpdated": "베이스 모델 경로 매핑이 업데이트되었습니다 ({count}개 매핑)",
|
||||||
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
"mappingsCleared": "베이스 모델 경로 매핑이 지워졌습니다",
|
||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "캐시 재구축 중...",
|
"rebuilding": "캐시 재구축 중...",
|
||||||
"rebuildFailed": "캐시 재구축 실패: {error}",
|
"rebuildFailed": "캐시 재구축 실패: {error}",
|
||||||
"retry": "다시 시도"
|
"retry": "다시 시도"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "기타 모델 관리를 사용할 수 있습니다",
|
||||||
|
"content": "전용 페이지에서 VAE, Upscaler, Text Encoder, CLIP Vision, ControlNet 파일을 스캔 및 관리하고 CivitAI에서 다운로드할 수 있습니다.",
|
||||||
|
"enable": "기타 모델 활성화",
|
||||||
|
"openSettings": "설정 열기"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "Копировать имя checkpoint",
|
"copyCheckpointName": "Копировать имя checkpoint",
|
||||||
"copyEmbeddingName": "Копировать имя embedding",
|
"copyEmbeddingName": "Копировать имя embedding",
|
||||||
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
"embeddingNameCopied": "Синтаксис embedding скопирован",
|
||||||
|
"modelNameCopied": "Имя модели скопировано",
|
||||||
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
"sendCheckpointToWorkflow": "Отправить в ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
"sendEmbeddingToWorkflow": "Отправить в ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "Повторное сопоставление рецептов с локальными моделями",
|
"label": "Повторное сопоставление рецептов с локальными моделями",
|
||||||
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
"loading": "Повторное сопоставление рецептов с локальными моделями...",
|
||||||
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
"success": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
||||||
"successErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
|
||||||
"allFailed": "Не удалось сопоставить: {failures} из {total} рецептов",
|
|
||||||
"noMatch": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
|
||||||
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
"cancelled": "Сопоставление отменено. Обновлено рецептов: {recipes} (записей: {entries})",
|
||||||
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
"error": "Не удалось выполнить сопоставление рецептов: {message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "Рецепты",
|
"recipes": "Рецепты",
|
||||||
"checkpoints": "Checkpoints",
|
"checkpoints": "Checkpoints",
|
||||||
"embeddings": "Embeddings",
|
"embeddings": "Embeddings",
|
||||||
|
"other": "Другое",
|
||||||
"statistics": "Статистика"
|
"statistics": "Статистика"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
"defaultUnetRootHelp": "Установить корневую папку Diffusion Model (UNET) по умолчанию для загрузок, импорта и перемещений",
|
||||||
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
"defaultEmbeddingRoot": "Корневая папка Embedding",
|
||||||
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
"defaultEmbeddingRootHelp": "Установить корневую папку embedding по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultVaeRoot": "Корневая папка VAE",
|
||||||
|
"defaultVaeRootHelp": "Установить корневую папку VAE по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultUpscalerRoot": "Корневая папка Upscaler",
|
||||||
|
"defaultUpscalerRootHelp": "Установить корневую папку Upscaler по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultTextEncoderRoot": "Корневая папка Text Encoder",
|
||||||
|
"defaultTextEncoderRootHelp": "Установить корневую папку Text Encoder по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultClipVisionRoot": "Корневая папка CLIP Vision",
|
||||||
|
"defaultClipVisionRootHelp": "Установить корневую папку CLIP Vision по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"defaultControlnetRoot": "Корневая папка ControlNet",
|
||||||
|
"defaultControlnetRootHelp": "Установить корневую папку ControlNet по умолчанию для загрузок, импорта и перемещений",
|
||||||
|
"enableOtherModels": "Управление другими моделями",
|
||||||
|
"enableOtherModelsHelp": "Если выключено, папки VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet не сканируются, страница «Другие модели» остаётся отключённой, а эти типы моделей нельзя загрузить.",
|
||||||
|
"otherSubTypes": "Управляемые типы моделей",
|
||||||
|
"otherSubTypesHelp": "Выберите, какие категории других моделей сканируются и отображаются на странице «Другие модели».",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "Путь хранения рецептов",
|
"recipesPath": "Путь хранения рецептов",
|
||||||
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
"recipesPathHelp": "Дополнительный пользовательский каталог для сохранённых рецептов. Оставьте пустым, чтобы использовать папку recipes в первом корне LoRA.",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Модели Embedding"
|
"title": "Модели Embedding"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Другие модели",
|
||||||
|
"disabled": {
|
||||||
|
"title": "Управление другими моделями отключено",
|
||||||
|
"description": "Включите, чтобы сканировать и управлять файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загружать их с CivitAI.",
|
||||||
|
"enableButton": "Включить другие модели",
|
||||||
|
"hint": "Вы сможете изменить управляемые типы моделей позже в разделе «Настройки > Библиотека».",
|
||||||
|
"enableFailed": "Не удалось включить другие модели",
|
||||||
|
"downloadBlocked": "Управление другими моделями отключено для этого типа моделей. Включите его в разделе «Настройки > Библиотека», чтобы загрузить этот файл.",
|
||||||
|
"enableAction": "Включить другие модели"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "Папки других моделей не найдены",
|
||||||
|
"descriptionStandalone": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте указанные ниже пути к папкам в settings.json и перезапустите LoRA Manager.",
|
||||||
|
"hintStandalone": "Сканируются только перечисленные выше ключи папок; ненужные ключи можно опустить.",
|
||||||
|
"descriptionComfyUI": "Управление другими моделями включено, но ни одна из настроенных папок моделей не существует на диске. Добавьте соответствующие папки моделей в пути к моделям ComfyUI и перезагрузите эту страницу.",
|
||||||
|
"hintComfyUI": "Другие модели читаются из папок vae, upscale_models, text_encoders, clip_vision и controlnet в ComfyUI.",
|
||||||
|
"openSettings": "Открыть настройки"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "Корень",
|
"modelRoot": "Корень",
|
||||||
"collapseAll": "Свернуть все папки",
|
"collapseAll": "Свернуть все папки",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
"note": "Файлы будут скачаны с использованием шаблонов путей по умолчанию. Это может занять некоторое время в зависимости от количества LoRAs.",
|
||||||
"downloadButton": "Скачать {count} LoRA(s)"
|
"downloadButton": "Скачать {count} LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "Повторное сопоставление рецептов",
|
||||||
|
"messageGlobal": "Все рецепты будут проверены по вашей локальной библиотеке моделей.",
|
||||||
|
"messageSingle": "Этот рецепт будет проверен по вашей локальной библиотеке моделей.",
|
||||||
|
"messageBulk": "Выбранные рецепты ({count}) будут проверены по вашей локальной библиотеке моделей.",
|
||||||
|
"relaxedLabel": "Также переподключать отсутствующие модели по имени файла",
|
||||||
|
"relaxedDescription": "Эти модели также можно исправить загрузкой — загрузка точнее. Совпадения могут привязать другую версию; они будут перечислены для проверки, и их можно будет отменить.",
|
||||||
|
"confirmButton": "Сопоставить"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "Отменить",
|
||||||
|
"undone": "Отменено",
|
||||||
|
"undoFailed": "Не удалось отменить сопоставление: {message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "Сводка повторного сопоставления",
|
||||||
|
"successMessage": "Сопоставлено записей: {entries}",
|
||||||
|
"failed": "Не удалось выполнить сопоставление",
|
||||||
|
"completedWithWarnings": "Сопоставление завершено — рекомендуется проверка",
|
||||||
|
"cancelledNote": "Запуск отменён до завершения — подсчёты неполные.",
|
||||||
|
"statMatched": "Сопоставленные записи",
|
||||||
|
"statReview": "Требуют проверки",
|
||||||
|
"statUnresolved": "Не сопоставлено",
|
||||||
|
"statErrors": "Ошибки",
|
||||||
|
"reviewSection": "Совпадения по имени файла для проверки ({count})",
|
||||||
|
"columnRecipe": "Рецепт",
|
||||||
|
"columnEntry": "Запись",
|
||||||
|
"columnFile": "Совпавший файл",
|
||||||
|
"columnUndo": "Отменить",
|
||||||
|
"copyReport": "Скопировать отчёт",
|
||||||
|
"close": "Закрыть",
|
||||||
|
"scope_global": "Все рецепты",
|
||||||
|
"scope_bulk": "Выбранные рецепты",
|
||||||
|
"scope_single": "Один рецепт"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "Локальные примеры изображений",
|
"title": "Локальные примеры изображений",
|
||||||
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
"message": "Локальные примеры изображений для этой модели не найдены. Варианты просмотра:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "Инициализация Embedding Manager",
|
"title": "Инициализация Embedding Manager",
|
||||||
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
"message": "Сканирование и построение кэша embedding. Это может занять несколько минут..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "Инициализация менеджера других моделей",
|
||||||
|
"message": "Сканирование и построение кэша моделей. Это может занять несколько минут..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "Инициализация менеджера рецептов",
|
"title": "Инициализация менеджера рецептов",
|
||||||
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
"message": "Загрузка и обработка рецептов. Это может занять несколько минут..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
"createMissingData": "Отсутствуют необходимые данные для создания рецепта",
|
||||||
"created": "Рецепт успешно создан",
|
"created": "Рецепт успешно создан",
|
||||||
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
|
||||||
|
"unresolvableMarkedForReconnect": "Помечено неразрешимых записей: {count} — теперь их можно переподключить к локальному LoRA.",
|
||||||
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
|
||||||
"noNextRecipe": "Следующий рецепт отсутствует",
|
"noNextRecipe": "Следующий рецепт отсутствует",
|
||||||
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
"batchImportBrowseFailed": "Не удалось открыть папку: {message}",
|
||||||
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
"batchImportDirectorySelected": "Выбрана папка: {path}",
|
||||||
"noRecipesSelected": "Рецепты не выбраны",
|
"noRecipesSelected": "Рецепты не выбраны",
|
||||||
"rematchComplete": "Сопоставлено записей: {entries} в рецептах: {recipes}",
|
|
||||||
"rematchCompleteErrors": "Сопоставлено записей: {entries} в рецептах: {recipes}, ошибок: {failures}",
|
|
||||||
"rematchAllFailed": "Не удалось сопоставить: {failures} из {total} выбранных рецептов",
|
|
||||||
"rematchUnmatched": "Не найдено локального сопоставления для {entries} записей в {recipes} рецептах",
|
|
||||||
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
"rematchSkipped": "Ни один из {total} выбранных рецептов не требует сопоставления",
|
||||||
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
"rematchFailed": "Не удалось сопоставить выбранные рецепты: {message}",
|
||||||
"reimporting": "Переимпорт рецепта из источника...",
|
"reimporting": "Переимпорт рецепта из источника...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
"checkpointRootsFailed": "Не удалось загрузить корни checkpoint: {message}",
|
||||||
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
"unetRootsFailed": "Не удалось загрузить корни Diffusion Model: {message}",
|
||||||
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
"embeddingRootsFailed": "Не удалось загрузить корни embedding: {message}",
|
||||||
|
"otherRootsFailed": "Не удалось загрузить корни других моделей: {message}",
|
||||||
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
"mappingsUpdated": "Сопоставления путей базовых моделей обновлены ({count})",
|
||||||
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
"mappingsCleared": "Сопоставления путей базовых моделей очищены",
|
||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "Перестроение кэша...",
|
"rebuilding": "Перестроение кэша...",
|
||||||
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
"rebuildFailed": "Не удалось перестроить кэш: {error}",
|
||||||
"retry": "Повторить"
|
"retry": "Повторить"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "Управление другими моделями доступно",
|
||||||
|
"content": "Сканирование и управление файлами VAE, Upscaler, Text Encoder, CLIP Vision и ControlNet, а также загрузка их с CivitAI — всё на одной отдельной странице.",
|
||||||
|
"enable": "Включить другие модели",
|
||||||
|
"openSettings": "Открыть настройки"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "复制 Checkpoint 名称",
|
"copyCheckpointName": "复制 Checkpoint 名称",
|
||||||
"copyEmbeddingName": "复制 Embedding 名称",
|
"copyEmbeddingName": "复制 Embedding 名称",
|
||||||
"embeddingNameCopied": "已复制 Embedding 语法",
|
"embeddingNameCopied": "已复制 Embedding 语法",
|
||||||
|
"modelNameCopied": "模型名称已复制",
|
||||||
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
"sendCheckpointToWorkflow": "发送到 ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
"sendEmbeddingToWorkflow": "发送到 ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "将配方重新匹配到本地模型",
|
"label": "将配方重新匹配到本地模型",
|
||||||
"loading": "正在将配方重新匹配到本地模型...",
|
"loading": "正在将配方重新匹配到本地模型...",
|
||||||
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
"success": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
||||||
"successErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
|
||||||
"allFailed": "{failures}/{total} 个配方重新匹配失败",
|
|
||||||
"noMatch": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
|
||||||
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
"cancelled": "已取消重新匹配。{recipes} 个配方已更新({entries} 个条目)。",
|
||||||
"error": "配方重新匹配失败:{message}"
|
"error": "配方重新匹配失败:{message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "其他",
|
||||||
"statistics": "统计"
|
"statistics": "统计"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
"defaultUnetRootHelp": "设置下载、导入和移动时的默认 Diffusion Model (UNET) 根目录",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目录",
|
"defaultEmbeddingRoot": "Embedding 根目录",
|
||||||
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
"defaultEmbeddingRootHelp": "设置下载、导入和移动时的默认 Embedding 根目录",
|
||||||
|
"defaultVaeRoot": "VAE 根目录",
|
||||||
|
"defaultVaeRootHelp": "设置下载、导入和移动时的默认 VAE 根目录",
|
||||||
|
"defaultUpscalerRoot": "Upscaler 根目录",
|
||||||
|
"defaultUpscalerRootHelp": "设置下载、导入和移动时的默认 Upscaler 根目录",
|
||||||
|
"defaultTextEncoderRoot": "Text Encoder 根目录",
|
||||||
|
"defaultTextEncoderRootHelp": "设置下载、导入和移动时的默认 Text Encoder 根目录",
|
||||||
|
"defaultClipVisionRoot": "CLIP Vision 根目录",
|
||||||
|
"defaultClipVisionRootHelp": "设置下载、导入和移动时的默认 CLIP Vision 根目录",
|
||||||
|
"defaultControlnetRoot": "ControlNet 根目录",
|
||||||
|
"defaultControlnetRootHelp": "设置下载、导入和移动时的默认 ControlNet 根目录",
|
||||||
|
"enableOtherModels": "其他模型管理",
|
||||||
|
"enableOtherModelsHelp": "关闭后,不会扫描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 文件夹,其他模型页面保持禁用,且无法下载这些模型类型。",
|
||||||
|
"otherSubTypes": "管理的模型类型",
|
||||||
|
"otherSubTypesHelp": "选择要在其他模型页面中扫描和显示的其他模型类别。",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "配方存储路径",
|
"recipesPath": "配方存储路径",
|
||||||
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
"recipesPathHelp": "已保存配方的可选自定义目录。留空则使用第一个 LoRA 根目录下的 recipes 文件夹。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "其他模型",
|
||||||
|
"disabled": {
|
||||||
|
"title": "其他模型管理已关闭",
|
||||||
|
"description": "启用后可扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||||
|
"enableButton": "启用其他模型",
|
||||||
|
"hint": "你可以稍后在“设置 > 库”中更改管理的模型类型。",
|
||||||
|
"enableFailed": "启用其他模型失败",
|
||||||
|
"downloadBlocked": "其他模型管理已对此模型类型禁用。请在“设置 > 库”中启用以下载此文件。",
|
||||||
|
"enableAction": "启用其他模型"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "未找到其他模型文件夹",
|
||||||
|
"descriptionStandalone": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将下面的文件夹路径添加到 settings.json,然后重启 LoRA Manager。",
|
||||||
|
"hintStandalone": "只会扫描上面列出的文件夹键;不需要的键可以省略。",
|
||||||
|
"descriptionComfyUI": "其他模型管理已开启,但配置的模型文件夹在磁盘上都不存在。请将对应的模型文件夹添加到 ComfyUI 的模型路径,然后重新加载此页面。",
|
||||||
|
"hintComfyUI": "其他模型从 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 文件夹中读取。",
|
||||||
|
"openSettings": "打开设置"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "根目录",
|
"modelRoot": "根目录",
|
||||||
"collapseAll": "折叠所有文件夹",
|
"collapseAll": "折叠所有文件夹",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
"note": "文件将使用默认路径模板下载。根据 LoRAs 的数量,这可能需要一些时间。",
|
||||||
"downloadButton": "下载 {count} 个 LoRA(s)"
|
"downloadButton": "下载 {count} 个 LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "重新匹配配方",
|
||||||
|
"messageGlobal": "将对照你的本地模型库扫描所有配方。",
|
||||||
|
"messageSingle": "将对照你的本地模型库扫描此配方。",
|
||||||
|
"messageBulk": "将对照你的本地模型库扫描 {count} 个所选配方。",
|
||||||
|
"relaxedLabel": "同时按文件名重新关联缺失的模型",
|
||||||
|
"relaxedDescription": "这些模型也可以通过下载来修复——下载更为准确。匹配结果可能链接到模型的其他版本;它们会被列出供检查,且可以撤销。",
|
||||||
|
"confirmButton": "重新匹配"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "撤销",
|
||||||
|
"undone": "已撤销",
|
||||||
|
"undoFailed": "撤销重新匹配失败:{message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "重新匹配摘要",
|
||||||
|
"successMessage": "已匹配 {entries} 个条目",
|
||||||
|
"failed": "重新匹配失败",
|
||||||
|
"completedWithWarnings": "重新匹配已完成——建议检查",
|
||||||
|
"cancelledNote": "运行在完成前已取消——统计不完整。",
|
||||||
|
"statMatched": "已匹配条目",
|
||||||
|
"statReview": "需要检查",
|
||||||
|
"statUnresolved": "未匹配",
|
||||||
|
"statErrors": "错误",
|
||||||
|
"reviewSection": "需要检查的文件名匹配({count})",
|
||||||
|
"columnRecipe": "配方",
|
||||||
|
"columnEntry": "条目",
|
||||||
|
"columnFile": "匹配到的文件",
|
||||||
|
"columnUndo": "撤销",
|
||||||
|
"copyReport": "复制报告",
|
||||||
|
"close": "关闭",
|
||||||
|
"scope_global": "所有配方",
|
||||||
|
"scope_bulk": "所选配方",
|
||||||
|
"scope_single": "单个配方"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "本地示例图片",
|
"title": "本地示例图片",
|
||||||
"message": "未找到此模型的本地示例图片。可选操作:",
|
"message": "未找到此模型的本地示例图片。可选操作:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "初始化 Embedding 管理器",
|
"title": "初始化 Embedding 管理器",
|
||||||
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
"message": "正在扫描并构建 Embedding 缓存。这可能需要几分钟..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "正在初始化其他模型管理器",
|
||||||
|
"message": "正在扫描并构建模型缓存。这可能需要几分钟..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "初始化配方管理器",
|
"title": "初始化配方管理器",
|
||||||
"message": "正在加载和处理配方。这可能需要几分钟..."
|
"message": "正在加载和处理配方。这可能需要几分钟..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "缺少创建配方所需的数据",
|
"createMissingData": "缺少创建配方所需的数据",
|
||||||
"created": "配方创建成功",
|
"created": "配方创建成功",
|
||||||
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
"noMissingLoras": "没有缺失的 LoRA 可下载",
|
||||||
|
"unresolvableMarkedForReconnect": "已标记 {count} 个无法解析的条目——现在可以将它们重新关联到本地 LoRA。",
|
||||||
"noPreviousRecipe": "没有上一个配方",
|
"noPreviousRecipe": "没有上一个配方",
|
||||||
"noNextRecipe": "没有下一个配方",
|
"noNextRecipe": "没有下一个配方",
|
||||||
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
"batchImportBrowseFailed": "浏览目录失败:{message}",
|
||||||
"batchImportDirectorySelected": "已选择目录:{path}",
|
"batchImportDirectorySelected": "已选择目录:{path}",
|
||||||
"noRecipesSelected": "未选择任何配方",
|
"noRecipesSelected": "未选择任何配方",
|
||||||
"rematchComplete": "已匹配 {entries} 个条目,涉及 {recipes} 个配方",
|
|
||||||
"rematchCompleteErrors": "已匹配 {entries} 个条目,涉及 {recipes} 个配方,{failures} 个失败",
|
|
||||||
"rematchAllFailed": "{failures}/{total} 个所选配方重新匹配失败",
|
|
||||||
"rematchUnmatched": "在 {recipes} 个配方中未找到 {entries} 个条目的本地匹配",
|
|
||||||
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
"rematchSkipped": "{total} 个所选配方均无需重新匹配",
|
||||||
"rematchFailed": "重新匹配所选配方失败:{message}",
|
"rematchFailed": "重新匹配所选配方失败:{message}",
|
||||||
"reimporting": "正在从源重新导入配方...",
|
"reimporting": "正在从源重新导入配方...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
"checkpointRootsFailed": "加载 Checkpoint 根目录失败:{message}",
|
||||||
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
"unetRootsFailed": "加载 Diffusion Model 根目录失败:{message}",
|
||||||
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
"embeddingRootsFailed": "加载 Embedding 根目录失败:{message}",
|
||||||
|
"otherRootsFailed": "加载其他模型根目录失败:{message}",
|
||||||
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
"mappingsUpdated": "基础模型路径映射已更新({count} 条映射)",
|
||||||
"mappingsCleared": "基础模型路径映射已清除",
|
"mappingsCleared": "基础模型路径映射已清除",
|
||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "正在重建缓存...",
|
"rebuilding": "正在重建缓存...",
|
||||||
"rebuildFailed": "重建缓存失败:{error}",
|
"rebuildFailed": "重建缓存失败:{error}",
|
||||||
"retry": "重试"
|
"retry": "重试"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "其他模型管理现已可用",
|
||||||
|
"content": "在一个专属页面中扫描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 文件,并从 CivitAI 下载。",
|
||||||
|
"enable": "启用其他模型",
|
||||||
|
"openSettings": "打开设置"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-7
@@ -149,6 +149,7 @@
|
|||||||
"copyCheckpointName": "複製 Checkpoint 名稱",
|
"copyCheckpointName": "複製 Checkpoint 名稱",
|
||||||
"copyEmbeddingName": "複製嵌入名稱",
|
"copyEmbeddingName": "複製嵌入名稱",
|
||||||
"embeddingNameCopied": "已複製 Embedding 語法",
|
"embeddingNameCopied": "已複製 Embedding 語法",
|
||||||
|
"modelNameCopied": "模型名稱已複製",
|
||||||
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
"sendCheckpointToWorkflow": "傳送到 ComfyUI",
|
||||||
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
"sendEmbeddingToWorkflow": "傳送到 ComfyUI"
|
||||||
},
|
},
|
||||||
@@ -216,9 +217,6 @@
|
|||||||
"label": "將配方重新匹配到本地模型",
|
"label": "將配方重新匹配到本地模型",
|
||||||
"loading": "正在將配方重新匹配到本地模型...",
|
"loading": "正在將配方重新匹配到本地模型...",
|
||||||
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
"success": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
||||||
"successErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
|
||||||
"allFailed": "{failures}/{total} 個配方重新匹配失敗",
|
|
||||||
"noMatch": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
|
||||||
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
"cancelled": "已取消重新匹配。{recipes} 個配方已更新({entries} 個條目)。",
|
||||||
"error": "配方重新匹配失敗:{message}"
|
"error": "配方重新匹配失敗:{message}"
|
||||||
},
|
},
|
||||||
@@ -236,6 +234,7 @@
|
|||||||
"recipes": "配方",
|
"recipes": "配方",
|
||||||
"checkpoints": "Checkpoint",
|
"checkpoints": "Checkpoint",
|
||||||
"embeddings": "Embedding",
|
"embeddings": "Embedding",
|
||||||
|
"other": "其他",
|
||||||
"statistics": "統計"
|
"statistics": "統計"
|
||||||
},
|
},
|
||||||
"search": {
|
"search": {
|
||||||
@@ -536,6 +535,25 @@
|
|||||||
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
"defaultUnetRootHelp": "設定下載、匯入和移動時的預設 Diffusion Model (UNET) 根目錄",
|
||||||
"defaultEmbeddingRoot": "Embedding 根目錄",
|
"defaultEmbeddingRoot": "Embedding 根目錄",
|
||||||
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
"defaultEmbeddingRootHelp": "設定下載、匯入和移動時的預設 Embedding 根目錄",
|
||||||
|
"defaultVaeRoot": "VAE 根目錄",
|
||||||
|
"defaultVaeRootHelp": "設定下載、匯入和移動時的預設 VAE 根目錄",
|
||||||
|
"defaultUpscalerRoot": "Upscaler 根目錄",
|
||||||
|
"defaultUpscalerRootHelp": "設定下載、匯入和移動時的預設 Upscaler 根目錄",
|
||||||
|
"defaultTextEncoderRoot": "Text Encoder 根目錄",
|
||||||
|
"defaultTextEncoderRootHelp": "設定下載、匯入和移動時的預設 Text Encoder 根目錄",
|
||||||
|
"defaultClipVisionRoot": "CLIP Vision 根目錄",
|
||||||
|
"defaultClipVisionRootHelp": "設定下載、匯入和移動時的預設 CLIP Vision 根目錄",
|
||||||
|
"defaultControlnetRoot": "ControlNet 根目錄",
|
||||||
|
"defaultControlnetRootHelp": "設定下載、匯入和移動時的預設 ControlNet 根目錄",
|
||||||
|
"enableOtherModels": "其他模型管理",
|
||||||
|
"enableOtherModelsHelp": "關閉後,不會掃描 VAE / Upscaler / Text Encoder / CLIP Vision / ControlNet 資料夾,其他模型頁面會保持停用,且無法下載這些模型類型。",
|
||||||
|
"otherSubTypes": "管理的模型類型",
|
||||||
|
"otherSubTypesHelp": "選擇要在其他模型頁面中掃描和顯示的其他模型類別。",
|
||||||
|
"subTypeVae": "VAE",
|
||||||
|
"subTypeUpscaler": "Upscaler",
|
||||||
|
"subTypeTextEncoder": "Text Encoder",
|
||||||
|
"subTypeClipVision": "CLIP Vision",
|
||||||
|
"subTypeControlnet": "ControlNet",
|
||||||
"recipesPath": "配方儲存路徑",
|
"recipesPath": "配方儲存路徑",
|
||||||
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
"recipesPathHelp": "已儲存配方的可選自訂目錄。留空則使用第一個 LoRA 根目錄下的 recipes 資料夾。",
|
||||||
"recipesPathPlaceholder": "/path/to/recipes",
|
"recipesPathPlaceholder": "/path/to/recipes",
|
||||||
@@ -1204,6 +1222,26 @@
|
|||||||
"embeddings": {
|
"embeddings": {
|
||||||
"title": "Embedding 模型"
|
"title": "Embedding 模型"
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "其他模型",
|
||||||
|
"disabled": {
|
||||||
|
"title": "其他模型管理已關閉",
|
||||||
|
"description": "啟用後可掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||||
|
"enableButton": "啟用其他模型",
|
||||||
|
"hint": "您稍後可以在「設定 > 模型庫」中變更管理的模型類型。",
|
||||||
|
"enableFailed": "啟用其他模型失敗",
|
||||||
|
"downloadBlocked": "其他模型管理已對此模型類型停用。請在「設定 > 模型庫」中啟用以下載此檔案。",
|
||||||
|
"enableAction": "啟用其他模型"
|
||||||
|
},
|
||||||
|
"noPaths": {
|
||||||
|
"title": "找不到其他模型資料夾",
|
||||||
|
"descriptionStandalone": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將下方的資料夾路徑加入 settings.json,然後重新啟動 LoRA Manager。",
|
||||||
|
"hintStandalone": "只會掃描上方列出的資料夾鍵;不需要的鍵可以省略。",
|
||||||
|
"descriptionComfyUI": "其他模型管理已開啟,但設定的模型資料夾在磁碟上都不存在。請將對應的模型資料夾加入 ComfyUI 的模型路徑,然後重新載入此頁面。",
|
||||||
|
"hintComfyUI": "其他模型會從 ComfyUI 的 vae、upscale_models、text_encoders、clip_vision 和 controlnet 資料夾讀取。",
|
||||||
|
"openSettings": "開啟設定"
|
||||||
|
}
|
||||||
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
"modelRoot": "根目錄",
|
"modelRoot": "根目錄",
|
||||||
"collapseAll": "全部摺疊資料夾",
|
"collapseAll": "全部摺疊資料夾",
|
||||||
@@ -1501,6 +1539,41 @@
|
|||||||
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
"note": "檔案將使用預設路徑模板下載。根據 LoRAs 的數量,這可能需要一些時間。",
|
||||||
"downloadButton": "下載 {count} 個 LoRA(s)"
|
"downloadButton": "下載 {count} 個 LoRA(s)"
|
||||||
},
|
},
|
||||||
|
"rematchOptions": {
|
||||||
|
"title": "重新匹配配方",
|
||||||
|
"messageGlobal": "所有配方將對照您的本地模型庫進行掃描。",
|
||||||
|
"messageSingle": "此配方將對照您的本地模型庫進行掃描。",
|
||||||
|
"messageBulk": "將對照您的本地模型庫掃描 {count} 個所選配方。",
|
||||||
|
"relaxedLabel": "同時依檔案名稱重新關聯缺少的模型",
|
||||||
|
"relaxedDescription": "這些模型也可以透過下載修復——下載更為準確。比對可能會連結到模型的不同版本;比對結果將列出供您檢閱,且可以撤銷。",
|
||||||
|
"confirmButton": "重新匹配"
|
||||||
|
},
|
||||||
|
"rematchResults": {
|
||||||
|
"undo": "撤銷",
|
||||||
|
"undone": "已撤銷",
|
||||||
|
"undoFailed": "撤銷重新匹配失敗:{message}"
|
||||||
|
},
|
||||||
|
"rematchSummary": {
|
||||||
|
"title": "重新匹配摘要",
|
||||||
|
"successMessage": "已匹配 {entries} 個條目",
|
||||||
|
"failed": "重新匹配失敗",
|
||||||
|
"completedWithWarnings": "重新匹配已完成——建議檢查",
|
||||||
|
"cancelledNote": "執行在完成前已取消——統計不完整。",
|
||||||
|
"statMatched": "已匹配條目",
|
||||||
|
"statReview": "需要檢查",
|
||||||
|
"statUnresolved": "未匹配",
|
||||||
|
"statErrors": "錯誤",
|
||||||
|
"reviewSection": "需要檢查的檔案名稱匹配({count})",
|
||||||
|
"columnRecipe": "配方",
|
||||||
|
"columnEntry": "條目",
|
||||||
|
"columnFile": "匹配到的檔案",
|
||||||
|
"columnUndo": "撤銷",
|
||||||
|
"copyReport": "複製報告",
|
||||||
|
"close": "關閉",
|
||||||
|
"scope_global": "所有配方",
|
||||||
|
"scope_bulk": "所選配方",
|
||||||
|
"scope_single": "單個配方"
|
||||||
|
},
|
||||||
"exampleAccess": {
|
"exampleAccess": {
|
||||||
"title": "本機範例圖片",
|
"title": "本機範例圖片",
|
||||||
"message": "此模型未找到本機範例圖片。可選擇:",
|
"message": "此模型未找到本機範例圖片。可選擇:",
|
||||||
@@ -1846,6 +1919,10 @@
|
|||||||
"title": "初始化 Embedding 管理器",
|
"title": "初始化 Embedding 管理器",
|
||||||
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
"message": "正在掃描並建立 Embedding 快取,可能需要幾分鐘..."
|
||||||
},
|
},
|
||||||
|
"other": {
|
||||||
|
"title": "正在初始化其他模型管理器",
|
||||||
|
"message": "正在掃描並建立模型快取。這可能需要幾分鐘..."
|
||||||
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
"title": "初始化配方管理器",
|
"title": "初始化配方管理器",
|
||||||
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
"message": "正在載入並處理配方,可能需要幾分鐘..."
|
||||||
@@ -2168,6 +2245,7 @@
|
|||||||
"createMissingData": "缺少建立配方所需的資料",
|
"createMissingData": "缺少建立配方所需的資料",
|
||||||
"created": "配方建立成功",
|
"created": "配方建立成功",
|
||||||
"noMissingLoras": "無缺少的 LoRA 可下載",
|
"noMissingLoras": "無缺少的 LoRA 可下載",
|
||||||
|
"unresolvableMarkedForReconnect": "已標記 {count} 個無法解析的條目——現在可以將它們重新關聯到本地 LoRA。",
|
||||||
"noPreviousRecipe": "沒有上一個配方",
|
"noPreviousRecipe": "沒有上一個配方",
|
||||||
"noNextRecipe": "沒有下一個配方",
|
"noNextRecipe": "沒有下一個配方",
|
||||||
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
|
||||||
@@ -2222,10 +2300,6 @@
|
|||||||
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
"batchImportBrowseFailed": "瀏覽目錄失敗:{message}",
|
||||||
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
"batchImportDirectorySelected": "已選擇目錄:{path}",
|
||||||
"noRecipesSelected": "未選取任何配方",
|
"noRecipesSelected": "未選取任何配方",
|
||||||
"rematchComplete": "已匹配 {entries} 個條目,涉及 {recipes} 個配方",
|
|
||||||
"rematchCompleteErrors": "已匹配 {entries} 個條目,涉及 {recipes} 個配方,{failures} 個失敗",
|
|
||||||
"rematchAllFailed": "{failures}/{total} 個所選配方重新匹配失敗",
|
|
||||||
"rematchUnmatched": "在 {recipes} 個配方中找不到 {entries} 個條目的本地匹配",
|
|
||||||
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
"rematchSkipped": "{total} 個所選配方均無需重新匹配",
|
||||||
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
"rematchFailed": "重新匹配所選配方失敗:{message}",
|
||||||
"reimporting": "正在從來源重新匯入配方...",
|
"reimporting": "正在從來源重新匯入配方...",
|
||||||
@@ -2304,6 +2378,7 @@
|
|||||||
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
"checkpointRootsFailed": "載入 checkpoint 根目錄失敗:{message}",
|
||||||
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
"unetRootsFailed": "載入 Diffusion Model 根目錄失敗:{message}",
|
||||||
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
"embeddingRootsFailed": "載入 embedding 根目錄失敗:{message}",
|
||||||
|
"otherRootsFailed": "載入其他模型根目錄失敗:{message}",
|
||||||
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
"mappingsUpdated": "基礎模型路徑對應已更新({count} 個對應)",
|
||||||
"mappingsCleared": "基礎模型路徑對應已清除",
|
"mappingsCleared": "基礎模型路徑對應已清除",
|
||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
@@ -2566,6 +2641,12 @@
|
|||||||
"rebuilding": "重建快取中...",
|
"rebuilding": "重建快取中...",
|
||||||
"rebuildFailed": "重建快取失敗:{error}",
|
"rebuildFailed": "重建快取失敗:{error}",
|
||||||
"retry": "重試"
|
"retry": "重試"
|
||||||
|
},
|
||||||
|
"otherModels": {
|
||||||
|
"title": "其他模型管理現已可用",
|
||||||
|
"content": "在專屬頁面中掃描和管理 VAE、Upscaler、Text Encoder、CLIP Vision 和 ControlNet 檔案,並從 CivitAI 下載。",
|
||||||
|
"enable": "啟用其他模型",
|
||||||
|
"openSettings": "開啟設定"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+276
-1
@@ -17,6 +17,9 @@ import types as _types
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
from .utils.cache_paths import CacheType, get_cache_file_path, get_legacy_cache_paths
|
||||||
|
from .utils.constants import (
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||||
|
)
|
||||||
from .utils.settings_paths import (
|
from .utils.settings_paths import (
|
||||||
ensure_settings_file,
|
ensure_settings_file,
|
||||||
get_settings_dir,
|
get_settings_dir,
|
||||||
@@ -172,6 +175,13 @@ class Config:
|
|||||||
self.embeddings_roots = None
|
self.embeddings_roots = None
|
||||||
self.base_models_roots = self._init_checkpoint_paths()
|
self.base_models_roots = self._init_checkpoint_paths()
|
||||||
self.embeddings_roots = self._init_embedding_paths()
|
self.embeddings_roots = self._init_embedding_paths()
|
||||||
|
# Other-model roots (VAE, upscalers, text encoders, ...): flat deduped
|
||||||
|
# list plus a normalized root -> sub_type map and per-folder_paths-key
|
||||||
|
# roots for settings persistence.
|
||||||
|
self.other_roots: Optional[List[str]] = None
|
||||||
|
self.other_root_subtypes: Dict[str, str] = {}
|
||||||
|
self.other_folder_roots: Dict[str, List[str]] = {}
|
||||||
|
self.other_roots = self._init_other_paths()
|
||||||
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
|
# Extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
self.extra_loras_roots: List[str] = []
|
self.extra_loras_roots: List[str] = []
|
||||||
self.extra_checkpoints_roots: List[str] = []
|
self.extra_checkpoints_roots: List[str] = []
|
||||||
@@ -336,6 +346,10 @@ class Config:
|
|||||||
"unet": list(self.unet_roots or []),
|
"unet": list(self.unet_roots or []),
|
||||||
"embeddings": list(self.embeddings_roots or []),
|
"embeddings": list(self.embeddings_roots or []),
|
||||||
}
|
}
|
||||||
|
# Persist the other-model roots under their original folder_paths
|
||||||
|
# keys so library switching round-trips them.
|
||||||
|
for key, roots in (self.other_folder_roots or {}).items():
|
||||||
|
target_folder_paths[key] = list(roots)
|
||||||
|
|
||||||
normalized_target_paths = _normalize_folder_paths_for_comparison(
|
normalized_target_paths = _normalize_folder_paths_for_comparison(
|
||||||
target_folder_paths
|
target_folder_paths
|
||||||
@@ -522,6 +536,7 @@ class Config:
|
|||||||
roots.extend(self.loras_roots or [])
|
roots.extend(self.loras_roots or [])
|
||||||
roots.extend(self.base_models_roots or [])
|
roots.extend(self.base_models_roots or [])
|
||||||
roots.extend(self.embeddings_roots or [])
|
roots.extend(self.embeddings_roots or [])
|
||||||
|
roots.extend(self.other_roots or [])
|
||||||
# Include extra paths for scanning symlinks
|
# Include extra paths for scanning symlinks
|
||||||
roots.extend(self.extra_loras_roots or [])
|
roots.extend(self.extra_loras_roots or [])
|
||||||
roots.extend(self.extra_checkpoints_roots or [])
|
roots.extend(self.extra_checkpoints_roots or [])
|
||||||
@@ -862,6 +877,8 @@ class Config:
|
|||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
for root in self.embeddings_roots or []:
|
for root in self.embeddings_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
|
for root in self.other_roots or []:
|
||||||
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
# Include extra paths for preview access
|
# Include extra paths for preview access
|
||||||
for root in self.extra_loras_roots or []:
|
for root in self.extra_loras_roots or []:
|
||||||
preview_roots.update(self._expand_preview_root(root))
|
preview_roots.update(self._expand_preview_root(root))
|
||||||
@@ -882,7 +899,7 @@ class Config:
|
|||||||
path for path in preview_roots if path.is_absolute()
|
path for path in preview_roots if path.is_absolute()
|
||||||
}
|
}
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d symlink mappings",
|
"Preview roots rebuilt: %d paths from %d lora roots (%d extra), %d checkpoint roots (%d extra), %d embedding roots (%d extra), %d other roots, %d symlink mappings",
|
||||||
len(self._preview_root_paths),
|
len(self._preview_root_paths),
|
||||||
len(self.loras_roots or []),
|
len(self.loras_roots or []),
|
||||||
len(self.extra_loras_roots or []),
|
len(self.extra_loras_roots or []),
|
||||||
@@ -890,6 +907,7 @@ class Config:
|
|||||||
len(self.extra_checkpoints_roots or []),
|
len(self.extra_checkpoints_roots or []),
|
||||||
len(self.embeddings_roots or []),
|
len(self.embeddings_roots or []),
|
||||||
len(self.extra_embeddings_roots or []),
|
len(self.extra_embeddings_roots or []),
|
||||||
|
len(self.other_roots or []),
|
||||||
len(self._path_mappings),
|
len(self._path_mappings),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1128,6 +1146,155 @@ class Config:
|
|||||||
|
|
||||||
return unique_paths
|
return unique_paths
|
||||||
|
|
||||||
|
def _get_enabled_other_folder_keys(self) -> List[str]:
|
||||||
|
"""Return the OTHER_MODEL_FOLDER_SUBTYPES keys that are enabled.
|
||||||
|
|
||||||
|
Other Models management is opt-in: while ``enable_other_models`` is
|
||||||
|
off (the default) no other-model folder is scanned at all. When it is
|
||||||
|
on, only the folder keys of the enabled sub_types are scanned
|
||||||
|
(text_encoder merges ``text_encoders`` with the legacy ``clip`` key).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
enabled_sub_types = get_settings_manager().get_enabled_other_sub_types()
|
||||||
|
except Exception:
|
||||||
|
enabled_sub_types = []
|
||||||
|
if not enabled_sub_types:
|
||||||
|
return []
|
||||||
|
allowed = set(enabled_sub_types)
|
||||||
|
return [
|
||||||
|
key
|
||||||
|
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items()
|
||||||
|
if sub_type in allowed
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collapse_legacy_folder_keys(keys: List[str]) -> List[str]:
|
||||||
|
"""Drop folder keys the host already normalizes onto another queried key.
|
||||||
|
|
||||||
|
ComfyUI's ``folder_paths`` rewrites legacy names before every access
|
||||||
|
(``clip`` -> ``text_encoders``, ``unet`` -> ``diffusion_models``), and
|
||||||
|
registers both legacy directories under the canonical key, so
|
||||||
|
``get_folder_paths("clip")`` returns exactly the same list as
|
||||||
|
``get_folder_paths("text_encoders")``. Querying both therefore reports
|
||||||
|
every text-encoder folder twice and trips the overlap guard with a
|
||||||
|
conflict the user cannot fix.
|
||||||
|
|
||||||
|
When the host exposes ``map_legacy`` the alias is provably redundant and
|
||||||
|
is skipped (an empty canonical list implies an empty alias list).
|
||||||
|
Without it - the standalone mock, whose keys are independent
|
||||||
|
``settings.json`` entries - every key is kept, because a ``clip``-only
|
||||||
|
configuration is then genuinely distinct.
|
||||||
|
"""
|
||||||
|
map_legacy = getattr(folder_paths, "map_legacy", None)
|
||||||
|
if not callable(map_legacy):
|
||||||
|
return list(keys)
|
||||||
|
|
||||||
|
queried = set(keys)
|
||||||
|
collapsed: List[str] = []
|
||||||
|
for key in keys:
|
||||||
|
try:
|
||||||
|
canonical = map_legacy(key)
|
||||||
|
except Exception:
|
||||||
|
canonical = key
|
||||||
|
if canonical != key and canonical in queried:
|
||||||
|
logger.debug(
|
||||||
|
"Skipping legacy folder key '%s'; the host resolves it to "
|
||||||
|
"'%s', which is queried as well.",
|
||||||
|
key,
|
||||||
|
canonical,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
collapsed.append(key)
|
||||||
|
return collapsed
|
||||||
|
|
||||||
|
def _prepare_other_paths(
|
||||||
|
self, folder_path_map: Mapping[str, Iterable[str]]
|
||||||
|
) -> Tuple[List[str], Dict[str, str], Dict[str, List[str]]]:
|
||||||
|
"""Prepare other-model paths from a folder_paths-key -> raw paths map.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (all_unique_roots, business_root -> sub_type map,
|
||||||
|
folder_paths key -> business roots). This method does NOT modify
|
||||||
|
instance variables - callers must set them.
|
||||||
|
"""
|
||||||
|
unique_paths: List[str] = []
|
||||||
|
sub_type_map: Dict[str, str] = {}
|
||||||
|
per_key_roots: Dict[str, List[str]] = {}
|
||||||
|
# real path -> (business path, sub_type) of the category that claimed it
|
||||||
|
seen_real_paths: Dict[str, Tuple[str, str]] = {}
|
||||||
|
|
||||||
|
# Cross-scanner overlap detection: warn when an "other" root is
|
||||||
|
# already covered by the checkpoints/unet or embeddings scanners.
|
||||||
|
# Kept (not dropped) on purpose - duplicate cards across pages are
|
||||||
|
# cosmetic, while dropping would silently unmanage the files.
|
||||||
|
covered_real_paths = {
|
||||||
|
os.path.normpath(os.path.realpath(path)).replace(os.sep, "/"): path
|
||||||
|
for path in [
|
||||||
|
*(self.base_models_roots or []),
|
||||||
|
*(self.embeddings_roots or []),
|
||||||
|
]
|
||||||
|
if isinstance(path, str) and path.strip() and os.path.exists(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||||
|
raw_paths = folder_path_map.get(key)
|
||||||
|
if not raw_paths:
|
||||||
|
continue
|
||||||
|
path_map = self._dedupe_existing_paths(raw_paths)
|
||||||
|
key_roots: List[str] = []
|
||||||
|
for real_path, business_path in sorted(
|
||||||
|
path_map.items(), key=lambda item: item[1].lower()
|
||||||
|
):
|
||||||
|
seen = seen_real_paths.get(real_path)
|
||||||
|
if seen is not None:
|
||||||
|
seen_business_path, seen_sub_type = seen
|
||||||
|
if seen_sub_type == sub_type:
|
||||||
|
# Same category reached through a second folder_paths
|
||||||
|
# key (legacy alias, or a sub_type spanning two keys).
|
||||||
|
# Expected, so never a "fix your configuration" warning.
|
||||||
|
logger.debug(
|
||||||
|
"Ignoring duplicate folder '%s' for category '%s' "
|
||||||
|
"(already covered by '%s').",
|
||||||
|
business_path,
|
||||||
|
sub_type,
|
||||||
|
seen_business_path,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Detected the same folder '%s' under multiple other-model "
|
||||||
|
"categories ('%s' is already mapped as '%s'). Keeping the "
|
||||||
|
"first category; please fix your path configuration.",
|
||||||
|
business_path,
|
||||||
|
seen_business_path,
|
||||||
|
seen_sub_type,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
seen_real_paths[real_path] = (business_path, sub_type)
|
||||||
|
unique_paths.append(business_path)
|
||||||
|
key_roots.append(business_path)
|
||||||
|
sub_type_map[business_path] = sub_type
|
||||||
|
|
||||||
|
if real_path != business_path:
|
||||||
|
self.add_path_mapping(business_path, real_path)
|
||||||
|
|
||||||
|
covered_by = covered_real_paths.get(real_path)
|
||||||
|
if covered_by:
|
||||||
|
logger.warning(
|
||||||
|
"Detected an other-model root ('%s', category '%s') that "
|
||||||
|
"overlaps an existing checkpoints/embeddings root ('%s'). "
|
||||||
|
"The same files will appear on both pages; please review "
|
||||||
|
"your path configuration.",
|
||||||
|
business_path,
|
||||||
|
key,
|
||||||
|
covered_by,
|
||||||
|
)
|
||||||
|
if key_roots:
|
||||||
|
per_key_roots[key] = key_roots
|
||||||
|
|
||||||
|
return unique_paths, sub_type_map, per_key_roots
|
||||||
|
|
||||||
def _apply_library_paths(
|
def _apply_library_paths(
|
||||||
self,
|
self,
|
||||||
folder_paths: Mapping[str, Any],
|
folder_paths: Mapping[str, Any],
|
||||||
@@ -1151,6 +1318,16 @@ class Config:
|
|||||||
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
) = self._prepare_checkpoint_paths(checkpoint_paths, unet_paths)
|
||||||
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
self.embeddings_roots = self._prepare_embedding_paths(embedding_paths)
|
||||||
|
|
||||||
|
other_path_map = {
|
||||||
|
key: folder_paths.get(key, []) or []
|
||||||
|
for key in self._get_enabled_other_folder_keys()
|
||||||
|
}
|
||||||
|
(
|
||||||
|
self.other_roots,
|
||||||
|
self.other_root_subtypes,
|
||||||
|
self.other_folder_roots,
|
||||||
|
) = self._prepare_other_paths(other_path_map)
|
||||||
|
|
||||||
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
# Process extra paths (only for LoRA Manager, not shared with ComfyUI)
|
||||||
extra_paths = extra_folder_paths or {}
|
extra_paths = extra_folder_paths or {}
|
||||||
extra_lora_paths = extra_paths.get("loras", []) or []
|
extra_lora_paths = extra_paths.get("loras", []) or []
|
||||||
@@ -1267,6 +1444,104 @@ class Config:
|
|||||||
logger.warning(f"Error initializing embedding paths: {e}")
|
logger.warning(f"Error initializing embedding paths: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def _init_other_paths(self) -> List[str]:
|
||||||
|
"""Initialize and validate other-model paths from ComfyUI settings.
|
||||||
|
|
||||||
|
Iterates the enabled OTHER_MODEL_FOLDER_SUBTYPES keys and pulls each
|
||||||
|
from ``folder_paths.get_folder_paths(key)`` (in standalone mode the
|
||||||
|
mock serves arbitrary keys from ``settings.json.folder_paths``).
|
||||||
|
Legacy aliases the host normalizes onto a canonical key (``clip`` ->
|
||||||
|
``text_encoders``) are collapsed first so the same folders are not
|
||||||
|
reported twice.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
folder_path_map: Dict[str, List[str]] = {}
|
||||||
|
for key in self._collapse_legacy_folder_keys(
|
||||||
|
self._get_enabled_other_folder_keys()
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
folder_path_map[key] = folder_paths.get_folder_paths(key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Error reading folder paths for '%s': %s", key, exc)
|
||||||
|
|
||||||
|
(
|
||||||
|
unique_paths,
|
||||||
|
self.other_root_subtypes,
|
||||||
|
self.other_folder_roots,
|
||||||
|
) = self._prepare_other_paths(folder_path_map)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Found other model roots:"
|
||||||
|
+ ("\n - " + "\n - ".join(unique_paths) if unique_paths else "[]")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not unique_paths:
|
||||||
|
logger.info("No valid other-model folders found in configuration")
|
||||||
|
return []
|
||||||
|
|
||||||
|
return unique_paths
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error initializing other model paths: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def refresh_other_roots(self) -> None:
|
||||||
|
"""Rebuild other-model roots after the management toggles changed.
|
||||||
|
|
||||||
|
Called when ``enable_other_models`` / ``enabled_other_sub_types`` are
|
||||||
|
updated so the scanner immediately reflects the new folder set without
|
||||||
|
a full application restart.
|
||||||
|
"""
|
||||||
|
self.other_roots = self._init_other_paths()
|
||||||
|
self._rebuild_preview_roots()
|
||||||
|
|
||||||
|
def get_other_models_availability(self) -> Dict[str, Any]:
|
||||||
|
"""Report the other-model folders the host can actually expose.
|
||||||
|
|
||||||
|
Independent of the opt-in ``enable_other_models`` toggle: this answers
|
||||||
|
"could Other Models management work here at all?". ComfyUI mode almost
|
||||||
|
always has these folder keys registered, while standalone mode only
|
||||||
|
knows the keys present in ``settings.json.folder_paths`` - so the UI
|
||||||
|
uses this to decide whether announcing the feature would be actionable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``{"available": bool, "sub_types": {sub_type: [existing roots]}}``.
|
||||||
|
A folder only counts when it exists on disk; an empty folder still
|
||||||
|
counts because CivitAI downloads can target it.
|
||||||
|
"""
|
||||||
|
sub_types: Dict[str, List[str]] = {}
|
||||||
|
try:
|
||||||
|
keys = self._collapse_legacy_folder_keys(
|
||||||
|
list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - defensive
|
||||||
|
keys = list(OTHER_MODEL_FOLDER_SUBTYPES.keys())
|
||||||
|
|
||||||
|
for key in keys:
|
||||||
|
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
|
||||||
|
if not sub_type:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw_paths = folder_paths.get_folder_paths(key)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("Error probing folder paths for '%s': %s", key, exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
bucket = sub_types.setdefault(sub_type, [])
|
||||||
|
for root in sorted(
|
||||||
|
self._dedupe_existing_paths(raw_paths or []).values(),
|
||||||
|
key=lambda path: path.lower(),
|
||||||
|
):
|
||||||
|
if root not in bucket:
|
||||||
|
bucket.append(root)
|
||||||
|
|
||||||
|
available_sub_types = {
|
||||||
|
sub_type: roots for sub_type, roots in sub_types.items() if roots
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"available": bool(available_sub_types),
|
||||||
|
"sub_types": available_sub_types,
|
||||||
|
}
|
||||||
|
|
||||||
def get_preview_static_url(self, preview_path: str) -> str:
|
def get_preview_static_url(self, preview_path: str) -> str:
|
||||||
if not preview_path:
|
if not preview_path:
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
+7
-1
@@ -219,6 +219,7 @@ class LoraManager:
|
|||||||
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
lora_scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
checkpoint_scanner = await ServiceRegistry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
|
||||||
# Initialize recipe scanner if needed
|
# Initialize recipe scanner if needed
|
||||||
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
recipe_scanner = await ServiceRegistry.get_recipe_scanner()
|
||||||
@@ -236,6 +237,10 @@ class LoraManager:
|
|||||||
embedding_scanner.initialize_in_background(),
|
embedding_scanner.initialize_in_background(),
|
||||||
name="embedding_cache_init",
|
name="embedding_cache_init",
|
||||||
),
|
),
|
||||||
|
asyncio.create_task(
|
||||||
|
other_scanner.initialize_in_background(),
|
||||||
|
name="other_cache_init",
|
||||||
|
),
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
|
recipe_scanner.initialize_in_background(), name="recipe_cache_init"
|
||||||
),
|
),
|
||||||
@@ -328,6 +333,7 @@ class LoraManager:
|
|||||||
all_roots.update(config.loras_roots)
|
all_roots.update(config.loras_roots)
|
||||||
all_roots.update(config.base_models_roots or [])
|
all_roots.update(config.base_models_roots or [])
|
||||||
all_roots.update(config.embeddings_roots or [])
|
all_roots.update(config.embeddings_roots or [])
|
||||||
|
all_roots.update(config.other_roots or [])
|
||||||
|
|
||||||
total_deleted = 0
|
total_deleted = 0
|
||||||
total_size_freed = 0
|
total_size_freed = 0
|
||||||
@@ -460,7 +466,7 @@ class LoraManager:
|
|||||||
# Cancel any in-flight scanner initialization tasks so thread-pool
|
# Cancel any in-flight scanner initialization tasks so thread-pool
|
||||||
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
# workers (e.g. _initialize_cache_sync) can break out of their loops
|
||||||
# when the server shuts down (e.g. Ctrl+C on WSL).
|
# when the server shuts down (e.g. Ctrl+C on WSL).
|
||||||
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner"):
|
for name in ("lora_scanner", "checkpoint_scanner", "embedding_scanner", "other_scanner"):
|
||||||
scanner = ServiceRegistry.get_service_sync(name)
|
scanner = ServiceRegistry.get_service_sync(name)
|
||||||
if scanner is not None and hasattr(scanner, "cancel_task"):
|
if scanner is not None and hasattr(scanner, "cancel_task"):
|
||||||
scanner.cancel_task()
|
scanner.cancel_task()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ SCANNER_TYPE_MAP: dict[str, str] = {
|
|||||||
"get_lora_scanner": "lora",
|
"get_lora_scanner": "lora",
|
||||||
"get_checkpoint_scanner": "checkpoint",
|
"get_checkpoint_scanner": "checkpoint",
|
||||||
"get_embedding_scanner": "embedding",
|
"get_embedding_scanner": "embedding",
|
||||||
|
"get_other_scanner": "other",
|
||||||
}
|
}
|
||||||
|
|
||||||
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
SCANNER_GETTER_NAMES = tuple(SCANNER_TYPE_MAP.keys())
|
||||||
@@ -80,8 +81,8 @@ async def _find_scanner_for_model(
|
|||||||
|
|
||||||
|
|
||||||
async def identify_model_type(model_path: str) -> str:
|
async def identify_model_type(model_path: str) -> str:
|
||||||
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``, or
|
"""Determine the model type (``\"lora\"``, ``\"checkpoint\"``,
|
||||||
``\"embedding\"``) for *model_path*.
|
``\"embedding\"``, or ``\"other\"``) for *model_path*.
|
||||||
|
|
||||||
Falls back to ``\"lora\"`` when unknown.
|
Falls back to ``\"lora\"`` when unknown.
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class BaseModelRoutes(ABC):
|
|||||||
settings_service=self._settings,
|
settings_service=self._settings,
|
||||||
server_i18n=self._server_i18n,
|
server_i18n=self._server_i18n,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
|
page_context_provider=self._get_page_context_provider(),
|
||||||
)
|
)
|
||||||
listing = ModelListingHandler(
|
listing = ModelListingHandler(
|
||||||
service=service,
|
service=service,
|
||||||
@@ -250,6 +251,10 @@ class BaseModelRoutes(ABC):
|
|||||||
"""Get expected model types string for error messages - to be overridden by subclasses."""
|
"""Get expected model types string for error messages - to be overridden by subclasses."""
|
||||||
return "any model type"
|
return "any model type"
|
||||||
|
|
||||||
|
def _get_page_context_provider(self):
|
||||||
|
"""Optional hook returning extra template context for the page view."""
|
||||||
|
return None
|
||||||
|
|
||||||
def _find_model_file(self, files):
|
def _find_model_file(self, files):
|
||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||||
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
|
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""HTTP handler for download target routing decisions."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...services.download_routing import (
|
||||||
|
is_diffusion_model_download,
|
||||||
|
resolve_other_download_sub_type,
|
||||||
|
)
|
||||||
|
from ...utils.constants import VALID_OTHER_CIVITAI_TYPES
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadRoutingHandler:
|
||||||
|
"""Expose the download-time checkpoint/diffusion-model routing decision.
|
||||||
|
|
||||||
|
The web UI calls this when the user reaches the download location step
|
||||||
|
so the root dropdown offers the same root set (checkpoint vs unet) that
|
||||||
|
the download manager would pick for ``use_default_paths``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def get_download_routing(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON payload"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
model_type = payload.get("model_type", "")
|
||||||
|
base_model = payload.get("base_model") or ""
|
||||||
|
file_types = payload.get("file_types") or []
|
||||||
|
selected_file_type = payload.get("selected_file_type")
|
||||||
|
|
||||||
|
if not isinstance(model_type, str) or not model_type:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "model_type is required"}, status=400
|
||||||
|
)
|
||||||
|
if not isinstance(base_model, str) or not isinstance(file_types, list):
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "base_model must be a string and file_types a list",
|
||||||
|
},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
if selected_file_type is not None and not isinstance(selected_file_type, str):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "selected_file_type must be a string"},
|
||||||
|
status=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
if model_type.lower() in VALID_OTHER_CIVITAI_TYPES:
|
||||||
|
from ...services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
settings = get_settings_manager()
|
||||||
|
if not settings.is_other_models_enabled():
|
||||||
|
# Opt-in feature is off: never auto-route, the UI falls back to
|
||||||
|
# manual folder selection and the download manager rejects it.
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"root_kind": "other",
|
||||||
|
"sub_type": None,
|
||||||
|
"disabled": True,
|
||||||
|
"reason": "other_models_disabled",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sub_type = resolve_other_download_sub_type(
|
||||||
|
model_type,
|
||||||
|
file_types=(str(t) for t in file_types),
|
||||||
|
selected_file_type=selected_file_type,
|
||||||
|
)
|
||||||
|
if sub_type and not settings.is_other_sub_type_enabled(sub_type):
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"root_kind": "other",
|
||||||
|
"sub_type": None,
|
||||||
|
"disabled": True,
|
||||||
|
"reason": "other_sub_type_disabled",
|
||||||
|
"requested_sub_type": sub_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"root_kind": "other",
|
||||||
|
"sub_type": sub_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
is_diffusion = is_diffusion_model_download(
|
||||||
|
model_type,
|
||||||
|
file_types=(str(t) for t in file_types),
|
||||||
|
base_model=base_model,
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"is_diffusion_model": is_diffusion,
|
||||||
|
"root_kind": "unet" if is_diffusion else model_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -122,12 +122,8 @@ async def _save_hf_metadata(dest_path: str, repo: str, model_root: str) -> None:
|
|||||||
metadata._unknown_fields["hf_url"] = hf_url
|
metadata._unknown_fields["hf_url"] = hf_url
|
||||||
metadata.from_civitai = False # HF models are not from CivitAI
|
metadata.from_civitai = False # HF models are not from CivitAI
|
||||||
|
|
||||||
metadata_dict = metadata.to_dict()
|
|
||||||
if "trainedWords" in metadata_dict and not metadata_dict["trainedWords"]:
|
|
||||||
del metadata_dict["trainedWords"]
|
|
||||||
|
|
||||||
# 3. Save metadata atomically
|
# 3. Save metadata atomically
|
||||||
await MetadataManager.save_metadata(dest_path, metadata_dict)
|
await MetadataManager.save_metadata(dest_path, metadata)
|
||||||
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
logger.info("Saved HF metadata (with hf_url) for %s", dest_path)
|
||||||
|
|
||||||
# 4. Determine relative folder path for cache
|
# 4. Determine relative folder path for cache
|
||||||
@@ -244,7 +240,10 @@ class HfHandler:
|
|||||||
})
|
})
|
||||||
|
|
||||||
existing["hf_url"] = hf_url
|
existing["hf_url"] = hf_url
|
||||||
existing["from_civitai"] = False
|
# NOTE: deliberately do NOT touch `from_civitai` here. It records
|
||||||
|
# where the metadata came from, and the UI must show the CivitAI
|
||||||
|
# link whenever CivitAI data is present — linking HuggingFace must
|
||||||
|
# not hide it (#1094). HF provenance is tracked via `hf_url`.
|
||||||
await MetadataManager.save_metadata(file_path, existing)
|
await MetadataManager.save_metadata(file_path, existing)
|
||||||
|
|
||||||
await _add_to_scanner_cache(file_path, existing)
|
await _add_to_scanner_cache(file_path, existing)
|
||||||
|
|||||||
@@ -53,9 +53,11 @@ from ...utils.constants import (
|
|||||||
PREVIEW_EXTENSIONS,
|
PREVIEW_EXTENSIONS,
|
||||||
SUPPORTED_MEDIA_EXTENSIONS,
|
SUPPORTED_MEDIA_EXTENSIONS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
|
VALID_OTHER_CIVITAI_TYPES,
|
||||||
)
|
)
|
||||||
from .hf_handlers import HfHandler
|
from .hf_handlers import HfHandler
|
||||||
from .agent_handlers import AgentHandler
|
from .agent_handlers import AgentHandler
|
||||||
|
from .download_routing_handlers import DownloadRoutingHandler
|
||||||
from .model_handlers import ModelCivitaiHandler
|
from .model_handlers import ModelCivitaiHandler
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
from ...utils.example_images_paths import (
|
from ...utils.example_images_paths import (
|
||||||
@@ -657,9 +659,21 @@ class HealthCheckHandler:
|
|||||||
"lora": ServiceRegistry.get_lora_scanner,
|
"lora": ServiceRegistry.get_lora_scanner,
|
||||||
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
|
||||||
"embedding": ServiceRegistry.get_embedding_scanner,
|
"embedding": ServiceRegistry.get_embedding_scanner,
|
||||||
|
"other": ServiceRegistry.get_other_scanner,
|
||||||
"recipe": ServiceRegistry.get_recipe_scanner,
|
"recipe": ServiceRegistry.get_recipe_scanner,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _active_scanner_getters(
|
||||||
|
self,
|
||||||
|
) -> Mapping[str, Callable[[], Awaitable[Any]]]:
|
||||||
|
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||||
|
getters = self._scanner_getters
|
||||||
|
if "other" not in getters:
|
||||||
|
return getters
|
||||||
|
if get_settings_manager().is_other_models_enabled():
|
||||||
|
return getters
|
||||||
|
return {name: getter for name, getter in getters.items() if name != "other"}
|
||||||
|
|
||||||
async def health_check(self, request: web.Request) -> web.Response:
|
async def health_check(self, request: web.Request) -> web.Response:
|
||||||
return web.json_response({"status": "ok"})
|
return web.json_response({"status": "ok"})
|
||||||
|
|
||||||
@@ -671,7 +685,7 @@ class HealthCheckHandler:
|
|||||||
page accepts the update and only reloads once all scanners are done.
|
page accepts the update and only reloads once all scanners are done.
|
||||||
"""
|
"""
|
||||||
pending: list[str] = []
|
pending: list[str] = []
|
||||||
for name, getter in self._scanner_getters.items():
|
for name, getter in self._active_scanner_getters().items():
|
||||||
try:
|
try:
|
||||||
scanner = await getter()
|
scanner = await getter()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -756,10 +770,19 @@ class DoctorHandler:
|
|||||||
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
("lora", "LoRAs", ServiceRegistry.get_lora_scanner),
|
||||||
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
("checkpoint", "Checkpoints", ServiceRegistry.get_checkpoint_scanner),
|
||||||
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
("embedding", "Embeddings", ServiceRegistry.get_embedding_scanner),
|
||||||
|
("other", "Other Models", ServiceRegistry.get_other_scanner),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self._app_version_getter = app_version_getter
|
self._app_version_getter = app_version_getter
|
||||||
|
|
||||||
|
def _active_scanner_factories(
|
||||||
|
self,
|
||||||
|
) -> Sequence[tuple[str, str, Callable[[], Awaitable[Any]]]]:
|
||||||
|
"""Drop the opt-in other scanner while Other Models is disabled."""
|
||||||
|
if self._settings.is_other_models_enabled():
|
||||||
|
return self._scanner_factories
|
||||||
|
return tuple(entry for entry in self._scanner_factories if entry[0] != "other")
|
||||||
|
|
||||||
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
|
async def get_doctor_diagnostics(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
client_version = (request.query.get("clientVersion") or "").strip()
|
client_version = (request.query.get("clientVersion") or "").strip()
|
||||||
@@ -807,7 +830,7 @@ class DoctorHandler:
|
|||||||
repaired: list[dict[str, Any]] = []
|
repaired: list[dict[str, Any]] = []
|
||||||
failures: list[dict[str, str]] = []
|
failures: list[dict[str, str]] = []
|
||||||
|
|
||||||
for model_type, label, factory in self._scanner_factories:
|
for model_type, label, factory in self._active_scanner_factories():
|
||||||
try:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
await scanner.get_cached_data(force_refresh=True, rebuild_cache=True)
|
||||||
@@ -839,7 +862,7 @@ class DoctorHandler:
|
|||||||
renamed: list[dict[str, Any]] = []
|
renamed: list[dict[str, Any]] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for model_type, label, factory in self._scanner_factories:
|
for model_type, label, factory in self._active_scanner_factories():
|
||||||
try:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
hash_index = getattr(scanner, "_hash_index", None)
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
@@ -1071,7 +1094,7 @@ class DoctorHandler:
|
|||||||
overall_status = "ok"
|
overall_status = "ok"
|
||||||
summary = "All model caches look healthy."
|
summary = "All model caches look healthy."
|
||||||
|
|
||||||
for model_type, label, factory in self._scanner_factories:
|
for model_type, label, factory in self._active_scanner_factories():
|
||||||
try:
|
try:
|
||||||
scanner = await factory()
|
scanner = await factory()
|
||||||
persisted = None
|
persisted = None
|
||||||
@@ -1156,7 +1179,7 @@ class DoctorHandler:
|
|||||||
total_conflict_groups = 0
|
total_conflict_groups = 0
|
||||||
total_conflict_files = 0
|
total_conflict_files = 0
|
||||||
|
|
||||||
for model_type, label, factory in self._scanner_factories:
|
for model_type, label, factory in self._active_scanner_factories():
|
||||||
# Duplicate filename detection targets LoRAs which use basename-only
|
# Duplicate filename detection targets LoRAs which use basename-only
|
||||||
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
# syntax (<lora:name:strength>). Checkpoints/embeddings reference
|
||||||
# models via relative paths with extensions, so conflicts there would
|
# models via relative paths with extensions, so conflicts there would
|
||||||
@@ -1536,6 +1559,22 @@ class SettingsHandler:
|
|||||||
response_data["civitai_api_key_set"] = bool(raw_key)
|
response_data["civitai_api_key_set"] = bool(raw_key)
|
||||||
raw_llm_key = self._settings.get("llm_api_key")
|
raw_llm_key = self._settings.get("llm_api_key")
|
||||||
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
response_data["llm_api_key_set"] = bool(raw_llm_key)
|
||||||
|
# Derived capability flag (not persisted): whether the host exposes
|
||||||
|
# any other-model folder at all. Standalone installs only know the
|
||||||
|
# folder_paths keys present in settings.json, so the announcement
|
||||||
|
# banner uses this to avoid promising a page that cannot list
|
||||||
|
# anything.
|
||||||
|
try:
|
||||||
|
availability = config.get_other_models_availability()
|
||||||
|
response_data["other_models_paths_available"] = bool(
|
||||||
|
availability.get("available")
|
||||||
|
)
|
||||||
|
except Exception as availability_error: # pragma: no cover - defensive
|
||||||
|
logger.debug(
|
||||||
|
"Could not resolve Other Models availability: %s",
|
||||||
|
availability_error,
|
||||||
|
)
|
||||||
|
response_data["other_models_paths_available"] = None
|
||||||
settings_file = getattr(self._settings, "settings_file", None)
|
settings_file = getattr(self._settings, "settings_file", None)
|
||||||
if settings_file:
|
if settings_file:
|
||||||
response_data["settings_file"] = settings_file
|
response_data["settings_file"] = settings_file
|
||||||
@@ -2065,6 +2104,7 @@ class ServiceRegistryAdapter:
|
|||||||
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
get_embedding_scanner: Callable[[], Awaitable[Any]]
|
||||||
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
get_downloaded_version_history_service: Callable[[], Awaitable[Any]]
|
||||||
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
get_backup_service: Callable[[], Awaitable[Any]] = _noop_backup_service
|
||||||
|
get_other_scanner: Callable[[], Awaitable[Any]] = ServiceRegistry.get_other_scanner
|
||||||
|
|
||||||
|
|
||||||
class ModelLibraryHandler:
|
class ModelLibraryHandler:
|
||||||
@@ -2089,6 +2129,8 @@ class ModelLibraryHandler:
|
|||||||
return "checkpoint"
|
return "checkpoint"
|
||||||
if normalized in {"embedding", "textualinversion"}:
|
if normalized in {"embedding", "textualinversion"}:
|
||||||
return "embedding"
|
return "embedding"
|
||||||
|
if normalized in VALID_OTHER_CIVITAI_TYPES:
|
||||||
|
return "other"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def _get_scanner_for_type(self, model_type: str | None):
|
async def _get_scanner_for_type(self, model_type: str | None):
|
||||||
@@ -2099,6 +2141,13 @@ class ModelLibraryHandler:
|
|||||||
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
return normalized_type, await self._service_registry.get_checkpoint_scanner()
|
||||||
if normalized_type == "embedding":
|
if normalized_type == "embedding":
|
||||||
return normalized_type, await self._service_registry.get_embedding_scanner()
|
return normalized_type, await self._service_registry.get_embedding_scanner()
|
||||||
|
if normalized_type == "other":
|
||||||
|
# Opt-in feature: the other scanner only resolves while the master
|
||||||
|
# switch is on, so callers keep returning the legacy "required"
|
||||||
|
# error (400) when it is off.
|
||||||
|
if not get_settings_manager().is_other_models_enabled():
|
||||||
|
return None, None
|
||||||
|
return normalized_type, await self._service_registry.get_other_scanner()
|
||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
async def _get_download_history_service(self):
|
async def _get_download_history_service(self):
|
||||||
@@ -2190,6 +2239,11 @@ class ModelLibraryHandler:
|
|||||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
# Opt-in: probe the other scanner only while Other Models is enabled,
|
||||||
|
# so the disabled behaviour stays byte-identical to the legacy one.
|
||||||
|
other_scanner = None
|
||||||
|
if get_settings_manager().is_other_models_enabled():
|
||||||
|
other_scanner = await self._service_registry.get_other_scanner()
|
||||||
|
|
||||||
if model_version_id_str:
|
if model_version_id_str:
|
||||||
try:
|
try:
|
||||||
@@ -2228,6 +2282,13 @@ class ModelLibraryHandler:
|
|||||||
exists = True
|
exists = True
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
matched_scanner = embedding_scanner
|
matched_scanner = embedding_scanner
|
||||||
|
elif (
|
||||||
|
other_scanner
|
||||||
|
and await other_scanner.check_model_version_exists(model_version_id)
|
||||||
|
):
|
||||||
|
exists = True
|
||||||
|
model_type = "other"
|
||||||
|
matched_scanner = other_scanner
|
||||||
|
|
||||||
if exists:
|
if exists:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2245,7 +2306,7 @@ class ModelLibraryHandler:
|
|||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
has_been_downloaded = False
|
has_been_downloaded = False
|
||||||
history_type = None
|
history_type = None
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
|
||||||
if await history_service.has_been_downloaded(
|
if await history_service.has_been_downloaded(
|
||||||
candidate_type,
|
candidate_type,
|
||||||
model_version_id,
|
model_version_id,
|
||||||
@@ -2267,6 +2328,7 @@ class ModelLibraryHandler:
|
|||||||
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
lora_versions = await lora_scanner.get_model_versions_by_id(model_id)
|
||||||
checkpoint_versions = []
|
checkpoint_versions = []
|
||||||
embedding_versions = []
|
embedding_versions = []
|
||||||
|
other_versions = []
|
||||||
if not lora_versions and checkpoint_scanner:
|
if not lora_versions and checkpoint_scanner:
|
||||||
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
checkpoint_versions = await checkpoint_scanner.get_model_versions_by_id(
|
||||||
model_id
|
model_id
|
||||||
@@ -2275,6 +2337,13 @@ class ModelLibraryHandler:
|
|||||||
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
embedding_versions = await embedding_scanner.get_model_versions_by_id(
|
||||||
model_id
|
model_id
|
||||||
)
|
)
|
||||||
|
if (
|
||||||
|
not lora_versions
|
||||||
|
and not checkpoint_versions
|
||||||
|
and not embedding_versions
|
||||||
|
and other_scanner
|
||||||
|
):
|
||||||
|
other_versions = await other_scanner.get_model_versions_by_id(model_id)
|
||||||
|
|
||||||
model_type = None
|
model_type = None
|
||||||
versions = []
|
versions = []
|
||||||
@@ -2306,9 +2375,18 @@ class ModelLibraryHandler:
|
|||||||
"downloadedVersionIds": [],
|
"downloadedVersionIds": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
if other_versions:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"modelType": "other",
|
||||||
|
"versions": self._with_downloaded_flag(other_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
for candidate_type in ("lora", "checkpoint", "embedding"):
|
for candidate_type in ("lora", "checkpoint", "embedding", "other"):
|
||||||
candidate_downloaded_version_ids = (
|
candidate_downloaded_version_ids = (
|
||||||
await history_service.get_downloaded_version_ids(
|
await history_service.get_downloaded_version_ids(
|
||||||
candidate_type,
|
candidate_type,
|
||||||
@@ -2363,6 +2441,11 @@ class ModelLibraryHandler:
|
|||||||
lora_scanner = await self._service_registry.get_lora_scanner()
|
lora_scanner = await self._service_registry.get_lora_scanner()
|
||||||
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
checkpoint_scanner = await self._service_registry.get_checkpoint_scanner()
|
||||||
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
embedding_scanner = await self._service_registry.get_embedding_scanner()
|
||||||
|
# Opt-in: keep the other probe last so model cards for lora /
|
||||||
|
# checkpoint / embedding ids are unaffected by the extra scanner.
|
||||||
|
other_scanner = None
|
||||||
|
if get_settings_manager().is_other_models_enabled():
|
||||||
|
other_scanner = await self._service_registry.get_other_scanner()
|
||||||
|
|
||||||
results: list[dict[str, Any]] = []
|
results: list[dict[str, Any]] = []
|
||||||
for model_id in model_ids:
|
for model_id in model_ids:
|
||||||
@@ -2398,6 +2481,17 @@ class ModelLibraryHandler:
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
if other_scanner:
|
||||||
|
other_versions = await other_scanner.get_model_versions_by_id(model_id)
|
||||||
|
if other_versions:
|
||||||
|
results.append({
|
||||||
|
"modelId": model_id,
|
||||||
|
"modelType": "other",
|
||||||
|
"versions": self._with_downloaded_flag(other_versions),
|
||||||
|
"downloadedVersionIds": [],
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"modelId": model_id,
|
"modelId": model_id,
|
||||||
"modelType": None,
|
"modelType": None,
|
||||||
@@ -2786,12 +2880,32 @@ class ModelLibraryHandler:
|
|||||||
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
model_type.lower() for model_type in CIVITAI_USER_MODEL_TYPES
|
||||||
}
|
}
|
||||||
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
lora_type_aliases = {model_type.lower() for model_type in VALID_LORA_TYPES}
|
||||||
|
other_type_aliases = {
|
||||||
|
model_type.lower() for model_type in VALID_OTHER_CIVITAI_TYPES
|
||||||
|
}
|
||||||
|
|
||||||
|
# Acquire the other scanner lazily so adapters without it only
|
||||||
|
# fail when the payload actually contains other-type models.
|
||||||
|
# While the opt-in feature is off the scanner still exists (its
|
||||||
|
# cache is empty), so other types simply report inLibrary=False.
|
||||||
|
needs_other_scanner = any(
|
||||||
|
isinstance(model, dict)
|
||||||
|
and str(model.get("type", "")).lower() in other_type_aliases
|
||||||
|
for model in models
|
||||||
|
)
|
||||||
|
other_scanner = None
|
||||||
|
if needs_other_scanner:
|
||||||
|
other_scanner = await self._service_registry.get_other_scanner()
|
||||||
|
|
||||||
type_scanner_map: Dict[str, Any] = {
|
type_scanner_map: Dict[str, Any] = {
|
||||||
**{alias: lora_scanner for alias in lora_type_aliases},
|
**{alias: lora_scanner for alias in lora_type_aliases},
|
||||||
"checkpoint": checkpoint_scanner,
|
"checkpoint": checkpoint_scanner,
|
||||||
"textualinversion": embedding_scanner,
|
"textualinversion": embedding_scanner,
|
||||||
}
|
}
|
||||||
|
if other_scanner is not None:
|
||||||
|
type_scanner_map.update(
|
||||||
|
{alias: other_scanner for alias in other_type_aliases}
|
||||||
|
)
|
||||||
|
|
||||||
versions: list[dict[str, Any]] = []
|
versions: list[dict[str, Any]] = []
|
||||||
history_service = await self._get_download_history_service()
|
history_service = await self._get_download_history_service()
|
||||||
@@ -2815,12 +2929,17 @@ class ModelLibraryHandler:
|
|||||||
"embedding",
|
"embedding",
|
||||||
model_ids,
|
model_ids,
|
||||||
)
|
)
|
||||||
|
other_downloaded = await history_service.get_downloaded_version_ids_bulk(
|
||||||
|
"other",
|
||||||
|
model_ids,
|
||||||
|
)
|
||||||
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
downloaded_version_map: Dict[str, Dict[int, set[int]]] = {
|
||||||
"lora": lora_downloaded,
|
"lora": lora_downloaded,
|
||||||
"locon": lora_downloaded,
|
"locon": lora_downloaded,
|
||||||
"dora": lora_downloaded,
|
"dora": lora_downloaded,
|
||||||
"checkpoint": checkpoint_downloaded,
|
"checkpoint": checkpoint_downloaded,
|
||||||
"textualinversion": embedding_downloaded,
|
"textualinversion": embedding_downloaded,
|
||||||
|
**{alias: other_downloaded for alias in VALID_OTHER_CIVITAI_TYPES},
|
||||||
}
|
}
|
||||||
for model in models:
|
for model in models:
|
||||||
if not isinstance(model, dict):
|
if not isinstance(model, dict):
|
||||||
@@ -3884,6 +4003,7 @@ class MiscHandlerSet:
|
|||||||
base_model: BaseModelHandlerSet,
|
base_model: BaseModelHandlerSet,
|
||||||
hf_handler: Any = None,
|
hf_handler: Any = None,
|
||||||
agent_handler: Any = None,
|
agent_handler: Any = None,
|
||||||
|
download_routing: Any = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.health = health
|
self.health = health
|
||||||
self.settings = settings
|
self.settings = settings
|
||||||
@@ -3904,6 +4024,7 @@ class MiscHandlerSet:
|
|||||||
self.base_model = base_model
|
self.base_model = base_model
|
||||||
self.hf_handler = hf_handler
|
self.hf_handler = hf_handler
|
||||||
self.agent_handler = agent_handler
|
self.agent_handler = agent_handler
|
||||||
|
self.download_routing = download_routing
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
self,
|
self,
|
||||||
@@ -3962,6 +4083,8 @@ class MiscHandlerSet:
|
|||||||
"get_agent_skills": self.agent_handler.get_agent_skills,
|
"get_agent_skills": self.agent_handler.get_agent_skills,
|
||||||
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
"execute_agent_skill": self.agent_handler.execute_agent_skill,
|
||||||
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
"cancel_agent_skill": self.agent_handler.cancel_agent_skill,
|
||||||
|
# Download routing handler
|
||||||
|
"get_download_routing": self.download_routing.get_download_routing,
|
||||||
# Base model handlers
|
# Base model handlers
|
||||||
"get_base_models": self.base_model.get_base_models,
|
"get_base_models": self.base_model.get_base_models,
|
||||||
"refresh_base_models": self.base_model.refresh_base_models,
|
"refresh_base_models": self.base_model.refresh_base_models,
|
||||||
@@ -3975,6 +4098,7 @@ def build_service_registry_adapter() -> ServiceRegistryAdapter:
|
|||||||
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
get_lora_scanner=ServiceRegistry.get_lora_scanner,
|
||||||
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
get_checkpoint_scanner=ServiceRegistry.get_checkpoint_scanner,
|
||||||
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
get_embedding_scanner=ServiceRegistry.get_embedding_scanner,
|
||||||
|
get_other_scanner=ServiceRegistry.get_other_scanner,
|
||||||
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
get_downloaded_version_history_service=ServiceRegistry.get_downloaded_version_history_service,
|
||||||
get_backup_service=ServiceRegistry.get_backup_service,
|
get_backup_service=ServiceRegistry.get_backup_service,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class ModelPageView:
|
|||||||
settings_service: SettingsManager,
|
settings_service: SettingsManager,
|
||||||
server_i18n,
|
server_i18n,
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
|
page_context_provider: Callable[[web.Request], Dict[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._template_env = template_env
|
self._template_env = template_env
|
||||||
self._template_name = template_name
|
self._template_name = template_name
|
||||||
@@ -97,6 +98,7 @@ class ModelPageView:
|
|||||||
self._settings = settings_service
|
self._settings = settings_service
|
||||||
self._server_i18n = server_i18n
|
self._server_i18n = server_i18n
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
self._page_context_provider = page_context_provider
|
||||||
|
|
||||||
def _load_supporters(self) -> dict[str, Any]:
|
def _load_supporters(self) -> dict[str, Any]:
|
||||||
"""Load supporters data from JSON file."""
|
"""Load supporters data from JSON file."""
|
||||||
@@ -210,6 +212,16 @@ class ModelPageView:
|
|||||||
self._logger.error("Error loading cache data: %s", cache_error)
|
self._logger.error("Error loading cache data: %s", cache_error)
|
||||||
template_context["is_initializing"] = True
|
template_context["is_initializing"] = True
|
||||||
|
|
||||||
|
if self._page_context_provider is not None:
|
||||||
|
try:
|
||||||
|
extra_context = self._page_context_provider(request)
|
||||||
|
if isinstance(extra_context, dict):
|
||||||
|
template_context.update(extra_context)
|
||||||
|
except Exception as context_error: # pragma: no cover - logging path
|
||||||
|
self._logger.error(
|
||||||
|
"Error building page context: %s", context_error
|
||||||
|
)
|
||||||
|
|
||||||
rendered = self._template_env.get_template(self._template_name).render(
|
rendered = self._template_env.get_template(self._template_name).render(
|
||||||
**template_context
|
**template_context
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ _MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
|||||||
"loras": "get_lora_scanner",
|
"loras": "get_lora_scanner",
|
||||||
"checkpoints": "get_checkpoint_scanner",
|
"checkpoints": "get_checkpoint_scanner",
|
||||||
"embeddings": "get_embedding_scanner",
|
"embeddings": "get_embedding_scanner",
|
||||||
|
"other": "get_other_scanner",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||||
|
|||||||
@@ -74,6 +74,26 @@ async def _read_preview_dims(path: str) -> Optional[Tuple[int, int]]:
|
|||||||
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
return await asyncio.to_thread(ExifUtils.get_image_dimensions, path)
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_relaxed_flag(request: web.Request) -> bool:
|
||||||
|
"""Read the relaxed-rematch flag from the JSON body or query string.
|
||||||
|
|
||||||
|
The flag defaults to False (strict candidacy). A JSON body value wins;
|
||||||
|
``?relaxed=true`` is honored as a fallback so GET-only clients can opt
|
||||||
|
in. Body parse failures (empty/invalid JSON) are treated as "no flag".
|
||||||
|
"""
|
||||||
|
relaxed = False
|
||||||
|
if request.can_read_body:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception: # noqa: BLE001 - any parse failure means no flag
|
||||||
|
data = None
|
||||||
|
if isinstance(data, dict):
|
||||||
|
relaxed = bool(data.get("relaxed"))
|
||||||
|
if not relaxed:
|
||||||
|
relaxed = request.query.get("relaxed", "").lower() == "true"
|
||||||
|
return relaxed
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class RecipeHandlerSet:
|
class RecipeHandlerSet:
|
||||||
"""Group of handlers providing recipe route implementations."""
|
"""Group of handlers providing recipe route implementations."""
|
||||||
@@ -812,6 +832,8 @@ class RecipeManagementHandler:
|
|||||||
|
|
||||||
recipe_scanner.reset_cancellation()
|
recipe_scanner.reset_cancellation()
|
||||||
|
|
||||||
|
relaxed = await _parse_relaxed_flag(request)
|
||||||
|
|
||||||
async def progress_callback(data):
|
async def progress_callback(data):
|
||||||
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
await self._ws_manager.broadcast_recipe_rematch_progress(data)
|
||||||
|
|
||||||
@@ -819,7 +841,8 @@ class RecipeManagementHandler:
|
|||||||
async def run_rematch():
|
async def run_rematch():
|
||||||
try:
|
try:
|
||||||
await recipe_scanner.rematch_all_recipes(
|
await recipe_scanner.rematch_all_recipes(
|
||||||
progress_callback=progress_callback
|
progress_callback=progress_callback,
|
||||||
|
relaxed=relaxed,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -892,7 +915,13 @@ class RecipeManagementHandler:
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await recipe_scanner.rematch_recipes_bulk(recipe_ids)
|
relaxed = bool(data.get("relaxed")) or (
|
||||||
|
request.query.get("relaxed", "").lower() == "true"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await recipe_scanner.rematch_recipes_bulk(
|
||||||
|
recipe_ids, relaxed=relaxed
|
||||||
|
)
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error(
|
self._logger.error(
|
||||||
@@ -921,7 +950,10 @@ class RecipeManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
recipe_id = request.match_info["recipe_id"]
|
recipe_id = request.match_info["recipe_id"]
|
||||||
result = await recipe_scanner.rematch_recipe_by_id(recipe_id)
|
relaxed = await _parse_relaxed_flag(request)
|
||||||
|
result = await recipe_scanner.rematch_recipe_by_id(
|
||||||
|
recipe_id, relaxed=relaxed
|
||||||
|
)
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except RecipeNotFoundError as exc:
|
except RecipeNotFoundError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
@@ -3092,6 +3124,12 @@ class RecipeWorkflowHandler:
|
|||||||
class BatchImportHandler:
|
class BatchImportHandler:
|
||||||
"""Handle batch import operations for recipes."""
|
"""Handle batch import operations for recipes."""
|
||||||
|
|
||||||
|
# Virtual path token for the Windows drive list. Browsing up from a drive
|
||||||
|
# root (e.g. C:\) lands here so users can switch drives without typing a
|
||||||
|
# path. Only meaningful on Windows; elsewhere it falls through to normal
|
||||||
|
# path handling and fails the existence check.
|
||||||
|
WINDOWS_DRIVES_TOKEN = "__drives__"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -3265,31 +3303,27 @@ class BatchImportHandler:
|
|||||||
data = await request.json()
|
data = await request.json()
|
||||||
directory_path = data.get("path", "")
|
directory_path = data.get("path", "")
|
||||||
|
|
||||||
|
if os.name == "nt" and directory_path == self.WINDOWS_DRIVES_TOKEN:
|
||||||
|
return self._windows_drives_response()
|
||||||
|
|
||||||
|
# Default to the user's home directory. The frontend previously
|
||||||
|
# sent "/" as the initial path, which is POSIX-only: on Windows it
|
||||||
|
# resolves to the current drive root and then fails the access
|
||||||
|
# check below.
|
||||||
if not directory_path:
|
if not directory_path:
|
||||||
return web.json_response(
|
path = Path.home()
|
||||||
{"success": False, "error": "Directory path is required"},
|
else:
|
||||||
status=400,
|
path = Path(directory_path).expanduser().resolve()
|
||||||
)
|
|
||||||
|
|
||||||
# Normalize the path
|
# Access check: browsing intentionally covers the whole server
|
||||||
path = Path(directory_path).expanduser().resolve()
|
# filesystem (the server operator browses their own machine). On
|
||||||
|
# POSIX every absolute path is under "/", but Path("/") has no
|
||||||
# Security check: ensure path is within allowed directories
|
# drive letter on Windows and can never anchor a drive-qualified
|
||||||
# Allow common image/model directories
|
# path in relative_to(), so test for a drive there instead.
|
||||||
allowed_roots = [
|
if os.name == "nt":
|
||||||
Path.home(),
|
is_allowed = bool(path.drive)
|
||||||
Path("/"), # Allow browsing from root for flexibility
|
else:
|
||||||
]
|
is_allowed = path.is_absolute()
|
||||||
|
|
||||||
# Check if path is within any allowed root
|
|
||||||
is_allowed = False
|
|
||||||
for root in allowed_roots:
|
|
||||||
try:
|
|
||||||
path.relative_to(root)
|
|
||||||
is_allowed = True
|
|
||||||
break
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not is_allowed:
|
if not is_allowed:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -3356,15 +3390,24 @@ class BatchImportHandler:
|
|||||||
directories.sort(key=lambda x: x["name"].lower())
|
directories.sort(key=lambda x: x["name"].lower())
|
||||||
image_files.sort(key=lambda x: x["name"].lower())
|
image_files.sort(key=lambda x: x["name"].lower())
|
||||||
|
|
||||||
# Add parent directory if not at root
|
# Parent directory. A filesystem root is its own parent
|
||||||
parent_path = path.parent
|
# (parent == path): POSIX "/" gets no parent, while a Windows
|
||||||
show_parent = str(path) != str(path.root)
|
# drive root (C:\) links up to the virtual drive list so users
|
||||||
|
# can switch drives. The previous str(path) != str(path.root)
|
||||||
|
# check misfired on Windows, where a drive root's parent is
|
||||||
|
# itself, producing an infinite self-loop.
|
||||||
|
if path.parent == path:
|
||||||
|
parent_path = (
|
||||||
|
self.WINDOWS_DRIVES_TOKEN if os.name == "nt" else None
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
parent_path = str(path.parent)
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"current_path": str(path),
|
"current_path": str(path),
|
||||||
"parent_path": str(parent_path) if show_parent else None,
|
"parent_path": parent_path,
|
||||||
"directories": directories,
|
"directories": directories,
|
||||||
"image_files": image_files,
|
"image_files": image_files,
|
||||||
"image_count": len(image_files),
|
"image_count": len(image_files),
|
||||||
@@ -3391,3 +3434,30 @@ class BatchImportHandler:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
self._logger.error("Error browsing directory: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
def _windows_drives_response(self) -> web.Response:
|
||||||
|
"""List available drive letters as a virtual directory (Windows only)."""
|
||||||
|
try:
|
||||||
|
drives = os.listdrives()
|
||||||
|
except AttributeError: # Python < 3.12
|
||||||
|
drives = [
|
||||||
|
f"{letter}:\\"
|
||||||
|
for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
if os.path.exists(f"{letter}:\\")
|
||||||
|
]
|
||||||
|
directories = [
|
||||||
|
{"name": drive, "path": drive, "is_parent": False} for drive in drives
|
||||||
|
]
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
# Empty current_path marks the virtual level; the frontend
|
||||||
|
# disables folder selection there.
|
||||||
|
"current_path": "",
|
||||||
|
"parent_path": None,
|
||||||
|
"directories": directories,
|
||||||
|
"image_files": [],
|
||||||
|
"image_count": 0,
|
||||||
|
"directory_count": len(directories),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
"GET", "/api/lm/hf-repo-files", "get_hf_repo_files"
|
||||||
),
|
),
|
||||||
|
# Download target routing decision (checkpoint vs diffusion model roots)
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/download/routing", "get_download_routing"
|
||||||
|
),
|
||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
"POST", "/api/lm/download-hf-model", "download_hf_model"
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from .handlers.misc_handlers import (
|
|||||||
from .handlers.base_model_handlers import BaseModelHandlerSet
|
from .handlers.base_model_handlers import BaseModelHandlerSet
|
||||||
from .handlers.hf_handlers import HfHandler
|
from .handlers.hf_handlers import HfHandler
|
||||||
from .handlers.agent_handlers import AgentHandler
|
from .handlers.agent_handlers import AgentHandler
|
||||||
|
from .handlers.download_routing_handlers import DownloadRoutingHandler
|
||||||
from .misc_route_registrar import MiscRouteRegistrar
|
from .misc_route_registrar import MiscRouteRegistrar
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -140,6 +141,7 @@ class MiscRoutes:
|
|||||||
base_model = BaseModelHandlerSet()
|
base_model = BaseModelHandlerSet()
|
||||||
hf_handler = HfHandler()
|
hf_handler = HfHandler()
|
||||||
agent_handler = AgentHandler()
|
agent_handler = AgentHandler()
|
||||||
|
download_routing = DownloadRoutingHandler()
|
||||||
|
|
||||||
return self._handler_set_factory(
|
return self._handler_set_factory(
|
||||||
health=health,
|
health=health,
|
||||||
@@ -161,6 +163,7 @@ class MiscRoutes:
|
|||||||
base_model=base_model,
|
base_model=base_model,
|
||||||
hf_handler=hf_handler,
|
hf_handler=hf_handler,
|
||||||
agent_handler=agent_handler,
|
agent_handler=agent_handler,
|
||||||
|
download_routing=download_routing,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .base_model_routes import BaseModelRoutes
|
||||||
|
from .model_route_registrar import ModelRouteRegistrar
|
||||||
|
from ..config import config
|
||||||
|
from ..services.other_model_service import OtherModelService
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..utils.constants import (
|
||||||
|
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES,
|
||||||
|
VALID_OTHER_CIVITAI_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OtherRoutes(BaseModelRoutes):
|
||||||
|
"""Other-model-specific route controller (VAE, upscaler, text encoder, ...)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize Other-model routes with OtherModel service"""
|
||||||
|
super().__init__()
|
||||||
|
self.template_name = "other.html"
|
||||||
|
|
||||||
|
async def initialize_services(self):
|
||||||
|
"""Initialize services from ServiceRegistry"""
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
update_service = await ServiceRegistry.get_model_update_service()
|
||||||
|
self.service = OtherModelService(other_scanner, update_service=update_service)
|
||||||
|
self.set_model_update_service(update_service)
|
||||||
|
|
||||||
|
# Attach service dependencies
|
||||||
|
self.attach_service(self.service)
|
||||||
|
|
||||||
|
def setup_routes(self, app: web.Application, prefix: str = "other"):
|
||||||
|
"""Setup Other-model routes"""
|
||||||
|
# Schedule service initialization on app startup
|
||||||
|
app.on_startup.append(lambda _: self.initialize_services())
|
||||||
|
|
||||||
|
# Setup common routes with 'other' prefix (includes page route)
|
||||||
|
super().setup_routes(app, prefix)
|
||||||
|
|
||||||
|
def setup_specific_routes(self, registrar: ModelRouteRegistrar, prefix: str):
|
||||||
|
"""Setup Other-model-specific routes"""
|
||||||
|
# Other-model info by name
|
||||||
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/info/{name}', prefix, self.get_other_model_info)
|
||||||
|
# Other-model roots grouped by sub_type (text_encoders + legacy clip
|
||||||
|
# are aggregated under text_encoder)
|
||||||
|
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/roots_by_subtype', prefix, self.get_roots_by_subtype)
|
||||||
|
|
||||||
|
def _validate_civitai_model_type(self, model_type: str) -> bool:
|
||||||
|
"""Validate CivitAI model type for other models.
|
||||||
|
|
||||||
|
Accepts retired CivitAI types (CLIP, CLIPVision) as well — grandfathered
|
||||||
|
models on CivitAI still carry them. Types whose sub_type is currently
|
||||||
|
disabled (or every type while the opt-in feature is off) are rejected.
|
||||||
|
"""
|
||||||
|
normalized = (model_type or "").strip().lower()
|
||||||
|
if normalized not in VALID_OTHER_CIVITAI_TYPES:
|
||||||
|
return False
|
||||||
|
if not self._settings.is_other_models_enabled():
|
||||||
|
return False
|
||||||
|
|
||||||
|
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized)
|
||||||
|
if sub_type is None:
|
||||||
|
# CivitAI "Other" has no sub_type of its own; it is only usable
|
||||||
|
# while at least one sub_type is enabled.
|
||||||
|
return bool(self._settings.get_enabled_other_sub_types())
|
||||||
|
return self._settings.is_other_sub_type_enabled(sub_type)
|
||||||
|
|
||||||
|
def _get_page_context_provider(self):
|
||||||
|
"""Expose the opt-in feature state to the Other Models page template."""
|
||||||
|
return self._page_context_for_other
|
||||||
|
|
||||||
|
def _page_context_for_other(self, request: web.Request) -> Dict[str, Any]:
|
||||||
|
if not self._settings.is_other_models_enabled():
|
||||||
|
return {"other_disabled": True, "other_no_paths": False}
|
||||||
|
|
||||||
|
# Enabled but nothing to scan: folder paths for the managed sub_types
|
||||||
|
# resolved to no existing folder. Render an actionable empty state
|
||||||
|
# instead of an apparently broken empty grid.
|
||||||
|
standalone_mode = os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1"
|
||||||
|
return {
|
||||||
|
"other_disabled": False,
|
||||||
|
"other_no_paths": not bool(config.other_roots),
|
||||||
|
"standalone_mode": standalone_mode,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_expected_model_types(self) -> str:
|
||||||
|
"""Get expected model types string for error messages"""
|
||||||
|
return "VAE, Upscaler, TextEncoder, CLIPVision, Controlnet, or Other"
|
||||||
|
|
||||||
|
def _parse_specific_params(self, request: web.Request) -> Dict[str, Any]:
|
||||||
|
"""Parse other-model-specific parameters (none in Phase 1)."""
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def get_roots_by_subtype(self, request: web.Request) -> web.Response:
|
||||||
|
"""Return other-model roots grouped by sub_type.
|
||||||
|
|
||||||
|
Aggregates the per-folder_paths-key roots from config
|
||||||
|
(``text_encoders`` and the legacy ``clip`` key both land under
|
||||||
|
``text_encoder``).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
roots_by_subtype: Dict[str, List[str]] = {}
|
||||||
|
for key, roots in (config.other_folder_roots or {}).items():
|
||||||
|
sub_type = OTHER_MODEL_FOLDER_SUBTYPES.get(key)
|
||||||
|
if not sub_type:
|
||||||
|
continue
|
||||||
|
bucket = roots_by_subtype.setdefault(sub_type, [])
|
||||||
|
for root in roots:
|
||||||
|
if root and root not in bucket:
|
||||||
|
bucket.append(root)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": True, "roots_by_subtype": roots_by_subtype}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting other roots by sub_type: {e}", exc_info=True)
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": str(e)}, status=500
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_other_model_info(self, request: web.Request) -> web.Response:
|
||||||
|
"""Get detailed information for a specific other model by name"""
|
||||||
|
try:
|
||||||
|
name = request.match_info.get('name', '')
|
||||||
|
model_info = await self.service.get_model_info_by_name(name) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
|
|
||||||
|
if model_info:
|
||||||
|
return web.json_response(model_info)
|
||||||
|
else:
|
||||||
|
return web.json_response({"error": "Model not found"}, status=404)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in get_other_model_info: {e}", exc_info=True)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
@@ -407,7 +407,6 @@ class AgentService:
|
|||||||
"base_model": metadata.get("base_model", ""),
|
"base_model": metadata.get("base_model", ""),
|
||||||
"tags": metadata.get("tags", []),
|
"tags": metadata.get("tags", []),
|
||||||
"modelDescription": metadata.get("modelDescription", ""),
|
"modelDescription": metadata.get("modelDescription", ""),
|
||||||
"trainedWords": metadata.get("trainedWords", []),
|
|
||||||
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
"sha256": (metadata.get("sha256") or "")[:16] + "..." if metadata.get("sha256") else "",
|
||||||
"size": metadata.get("size", 0),
|
"size": metadata.get("size", 0),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,10 @@ class PostProcessor:
|
|||||||
preview_downloaded = False
|
preview_downloaded = False
|
||||||
|
|
||||||
# -- Determine whether this is an HF-sourced model -----------------
|
# -- Determine whether this is an HF-sourced model -----------------
|
||||||
is_hf_model = not metadata.get("from_civitai", True)
|
# Key off `hf_url` directly: `from_civitai` records provenance and can
|
||||||
|
# be true for a model that is also linked to HuggingFace (both sources
|
||||||
|
# coexist, see #1094), so it must not gate HF enrichment.
|
||||||
|
is_hf_model = bool(metadata.get("hf_url", ""))
|
||||||
|
|
||||||
# -- Collect updates -----------------------------------------------
|
# -- Collect updates -----------------------------------------------
|
||||||
updates: Dict[str, Any] = {}
|
updates: Dict[str, Any] = {}
|
||||||
|
|||||||
@@ -161,6 +161,11 @@ class Aria2Downloader:
|
|||||||
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
(typically an expired CivitAI signed URL): a fresh URL is resolved
|
||||||
and the partial download continues. Recovery is bounded by
|
and the partial download continues. Recovery is bounded by
|
||||||
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
``MAX_TRANSFER_RECOVERY_ATTEMPTS``.
|
||||||
|
|
||||||
|
Cancellation never leaks daemon transfers: the gid is tracked in
|
||||||
|
``_transfers`` before any post-``addUri`` await, and a gid accepted
|
||||||
|
by the daemon while the caller is being cancelled is removed again
|
||||||
|
before the ``CancelledError`` propagates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
await self._ensure_process()
|
await self._ensure_process()
|
||||||
@@ -251,7 +256,11 @@ class Aria2Downloader:
|
|||||||
await asyncio.sleep(self._poll_interval)
|
await asyncio.sleep(self._poll_interval)
|
||||||
finally:
|
finally:
|
||||||
current = self._transfers.get(download_id)
|
current = self._transfers.get(download_id)
|
||||||
if current is not None and current.gid == transfer.gid:
|
if (
|
||||||
|
transfer is not None
|
||||||
|
and current is not None
|
||||||
|
and current.gid == transfer.gid
|
||||||
|
):
|
||||||
self._transfers.pop(download_id, None)
|
self._transfers.pop(download_id, None)
|
||||||
|
|
||||||
async def _get_status_with_retry(
|
async def _get_status_with_retry(
|
||||||
@@ -339,21 +348,43 @@ class Aria2Downloader:
|
|||||||
resolved_url != url,
|
resolved_url != url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Shield the addUri RPC from cancellation: the daemon may accept the
|
||||||
|
# download even when the caller is cancelled while the request is in
|
||||||
|
# flight. On cancellation, wait for the RPC result so the freshly
|
||||||
|
# created gid can be removed instead of leaking an untracked
|
||||||
|
# download that keeps running in the daemon.
|
||||||
|
add_task = asyncio.ensure_future(
|
||||||
|
self._rpc_call("aria2.addUri", [[resolved_url], options])
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
gid = await self._rpc_call("aria2.addUri", [[resolved_url], options])
|
gid = await asyncio.shield(add_task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
leaked_gid: Any = None
|
||||||
|
try:
|
||||||
|
leaked_gid = await add_task
|
||||||
|
except Exception:
|
||||||
|
leaked_gid = None
|
||||||
|
if isinstance(leaked_gid, str) and leaked_gid:
|
||||||
|
logger.info(
|
||||||
|
"Removing aria2 gid %s accepted while download %s was "
|
||||||
|
"being cancelled",
|
||||||
|
leaked_gid,
|
||||||
|
download_id,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._rpc_call("aria2.forceRemove", [leaked_gid])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to remove leaked aria2 gid %s for download %s: %s",
|
||||||
|
leaked_gid,
|
||||||
|
download_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
raise Aria2Error(f"Failed to schedule aria2 download: {exc}") from exc
|
||||||
|
|
||||||
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
|
logger.debug("aria2 accepted download %s with gid %s", download_id, gid)
|
||||||
await self._state_store.upsert(
|
|
||||||
download_id,
|
|
||||||
{
|
|
||||||
"gid": gid,
|
|
||||||
"save_path": save_path,
|
|
||||||
"status": "downloading",
|
|
||||||
"url": url,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return gid
|
return gid
|
||||||
|
|
||||||
async def _register_transfer(
|
async def _register_transfer(
|
||||||
@@ -372,7 +403,46 @@ class Aria2Downloader:
|
|||||||
headers=headers,
|
headers=headers,
|
||||||
)
|
)
|
||||||
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
transfer = Aria2Transfer(gid=gid, save_path=os.path.abspath(save_path))
|
||||||
|
# Register the transfer before any further await: once the daemon
|
||||||
|
# holds the gid, cancel_download() must be able to find it. An await
|
||||||
|
# in between would open a window where a concurrent cancel reports
|
||||||
|
# "Download task not found" and the daemon keeps downloading
|
||||||
|
# untracked.
|
||||||
self._transfers[download_id] = transfer
|
self._transfers[download_id] = transfer
|
||||||
|
try:
|
||||||
|
await self._state_store.upsert(
|
||||||
|
download_id,
|
||||||
|
{
|
||||||
|
"gid": gid,
|
||||||
|
"save_path": transfer.save_path,
|
||||||
|
"status": "downloading",
|
||||||
|
"url": url,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# The task was cancelled while persisting state and the
|
||||||
|
# coordinator's cancel ran before the transfer was registered
|
||||||
|
# above. Remove the daemon transfer unless it was deliberately
|
||||||
|
# paused (skip_download preserves paused transfers for resume).
|
||||||
|
status = None
|
||||||
|
try:
|
||||||
|
status = await self.get_status(download_id)
|
||||||
|
except Exception:
|
||||||
|
status = None
|
||||||
|
if status is not None and status.get("status") != "paused":
|
||||||
|
try:
|
||||||
|
await self._rpc_call("aria2.forceRemove", [gid])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to remove aria2 gid %s for cancelled download %s: %s",
|
||||||
|
gid,
|
||||||
|
download_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
current = self._transfers.get(download_id)
|
||||||
|
if current is not None and current.gid == gid:
|
||||||
|
self._transfers.pop(download_id, None)
|
||||||
|
raise
|
||||||
return transfer
|
return transfer
|
||||||
|
|
||||||
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES
|
from ..utils.constants import VALID_LORA_SUB_TYPES, VALID_CHECKPOINT_SUB_TYPES, VALID_OTHER_SUB_TYPES
|
||||||
from ..utils.models import BaseModelMetadata
|
from ..utils.models import BaseModelMetadata
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..utils.usage_stats import UsageStats
|
from ..utils.usage_stats import UsageStats
|
||||||
@@ -904,6 +904,11 @@ class BaseModelService(ABC):
|
|||||||
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
and normalized_type not in VALID_CHECKPOINT_SUB_TYPES
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
|
if (
|
||||||
|
self.model_type == "other"
|
||||||
|
and normalized_type not in VALID_OTHER_SUB_TYPES
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
type_counts[normalized_type] = type_counts.get(normalized_type, 0) + 1
|
||||||
|
|
||||||
|
|||||||
@@ -410,6 +410,10 @@ class CheckpointScanner(ModelScanner):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||||
|
"""Resolve sub_type from the configured root that contains the file."""
|
||||||
|
return self._resolve_sub_type(self._find_root_for_file(file_path))
|
||||||
|
|
||||||
def adjust_metadata(self, metadata, file_path, root_path):
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
"""Adjust metadata during scanning to set sub_type."""
|
"""Adjust metadata during scanning to set sub_type."""
|
||||||
sub_type = self._resolve_sub_type(root_path)
|
sub_type = self._resolve_sub_type(root_path)
|
||||||
@@ -419,9 +423,7 @@ class CheckpointScanner(ModelScanner):
|
|||||||
|
|
||||||
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
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."""
|
"""Adjust entries loaded from the persisted cache to ensure sub_type is set."""
|
||||||
sub_type = self._resolve_sub_type(
|
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||||
self._find_root_for_file(entry.get("file_path"))
|
|
||||||
)
|
|
||||||
if sub_type:
|
if sub_type:
|
||||||
entry["sub_type"] = sub_type
|
entry["sub_type"] = sub_type
|
||||||
return entry
|
return entry
|
||||||
|
|||||||
+122
-26
@@ -17,13 +17,18 @@ from dataclasses import dataclass, field
|
|||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
from ..utils.models import (
|
||||||
|
LoraMetadata,
|
||||||
|
CheckpointMetadata,
|
||||||
|
EmbeddingMetadata,
|
||||||
|
OtherModelMetadata,
|
||||||
|
)
|
||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
DIFFUSION_MODEL_BASE_MODELS,
|
|
||||||
MODEL_WEIGHT_FILE_TYPES,
|
MODEL_WEIGHT_FILE_TYPES,
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
VALID_LORA_TYPES,
|
VALID_LORA_TYPES,
|
||||||
|
VALID_OTHER_CIVITAI_TYPES,
|
||||||
)
|
)
|
||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||||
@@ -32,6 +37,7 @@ from ..utils.utils import sanitize_folder_name
|
|||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .service_registry import ServiceRegistry
|
from .service_registry import ServiceRegistry
|
||||||
|
from .download_routing import is_diffusion_model_download, resolve_other_download_sub_type
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
from .metadata_service import get_default_metadata_provider, get_metadata_provider
|
||||||
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
from .downloader import get_downloader, DownloadProgress, DownloadStreamControl
|
||||||
@@ -228,12 +234,21 @@ class DownloadManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def _get_scanner_for_model_type(self, model_type: str):
|
async def _get_scanner_for_model_type(self, model_type: str):
|
||||||
"""Return the scanner responsible for the given model type."""
|
"""Return the scanner responsible for the given model type.
|
||||||
|
|
||||||
|
Every supported type resolves explicitly — an unknown type must never
|
||||||
|
fall through to the lora scanner (an "other" download would silently
|
||||||
|
dedupe against the lora library).
|
||||||
|
"""
|
||||||
if model_type == "checkpoint":
|
if model_type == "checkpoint":
|
||||||
return await self._get_checkpoint_scanner()
|
return await self._get_checkpoint_scanner()
|
||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
return await ServiceRegistry.get_embedding_scanner()
|
return await ServiceRegistry.get_embedding_scanner()
|
||||||
return await self._get_lora_scanner()
|
if model_type == "other":
|
||||||
|
return await ServiceRegistry.get_other_scanner()
|
||||||
|
if model_type == "lora":
|
||||||
|
return await self._get_lora_scanner()
|
||||||
|
raise ValueError(f'Unknown model type "{model_type}"')
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_target_file(
|
def _resolve_target_file(
|
||||||
@@ -978,6 +993,8 @@ class DownloadManager:
|
|||||||
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
return CheckpointMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
if model_type == "embedding":
|
if model_type == "embedding":
|
||||||
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
return EmbeddingMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
|
if model_type == "other":
|
||||||
|
return OtherModelMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
return LoraMetadata.from_civitai_info(version_info, file_info, save_path)
|
||||||
|
|
||||||
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
|
def _resolve_save_path_from_persisted_record(self, record: Dict[str, Any]) -> Optional[str]:
|
||||||
@@ -1438,6 +1455,7 @@ class DownloadManager:
|
|||||||
lora_scanner = await self._get_lora_scanner()
|
lora_scanner = await self._get_lora_scanner()
|
||||||
checkpoint_scanner = await self._get_checkpoint_scanner()
|
checkpoint_scanner = await self._get_checkpoint_scanner()
|
||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
|
||||||
# Check lora scanner first
|
# Check lora scanner first
|
||||||
if await lora_scanner.check_model_version_exists(model_version_id):
|
if await lora_scanner.check_model_version_exists(model_version_id):
|
||||||
@@ -1462,6 +1480,13 @@ class DownloadManager:
|
|||||||
"error": "Model version already exists in embedding library",
|
"error": "Model version already exists in embedding library",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Check other scanner
|
||||||
|
if await other_scanner.check_model_version_exists(model_version_id):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Model version already exists in other library",
|
||||||
|
}
|
||||||
|
|
||||||
# Use CivArchive provider directly when source is 'civarchive'
|
# Use CivArchive provider directly when source is 'civarchive'
|
||||||
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
# This prioritizes CivArchive metadata (with mirror availability info) over Civitai
|
||||||
if source == "civarchive":
|
if source == "civarchive":
|
||||||
@@ -1500,6 +1525,20 @@ class DownloadManager:
|
|||||||
model_type = "lora"
|
model_type = "lora"
|
||||||
elif model_type_from_info == "textualinversion":
|
elif model_type_from_info == "textualinversion":
|
||||||
model_type = "embedding"
|
model_type = "embedding"
|
||||||
|
elif model_type_from_info in VALID_OTHER_CIVITAI_TYPES:
|
||||||
|
if not get_settings_manager().is_other_models_enabled():
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
"Other Models management is disabled. Enable it in "
|
||||||
|
"Settings > Library before downloading VAE, upscaler, "
|
||||||
|
"text encoder or CLIP files."
|
||||||
|
),
|
||||||
|
# Machine-readable failure code consumed by the companion
|
||||||
|
# browser extension (docs/other-models-support.md C4).
|
||||||
|
"reason": "other_models_disabled",
|
||||||
|
}
|
||||||
|
model_type = "other"
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -1621,27 +1660,13 @@ class DownloadManager:
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Check if this checkpoint should be treated as a diffusion model
|
# Check if this checkpoint should be treated as a diffusion model
|
||||||
# Priority: (1) any file has type "UNet" or "Diffusion Model",
|
# (shared with the download routing endpoint so the UI location
|
||||||
# (2) baseModel is in DIFFUSION_MODEL_BASE_MODELS
|
# step and the actual download agree on the target roots).
|
||||||
is_diffusion_model = False
|
is_diffusion_model = is_diffusion_model_download(
|
||||||
if model_type == "checkpoint":
|
model_type,
|
||||||
# Check file types first (more direct signal from CivitAI)
|
file_types=(f.get("type", "") for f in version_info.get("files", [])),
|
||||||
version_files = version_info.get("files", [])
|
base_model=base_model_value,
|
||||||
for f in version_files:
|
)
|
||||||
f_type = f.get("type", "")
|
|
||||||
if f_type in ("UNet", "Diffusion Model"):
|
|
||||||
is_diffusion_model = True
|
|
||||||
logger.info(
|
|
||||||
f"File type '{f_type}' detected, routing checkpoint to unet folder"
|
|
||||||
)
|
|
||||||
break
|
|
||||||
|
|
||||||
# Fallback to baseModel name check
|
|
||||||
if not is_diffusion_model and base_model_value in DIFFUSION_MODEL_BASE_MODELS:
|
|
||||||
is_diffusion_model = True
|
|
||||||
logger.info(
|
|
||||||
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Existence check after the metadata fetch (#1058):
|
# Existence check after the metadata fetch (#1058):
|
||||||
# - An explicit file selection only blocks when THIS file is
|
# - An explicit file selection only blocks when THIS file is
|
||||||
@@ -1700,6 +1725,13 @@ class DownloadManager:
|
|||||||
"success": False,
|
"success": False,
|
||||||
"error": "Model version already exists in embedding library",
|
"error": "Model version already exists in embedding library",
|
||||||
}
|
}
|
||||||
|
elif model_type == "other":
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
if await other_scanner.check_model_version_exists(version_id):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": "Model version already exists in other library",
|
||||||
|
}
|
||||||
|
|
||||||
# Handle use_default_paths
|
# Handle use_default_paths
|
||||||
if use_default_paths:
|
if use_default_paths:
|
||||||
@@ -1739,6 +1771,60 @@ class DownloadManager:
|
|||||||
"error": "Default embedding root path not set in settings",
|
"error": "Default embedding root path not set in settings",
|
||||||
}
|
}
|
||||||
save_dir = default_path
|
save_dir = default_path
|
||||||
|
elif model_type == "other":
|
||||||
|
other_sub_type = resolve_other_download_sub_type(
|
||||||
|
model_type_from_info,
|
||||||
|
file_types=(
|
||||||
|
f.get("type", "")
|
||||||
|
for f in version_info.get("files", [])
|
||||||
|
if isinstance(f, dict)
|
||||||
|
),
|
||||||
|
selected_file_type=(
|
||||||
|
target_file.get("type") if explicit_file else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
default_other_roots = (
|
||||||
|
settings_manager.get("default_other_roots") or {}
|
||||||
|
)
|
||||||
|
if other_sub_type and not settings_manager.is_other_sub_type_enabled(
|
||||||
|
other_sub_type
|
||||||
|
):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Other-model sub-type '{other_sub_type}' is "
|
||||||
|
f"disabled in settings. Please pick a destination "
|
||||||
|
f"folder explicitly instead of using default paths."
|
||||||
|
),
|
||||||
|
"reason": "other_sub_type_disabled",
|
||||||
|
}
|
||||||
|
default_path = (
|
||||||
|
default_other_roots.get(other_sub_type)
|
||||||
|
if other_sub_type
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not isinstance(default_path, str) or not default_path:
|
||||||
|
if other_sub_type:
|
||||||
|
detail = (
|
||||||
|
f"No default root configured for other-model "
|
||||||
|
f"sub-type '{other_sub_type}'"
|
||||||
|
)
|
||||||
|
reason = "other_no_default_root"
|
||||||
|
else:
|
||||||
|
detail = (
|
||||||
|
"Could not determine the other-model sub-type "
|
||||||
|
"from the model metadata"
|
||||||
|
)
|
||||||
|
reason = "other_sub_type_undecidable"
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"{detail}. Please pick a destination folder "
|
||||||
|
f"explicitly instead of using default paths."
|
||||||
|
),
|
||||||
|
"reason": reason,
|
||||||
|
}
|
||||||
|
save_dir = default_path
|
||||||
|
|
||||||
# Calculate relative path using template
|
# Calculate relative path using template
|
||||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||||
@@ -1935,6 +2021,11 @@ class DownloadManager:
|
|||||||
version_info, file_info, save_path
|
version_info, file_info, save_path
|
||||||
)
|
)
|
||||||
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
logger.info(f"Creating EmbeddingMetadata for {file_name}")
|
||||||
|
elif model_type == "other":
|
||||||
|
metadata = OtherModelMetadata.from_civitai_info(
|
||||||
|
version_info, file_info, save_path
|
||||||
|
)
|
||||||
|
logger.info(f"Creating OtherModelMetadata for {file_name}")
|
||||||
else:
|
else:
|
||||||
return {
|
return {
|
||||||
"success": False,
|
"success": False,
|
||||||
@@ -2147,6 +2238,8 @@ class DownloadManager:
|
|||||||
scanner = await self._get_checkpoint_scanner()
|
scanner = await self._get_checkpoint_scanner()
|
||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
|
elif model_type == "other":
|
||||||
|
scanner = await ServiceRegistry.get_other_scanner()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
logger.debug("Failed to acquire scanner for %s models: %s", model_type, exc)
|
||||||
|
|
||||||
@@ -2643,6 +2736,9 @@ class DownloadManager:
|
|||||||
elif model_type == "embedding":
|
elif model_type == "embedding":
|
||||||
scanner = await ServiceRegistry.get_embedding_scanner()
|
scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
logger.info(f"Updating embedding cache for {actual_file_paths[0]}")
|
||||||
|
elif model_type == "other":
|
||||||
|
scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
logger.info(f"Updating other-model cache for {actual_file_paths[0]}")
|
||||||
|
|
||||||
adjust_cached_entry = (
|
adjust_cached_entry = (
|
||||||
getattr(scanner, "adjust_cached_entry", None)
|
getattr(scanner, "adjust_cached_entry", None)
|
||||||
@@ -2732,7 +2828,7 @@ class DownloadManager:
|
|||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||||
if model_type == "checkpoint":
|
if model_type in ("checkpoint", "other"):
|
||||||
return {
|
return {
|
||||||
".ckpt",
|
".ckpt",
|
||||||
".pt",
|
".pt",
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Shared download routing logic.
|
||||||
|
|
||||||
|
Decides whether a download initiated from the checkpoint library should be
|
||||||
|
routed to the unet/diffusion-model roots instead of the checkpoint roots.
|
||||||
|
Used by both the download manager (at download time) and the download
|
||||||
|
routing HTTP endpoint (when the user picks a location in the UI), so the
|
||||||
|
two can never disagree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
from ..utils.constants import (
|
||||||
|
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE,
|
||||||
|
CIVITAI_TYPE_TO_OTHER_SUB_TYPE,
|
||||||
|
DIFFUSION_MODEL_BASE_MODELS,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# File types reported by the CivitAI API that indicate a raw diffusion
|
||||||
|
# model (loaded via UNETLoader in ComfyUI) rather than a full checkpoint.
|
||||||
|
DIFFUSION_FILE_TYPES = frozenset({"UNet", "Diffusion Model"})
|
||||||
|
|
||||||
|
|
||||||
|
def is_diffusion_model_download(
|
||||||
|
model_type: str,
|
||||||
|
file_types: Iterable[str] = (),
|
||||||
|
base_model: str = "",
|
||||||
|
) -> bool:
|
||||||
|
"""Return True when a download should be routed to the unet roots.
|
||||||
|
|
||||||
|
Only applies to downloads initiated from the checkpoint library.
|
||||||
|
Priority: (1) any file has type "UNet" or "Diffusion Model" (the more
|
||||||
|
direct signal from CivitAI), (2) baseModel is a known diffusion model.
|
||||||
|
"""
|
||||||
|
if model_type != "checkpoint":
|
||||||
|
return False
|
||||||
|
|
||||||
|
for file_type in file_types:
|
||||||
|
if file_type in DIFFUSION_FILE_TYPES:
|
||||||
|
logger.info(
|
||||||
|
"File type '%s' detected, routing checkpoint to unet folder",
|
||||||
|
file_type,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if base_model in DIFFUSION_MODEL_BASE_MODELS:
|
||||||
|
logger.info(
|
||||||
|
"baseModel '%s' is a known diffusion model, routing to unet folder",
|
||||||
|
base_model,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_other_download_sub_type(
|
||||||
|
civitai_model_type: str,
|
||||||
|
file_types: Iterable[str] = (),
|
||||||
|
selected_file_type: Optional[str] = None,
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Resolve the "other"-page sub_type for a download.
|
||||||
|
|
||||||
|
Fixed priority (locked design, docs/plans/other-models-page.md §9.2):
|
||||||
|
|
||||||
|
1. Explicit user file pick — when the picked file's type maps, it wins
|
||||||
|
even when model.type maps to something else.
|
||||||
|
2. model.type via CIVITAI_TYPE_TO_OTHER_SUB_TYPE.
|
||||||
|
3. file.type fallback — only when model.type maps to nothing. Must NOT
|
||||||
|
override a mapped model.type: checkpoint models routinely bundle
|
||||||
|
VAE/Text Encoder component files.
|
||||||
|
4. Still undecidable -> None (caller must ask the user for a folder).
|
||||||
|
"""
|
||||||
|
if selected_file_type:
|
||||||
|
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(selected_file_type)
|
||||||
|
if mapped:
|
||||||
|
logger.info(
|
||||||
|
"Explicit file pick type '%s' routes other download to '%s'",
|
||||||
|
selected_file_type,
|
||||||
|
mapped,
|
||||||
|
)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
normalized_model_type = (civitai_model_type or "").strip().lower()
|
||||||
|
mapped = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(normalized_model_type)
|
||||||
|
if mapped:
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
for file_type in file_types:
|
||||||
|
mapped = CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE.get(file_type)
|
||||||
|
if mapped:
|
||||||
|
logger.info(
|
||||||
|
"model.type '%s' unmapped; file type '%s' routes other download to '%s'",
|
||||||
|
civitai_model_type,
|
||||||
|
file_type,
|
||||||
|
mapped,
|
||||||
|
)
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
return None
|
||||||
+91
-48
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -32,8 +33,26 @@ _catalog_cache: Optional[Dict[str, List[str]]] = None
|
|||||||
# ``{provider_id: {model_id: max_output_tokens}}``.
|
# ``{provider_id: {model_id: max_output_tokens}}``.
|
||||||
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
_model_output_limits: Dict[str, Dict[str, int]] = {}
|
||||||
|
|
||||||
|
# Monotonic timestamp of the last failed catalog fetch (None = no failure
|
||||||
|
# yet). Failed fetches are negatively cached: further calls return the
|
||||||
|
# empty fallback without hitting the network until the cooldown elapses,
|
||||||
|
# so users on broken networks don't stall on every settings-modal open.
|
||||||
|
_catalog_last_failure: Optional[float] = None
|
||||||
|
_CATALOG_FAILURE_COOLDOWN = 600.0 # seconds
|
||||||
|
|
||||||
|
# Serializes catalog fetches so concurrent callers don't duplicate requests.
|
||||||
|
_catalog_lock = asyncio.Lock()
|
||||||
|
|
||||||
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
_CATALOG_TIMEOUT = aiohttp.ClientTimeout(total=30)
|
||||||
|
|
||||||
|
# Cloudflare serves brotli when the client advertises it, and brotli is a
|
||||||
|
# required dependency here — a corrupted br stream can crash the native
|
||||||
|
# decoder with a Windows access violation (issue #1099). Request gzip
|
||||||
|
# instead; zlib decompression is not affected and corrupt gzip data only
|
||||||
|
# raises ContentEncodingError (an aiohttp.ClientError subclass), which the
|
||||||
|
# exception handlers below already catch.
|
||||||
|
_NO_BROTLI_HEADERS = {"Accept-Encoding": "gzip, deflate"}
|
||||||
|
|
||||||
|
|
||||||
async def _load_model_catalog() -> Dict[str, List[str]]:
|
async def _load_model_catalog() -> Dict[str, List[str]]:
|
||||||
"""Fetch and parse the model catalog.
|
"""Fetch and parse the model catalog.
|
||||||
@@ -46,61 +65,85 @@ async def _load_model_catalog() -> Dict[str, List[str]]:
|
|||||||
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
value has a ``models`` sub-dict keyed by model ID. The result is cached
|
||||||
in memory after the first successful fetch.
|
in memory after the first successful fetch.
|
||||||
Subsequent calls return the cached data immediately.
|
Subsequent calls return the cached data immediately.
|
||||||
|
|
||||||
|
Failed fetches are negatively cached: further calls return an empty
|
||||||
|
dict without hitting the network until ``_CATALOG_FAILURE_COOLDOWN``
|
||||||
|
has elapsed, so a broken network does not stall every settings-modal
|
||||||
|
open. Concurrent callers are serialized behind :data:`_catalog_lock`
|
||||||
|
so only one request is ever in flight.
|
||||||
"""
|
"""
|
||||||
global _catalog_cache, _model_output_limits
|
global _catalog_cache, _model_output_limits, _catalog_last_failure
|
||||||
if _catalog_cache is not None:
|
if _catalog_cache is not None:
|
||||||
return _catalog_cache
|
return _catalog_cache
|
||||||
|
|
||||||
try:
|
async with _catalog_lock:
|
||||||
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
# Re-check under the lock: another caller may have fetched (or
|
||||||
async with session.get(_MODEL_CATALOG_URL) as resp:
|
# failed) while we were waiting.
|
||||||
if resp.status != 200:
|
if _catalog_cache is not None:
|
||||||
logger.warning("Model catalog returned HTTP %s", resp.status)
|
return _catalog_cache
|
||||||
return _catalog_cache or {}
|
if (
|
||||||
data = await resp.json()
|
_catalog_last_failure is not None
|
||||||
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
and time.monotonic() - _catalog_last_failure < _CATALOG_FAILURE_COOLDOWN
|
||||||
logger.warning("Failed to fetch model catalog: %s", exc)
|
):
|
||||||
return _catalog_cache or {}
|
logger.debug(
|
||||||
|
"Skipping model catalog fetch: last attempt failed %.0fs ago",
|
||||||
|
time.monotonic() - _catalog_last_failure,
|
||||||
|
)
|
||||||
|
return {}
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
try:
|
||||||
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
async with aiohttp.ClientSession(timeout=_CATALOG_TIMEOUT) as session:
|
||||||
return _catalog_cache or {}
|
async with session.get(_MODEL_CATALOG_URL, headers=_NO_BROTLI_HEADERS) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning("Model catalog returned HTTP %s", resp.status)
|
||||||
|
_catalog_last_failure = time.monotonic()
|
||||||
|
return {}
|
||||||
|
data = await resp.json()
|
||||||
|
except (aiohttp.ClientError, asyncio.TimeoutError, json.JSONDecodeError, UnicodeDecodeError) as exc:
|
||||||
|
logger.warning("Failed to fetch model catalog: %s", exc)
|
||||||
|
_catalog_last_failure = time.monotonic()
|
||||||
|
return {}
|
||||||
|
|
||||||
result: Dict[str, List[str]] = {}
|
if not isinstance(data, dict):
|
||||||
output_limits: Dict[str, Dict[str, int]] = {}
|
logger.warning("Model catalog is not a dict, got %s", type(data).__name__)
|
||||||
for provider_id, provider_info in data.items():
|
_catalog_last_failure = time.monotonic()
|
||||||
if not isinstance(provider_info, dict):
|
return {}
|
||||||
continue
|
|
||||||
models_dict = provider_info.get("models")
|
result: Dict[str, List[str]] = {}
|
||||||
if not isinstance(models_dict, dict):
|
output_limits: Dict[str, Dict[str, int]] = {}
|
||||||
continue
|
for provider_id, provider_info in data.items():
|
||||||
model_ids: List[str] = []
|
if not isinstance(provider_info, dict):
|
||||||
provider_limits: Dict[str, int] = {}
|
|
||||||
for mid, model_info in models_dict.items():
|
|
||||||
if not isinstance(mid, str):
|
|
||||||
continue
|
continue
|
||||||
model_ids.append(mid)
|
models_dict = provider_info.get("models")
|
||||||
if isinstance(model_info, dict):
|
if not isinstance(models_dict, dict):
|
||||||
limit = model_info.get("limit")
|
continue
|
||||||
if isinstance(limit, dict):
|
model_ids: List[str] = []
|
||||||
output = limit.get("output")
|
provider_limits: Dict[str, int] = {}
|
||||||
if isinstance(output, (int, float)) and output > 0:
|
for mid, model_info in models_dict.items():
|
||||||
provider_limits[mid] = int(output)
|
if not isinstance(mid, str):
|
||||||
if model_ids:
|
continue
|
||||||
result[provider_id] = model_ids
|
model_ids.append(mid)
|
||||||
if provider_limits:
|
if isinstance(model_info, dict):
|
||||||
output_limits[provider_id] = provider_limits
|
limit = model_info.get("limit")
|
||||||
|
if isinstance(limit, dict):
|
||||||
|
output = limit.get("output")
|
||||||
|
if isinstance(output, (int, float)) and output > 0:
|
||||||
|
provider_limits[mid] = int(output)
|
||||||
|
if model_ids:
|
||||||
|
result[provider_id] = model_ids
|
||||||
|
if provider_limits:
|
||||||
|
output_limits[provider_id] = provider_limits
|
||||||
|
|
||||||
_catalog_cache = result
|
_catalog_cache = result
|
||||||
_model_output_limits = output_limits
|
_model_output_limits = output_limits
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"Loaded model catalog: %d providers, %d total models "
|
"Loaded model catalog: %d providers, %d total models "
|
||||||
"(%d providers have output limits)",
|
"(%d providers have output limits)",
|
||||||
len(result),
|
len(result),
|
||||||
sum(len(m) for m in result.values()),
|
sum(len(m) for m in result.values()),
|
||||||
len(output_limits),
|
len(output_limits),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
def _get_model_max_output(provider: str, model: str) -> Optional[int]:
|
||||||
@@ -126,7 +169,7 @@ async def fetch_ollama_models(api_base: str) -> List[str]:
|
|||||||
url = f"{api_base.rstrip('/')}/models"
|
url = f"{api_base.rstrip('/')}/models"
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
async with aiohttp.ClientSession(timeout=_OLLAMA_API_TIMEOUT) as session:
|
||||||
async with session.get(url) as resp:
|
async with session.get(url, headers=_NO_BROTLI_HEADERS) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
logger.debug("Ollama API returned HTTP %s from %s", resp.status, api_base)
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class ModelCache:
|
|||||||
|
|
||||||
raw_data: List[Dict[str, Any]]
|
raw_data: List[Dict[str, Any]]
|
||||||
folders: List[str]
|
folders: List[str]
|
||||||
|
# Every directory under the model roots (including empty ones), as
|
||||||
|
# recorded by the last scan/hydration. ``None`` means "never recorded"
|
||||||
|
# (e.g. a persisted snapshot predating this field) and triggers a
|
||||||
|
# background filesystem backfill in the scanner.
|
||||||
|
all_folders: Optional[List[str]] = None
|
||||||
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
|
||||||
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
|
||||||
# Multi-valued companion to version_index: every local file entry of a
|
# Multi-valued companion to version_index: every local file entry of a
|
||||||
|
|||||||
+256
-77
@@ -62,16 +62,13 @@ def _is_hidden_relative_path(rel_path: str) -> bool:
|
|||||||
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
|
||||||
|
|
||||||
|
|
||||||
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
|
|
||||||
# requests (modal open + autocomplete) do not re-walk the model roots.
|
|
||||||
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
|
|
||||||
|
|
||||||
# Maps a scanner model type to the manager page type used in progress
|
# Maps a scanner model type to the manager page type used in progress
|
||||||
# broadcasts (e.g. 'lora' -> 'loras').
|
# broadcasts (e.g. 'lora' -> 'loras').
|
||||||
PAGE_TYPE_MAP = {
|
PAGE_TYPE_MAP = {
|
||||||
'lora': 'loras',
|
'lora': 'loras',
|
||||||
'checkpoint': 'checkpoints',
|
'checkpoint': 'checkpoints',
|
||||||
'embedding': 'embeddings',
|
'embedding': 'embeddings',
|
||||||
|
'other': 'other',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -89,6 +86,10 @@ class CacheBuildResult:
|
|||||||
hash_index: ModelHashIndex
|
hash_index: ModelHashIndex
|
||||||
tags_count: Dict[str, int]
|
tags_count: Dict[str, int]
|
||||||
excluded_models: List[str]
|
excluded_models: List[str]
|
||||||
|
# Every directory under the model roots (including empty ones) discovered
|
||||||
|
# during the scan, or None when the source has no folder information
|
||||||
|
# (e.g. a persisted snapshot predating folder recording).
|
||||||
|
all_folders: Optional[List[str]] = None
|
||||||
|
|
||||||
class ModelScanner:
|
class ModelScanner:
|
||||||
"""Base service for scanning and managing model files"""
|
"""Base service for scanning and managing model files"""
|
||||||
@@ -144,8 +145,9 @@ class ModelScanner:
|
|||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
self._cancel_requested = False # Flag for cancellation
|
self._cancel_requested = False # Flag for cancellation
|
||||||
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
|
||||||
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
|
# Guard against concurrent all-folders backfill walks (cold fallback
|
||||||
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
|
# for persisted snapshots that predate folder recording).
|
||||||
|
self._all_folders_backfill_running = False
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
@@ -208,8 +210,14 @@ class ModelScanner:
|
|||||||
"""
|
"""
|
||||||
self._cache_version += 1
|
self._cache_version += 1
|
||||||
|
|
||||||
def on_library_changed(self) -> None:
|
def on_library_changed(self, reconcile: bool = False) -> None:
|
||||||
"""Reset caches when the active library changes."""
|
"""Reset caches when the active library changes.
|
||||||
|
|
||||||
|
When ``reconcile`` is True an incremental reconcile runs right after
|
||||||
|
the cache is re-hydrated, so newly configured roots are scanned and
|
||||||
|
entries for removed roots are purged. Used when scanner-affecting
|
||||||
|
settings (e.g. the Other Models toggles) change.
|
||||||
|
"""
|
||||||
self._persistent_cache = get_persistent_cache()
|
self._persistent_cache = get_persistent_cache()
|
||||||
self._cache = None
|
self._cache = None
|
||||||
self._hash_index = ModelHashIndex()
|
self._hash_index = ModelHashIndex()
|
||||||
@@ -217,7 +225,6 @@ class ModelScanner:
|
|||||||
self._excluded_models = []
|
self._excluded_models = []
|
||||||
self._is_initializing = False
|
self._is_initializing = False
|
||||||
self._name_display_mode = self._resolve_name_display_mode()
|
self._name_display_mode = self._resolve_name_display_mode()
|
||||||
self.invalidate_all_folders_cache()
|
|
||||||
self.bump_cache_version()
|
self.bump_cache_version()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -228,7 +235,7 @@ class ModelScanner:
|
|||||||
if loop and not loop.is_closed():
|
if loop and not loop.is_closed():
|
||||||
self._loop = loop
|
self._loop = loop
|
||||||
self.loop = loop
|
self.loop = loop
|
||||||
loop.create_task(self.initialize_in_background())
|
loop.create_task(self.initialize_in_background(reconcile=reconcile))
|
||||||
|
|
||||||
def _resolve_name_display_mode(self) -> str:
|
def _resolve_name_display_mode(self) -> str:
|
||||||
"""Return the configured display mode for name sorting."""
|
"""Return the configured display mode for name sorting."""
|
||||||
@@ -459,8 +466,14 @@ class ModelScanner:
|
|||||||
_, license_flags = resolve_license_info(license_source)
|
_, license_flags = resolve_license_info(license_source)
|
||||||
entry['license_flags'] = license_flags
|
entry['license_flags'] = license_flags
|
||||||
|
|
||||||
async def initialize_in_background(self) -> None:
|
async def initialize_in_background(self, reconcile: bool = False) -> None:
|
||||||
"""Initialize cache in background using thread pool"""
|
"""Initialize cache in background using thread pool
|
||||||
|
|
||||||
|
Args:
|
||||||
|
reconcile: When True and a persisted snapshot is hydrated, run an
|
||||||
|
incremental reconcile afterwards so the cache matches the
|
||||||
|
current root configuration.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
# Set initial empty cache to avoid None reference errors
|
# Set initial empty cache to avoid None reference errors
|
||||||
if self._cache is None:
|
if self._cache is None:
|
||||||
@@ -500,6 +513,11 @@ class ModelScanner:
|
|||||||
logger.info(
|
logger.info(
|
||||||
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
|
f"{self.model_type.capitalize()} cache hydrated from persisted snapshot with {len(self._cache.raw_data)} models"
|
||||||
)
|
)
|
||||||
|
if reconcile:
|
||||||
|
# Root configuration changed (e.g. Other Models toggles):
|
||||||
|
# pick up newly enabled folders and drop rows for folders
|
||||||
|
# that are no longer managed.
|
||||||
|
await self.get_cached_data(force_refresh=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Persistent load failed; fall back to a full scan
|
# Persistent load failed; fall back to a full scan
|
||||||
@@ -662,21 +680,33 @@ class ModelScanner:
|
|||||||
if not persisted or not persisted.raw_data:
|
if not persisted or not persisted.raw_data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
# Drop entries the scanner no longer manages (e.g. an other-model
|
||||||
|
# sub_type the user just disabled) before rebuilding the indexes, so
|
||||||
|
# hash/autov3 lookups cannot resolve to unmanaged files either.
|
||||||
|
kept_items = [
|
||||||
|
item
|
||||||
|
for item in persisted.raw_data
|
||||||
|
if self._should_keep_cached_entry(item)
|
||||||
|
]
|
||||||
|
kept_paths = {
|
||||||
|
item.get("file_path") for item in kept_items if item.get("file_path")
|
||||||
|
}
|
||||||
|
|
||||||
hash_index = ModelHashIndex()
|
hash_index = ModelHashIndex()
|
||||||
for sha_value, path in persisted.hash_rows:
|
for sha_value, path in persisted.hash_rows:
|
||||||
if sha_value and path:
|
if sha_value and path and path in kept_paths:
|
||||||
hash_index.add_entry(sha_value.lower(), path)
|
hash_index.add_entry(sha_value.lower(), path)
|
||||||
|
|
||||||
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
|
||||||
# cover every known autov3 -> path mapping regardless of whether a
|
# cover every known autov3 -> path mapping regardless of whether a
|
||||||
# sha256 row also exists for the same file.
|
# sha256 row also exists for the same file.
|
||||||
for autov3_value, path in persisted.autov3_hash_rows:
|
for autov3_value, path in persisted.autov3_hash_rows:
|
||||||
if autov3_value and path:
|
if autov3_value and path and path in kept_paths:
|
||||||
hash_index.add_autov3(autov3_value.lower(), path)
|
hash_index.add_autov3(autov3_value.lower(), path)
|
||||||
|
|
||||||
tags_count: Dict[str, int] = {}
|
tags_count: Dict[str, int] = {}
|
||||||
adjusted_raw_data: List[Dict[str, Any]] = []
|
adjusted_raw_data: List[Dict[str, Any]] = []
|
||||||
for item in persisted.raw_data:
|
for item in kept_items:
|
||||||
# load_cache builds a fresh dict per row, and validate_batch below
|
# load_cache builds a fresh dict per row, and validate_batch below
|
||||||
# works on its own per-entry copy when auto_repair=True, so no
|
# works on its own per-entry copy when auto_repair=True, so no
|
||||||
# additional dict copy is needed here.
|
# additional dict copy is needed here.
|
||||||
@@ -702,7 +732,8 @@ class ModelScanner:
|
|||||||
raw_data=valid_entries,
|
raw_data=valid_entries,
|
||||||
hash_index=hash_index,
|
hash_index=hash_index,
|
||||||
tags_count=tags_count,
|
tags_count=tags_count,
|
||||||
excluded_models=list(persisted.excluded_models)
|
excluded_models=list(persisted.excluded_models),
|
||||||
|
all_folders=list(persisted.all_folders) if persisted.all_folders is not None else None,
|
||||||
)
|
)
|
||||||
return scan_result, invalid_entries
|
return scan_result, invalid_entries
|
||||||
|
|
||||||
@@ -737,6 +768,7 @@ class ModelScanner:
|
|||||||
hash_snapshot,
|
hash_snapshot,
|
||||||
list(scan_result.excluded_models),
|
list(scan_result.excluded_models),
|
||||||
autov3_snapshot,
|
autov3_snapshot,
|
||||||
|
scan_result.all_folders,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
logger.warning("%s Scanner: Failed to persist cache: %s", self.model_type.capitalize(), exc)
|
||||||
@@ -784,7 +816,12 @@ class ModelScanner:
|
|||||||
raw_data=list(self._cache.raw_data),
|
raw_data=list(self._cache.raw_data),
|
||||||
hash_index=self._hash_index,
|
hash_index=self._hash_index,
|
||||||
tags_count=dict(self._tags_count),
|
tags_count=dict(self._tags_count),
|
||||||
excluded_models=list(self._excluded_models)
|
excluded_models=list(self._excluded_models),
|
||||||
|
all_folders=(
|
||||||
|
list(self._cache.all_folders)
|
||||||
|
if self._cache.all_folders is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await self._save_persistent_cache(snapshot)
|
await self._save_persistent_cache(snapshot)
|
||||||
await self._sync_download_history(snapshot.raw_data, source='scan')
|
await self._sync_download_history(snapshot.raw_data, source='scan')
|
||||||
@@ -1005,20 +1042,36 @@ class ModelScanner:
|
|||||||
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
await self._broadcast_scan_progress('started', 'reconcile_scan', 0, False)
|
||||||
|
|
||||||
# Get current cached file paths
|
# Get current cached file paths
|
||||||
|
cached_size_before = len(self._cache.raw_data)
|
||||||
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
cached_paths = {item['file_path'] for item in self._cache.raw_data}
|
||||||
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
|
path_to_item = {item['file_path']: item for item in self._cache.raw_data}
|
||||||
cached_real_paths = {}
|
|
||||||
for cached_path in cached_paths:
|
# physical path -> cached business path, for the alias case where the
|
||||||
try:
|
# same file is reachable under a different path than the cached one
|
||||||
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
# (overlapping roots / symlink layout changes): keep the existing
|
||||||
except Exception:
|
# entry instead of delete + re-add (which would re-read metadata and
|
||||||
continue
|
# re-hash every file). Built lazily on the first miss, because a
|
||||||
|
# realpath per cached entry is ~half the cost of a no-change
|
||||||
|
# reconcile and the map is only ever consulted for misses.
|
||||||
|
cached_real_paths: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
def lookup_cached_real_path(real_path: str) -> Optional[str]:
|
||||||
|
nonlocal cached_real_paths
|
||||||
|
if cached_real_paths is None:
|
||||||
|
cached_real_paths = {}
|
||||||
|
for cached_path in cached_paths:
|
||||||
|
try:
|
||||||
|
cached_real_paths.setdefault(os.path.realpath(cached_path), cached_path)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return cached_real_paths.get(real_path)
|
||||||
|
|
||||||
# Track found files and new files
|
# Track found files and new files
|
||||||
found_paths = set()
|
found_paths = set()
|
||||||
new_files = []
|
new_files = []
|
||||||
visited_real_paths = set()
|
visited_real_paths = set()
|
||||||
discovered_real_files = set()
|
discovered_real_files = set()
|
||||||
|
discovered_folders: Set[str] = set()
|
||||||
|
|
||||||
# Scan all model roots
|
# Scan all model roots
|
||||||
for root_path in self.get_model_roots():
|
for root_path in self.get_model_roots():
|
||||||
@@ -1033,19 +1086,31 @@ class ModelScanner:
|
|||||||
continue
|
continue
|
||||||
visited_real_paths.add(real_root)
|
visited_real_paths.add(real_root)
|
||||||
|
|
||||||
|
# Record every visited directory (including empty ones) so
|
||||||
|
# the folder tree stays accurate without a live walk.
|
||||||
|
rel_dir = os.path.relpath(
|
||||||
|
os.path.abspath(root), os.path.abspath(root_path)
|
||||||
|
).replace(os.path.sep, "/")
|
||||||
|
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||||
|
discovered_folders.add(rel_dir)
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
ext = os.path.splitext(file)[1].lower()
|
ext = os.path.splitext(file)[1].lower()
|
||||||
if ext in self.file_extensions:
|
if ext in self.file_extensions:
|
||||||
# Construct paths exactly as they would be in cache
|
# Construct paths exactly as they would be in cache
|
||||||
file_path = os.path.join(root, file).replace(os.sep, '/')
|
file_path = os.path.join(root, file).replace(os.sep, '/')
|
||||||
real_file_path = os.path.realpath(os.path.join(root, file))
|
|
||||||
|
|
||||||
# Check if this file is already in cache
|
# Check if this file is already in cache
|
||||||
if file_path in cached_paths:
|
if file_path in cached_paths:
|
||||||
found_paths.add(file_path)
|
found_paths.add(file_path)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cached_real_match = cached_real_paths.get(real_file_path)
|
# Only a cache miss needs the physical path, so the
|
||||||
|
# realpath syscalls are paid per changed file rather
|
||||||
|
# than per file in the library.
|
||||||
|
real_file_path = os.path.realpath(os.path.join(root, file))
|
||||||
|
|
||||||
|
cached_real_match = lookup_cached_real_path(real_file_path)
|
||||||
if cached_real_match:
|
if cached_real_match:
|
||||||
found_paths.add(cached_real_match)
|
found_paths.add(cached_real_match)
|
||||||
continue
|
continue
|
||||||
@@ -1090,6 +1155,9 @@ class ModelScanner:
|
|||||||
total_new = len(new_files)
|
total_new = len(new_files)
|
||||||
processed_new = 0
|
processed_new = 0
|
||||||
last_progress_time = time.time()
|
last_progress_time = time.time()
|
||||||
|
# Snapshot the roots once: this matches the walk above (which
|
||||||
|
# also snapshots them) and avoids a config read per new file.
|
||||||
|
model_roots = self.get_model_roots()
|
||||||
for i in range(0, total_new, batch_size):
|
for i in range(0, total_new, batch_size):
|
||||||
batch = new_files[i:i+batch_size]
|
batch = new_files[i:i+batch_size]
|
||||||
for path in batch:
|
for path in batch:
|
||||||
@@ -1098,12 +1166,10 @@ class ModelScanner:
|
|||||||
try:
|
try:
|
||||||
# Find the appropriate root path for this file
|
# Find the appropriate root path for this file
|
||||||
root_path = None
|
root_path = None
|
||||||
model_roots = self.get_model_roots()
|
normalized_path = os.path.normpath(path)
|
||||||
for potential_root in model_roots:
|
for potential_root in model_roots:
|
||||||
# Normalize both paths for comparison
|
# Normalize both paths for comparison
|
||||||
normalized_path = os.path.normpath(path)
|
if normalized_path.startswith(os.path.normpath(potential_root)):
|
||||||
normalized_root = os.path.normpath(potential_root)
|
|
||||||
if normalized_path.startswith(normalized_root):
|
|
||||||
root_path = potential_root
|
root_path = potential_root
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -1200,25 +1266,41 @@ class ModelScanner:
|
|||||||
# Update cache data
|
# Update cache data
|
||||||
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
self._cache.raw_data = [item for item in self._cache.raw_data if item['file_path'] not in missing_files]
|
||||||
|
|
||||||
dedup_removed = 0
|
# Defensive integrity pass: drop entries sharing a business path.
|
||||||
seen_paths: set[str] = set()
|
# Duplicates can only be introduced by external code rewriting
|
||||||
deduped: list[Dict[str, Any]] = []
|
# raw_data directly or by this pass's own appends, so an unchanged
|
||||||
for item in reversed(self._cache.raw_data):
|
# filesystem walk over a clean cache has nothing to clean. The size
|
||||||
path = item.get('file_path', '')
|
# mismatch is an O(1) tell that the snapshot already contained
|
||||||
if path not in seen_paths:
|
# duplicates; skipping the O(N) pass when it is provably clean is
|
||||||
seen_paths.add(path)
|
# what keeps a no-change Refresh cheap.
|
||||||
deduped.append(item)
|
if cached_size_before != len(cached_paths) or total_added > 0:
|
||||||
else:
|
dedup_removed = 0
|
||||||
for tag in item.get('tags', []):
|
seen_paths: set[str] = set()
|
||||||
if tag in self._tags_count:
|
deduped: list[Dict[str, Any]] = []
|
||||||
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
for item in reversed(self._cache.raw_data):
|
||||||
if self._tags_count[tag] == 0:
|
path = item.get('file_path', '')
|
||||||
del self._tags_count[tag]
|
if path not in seen_paths:
|
||||||
dedup_removed += 1
|
seen_paths.add(path)
|
||||||
if dedup_removed > 0:
|
deduped.append(item)
|
||||||
self._cache.raw_data = list(reversed(deduped))
|
else:
|
||||||
total_removed += dedup_removed
|
for tag in item.get('tags', []):
|
||||||
|
if tag in self._tags_count:
|
||||||
|
self._tags_count[tag] = max(0, self._tags_count[tag] - 1)
|
||||||
|
if self._tags_count[tag] == 0:
|
||||||
|
del self._tags_count[tag]
|
||||||
|
dedup_removed += 1
|
||||||
|
if dedup_removed > 0:
|
||||||
|
self._cache.raw_data = list(reversed(deduped))
|
||||||
|
total_removed += dedup_removed
|
||||||
|
|
||||||
|
# The walk above visited every directory, so refresh the recorded
|
||||||
|
# folder list (including empty folders) even when no model files
|
||||||
|
# changed — e.g. an empty folder was created or removed externally.
|
||||||
|
sorted_discovered = sorted(discovered_folders, key=lambda x: x.lower())
|
||||||
|
folders_changed = self._cache.all_folders != sorted_discovered
|
||||||
|
if folders_changed:
|
||||||
|
self._cache.all_folders = sorted_discovered
|
||||||
|
|
||||||
# Resort cache if changes were made
|
# Resort cache if changes were made
|
||||||
if total_added > 0 or total_removed > 0:
|
if total_added > 0 or total_removed > 0:
|
||||||
# Update folders list
|
# Update folders list
|
||||||
@@ -1231,6 +1313,8 @@ class ModelScanner:
|
|||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
|
elif folders_changed:
|
||||||
|
await self._persist_current_cache()
|
||||||
|
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Cache reconciliation completed in {time.time() - start_time:.2f} seconds. Added {total_added}, removed {total_removed} models.")
|
||||||
await self._broadcast_scan_progress(
|
await self._broadcast_scan_progress(
|
||||||
@@ -1270,22 +1354,73 @@ class ModelScanner:
|
|||||||
raise NotImplementedError("Subclasses must implement get_model_roots")
|
raise NotImplementedError("Subclasses must implement get_model_roots")
|
||||||
|
|
||||||
async def get_all_folders(self) -> List[str]:
|
async def get_all_folders(self) -> List[str]:
|
||||||
|
"""Return every known directory under the model roots.
|
||||||
|
|
||||||
|
The directory list (including empty ones) is recorded during cache
|
||||||
|
scans and hydrated from the persisted snapshot, so this is a pure
|
||||||
|
in-memory read — no filesystem walk ever runs on the event loop
|
||||||
|
(walking network roots synchronously used to freeze the whole
|
||||||
|
server, see issue #1110). The result is unioned with the
|
||||||
|
model-derived folders so it is always a superset of
|
||||||
|
``cache.folders``.
|
||||||
|
|
||||||
|
Cold fallback: when the cache was hydrated from a persisted snapshot
|
||||||
|
that predates folder recording (``all_folders is None``), a one-shot
|
||||||
|
background walk is scheduled off the event loop to backfill and
|
||||||
|
persist the list; until it lands, the models-only folders are
|
||||||
|
returned.
|
||||||
|
"""
|
||||||
|
folders: Set[str] = set()
|
||||||
|
cache = self._cache
|
||||||
|
if cache is not None:
|
||||||
|
folders |= {item.get('folder', '') for item in cache.raw_data}
|
||||||
|
recorded = getattr(cache, 'all_folders', None)
|
||||||
|
if recorded is None:
|
||||||
|
self._schedule_all_folders_backfill()
|
||||||
|
else:
|
||||||
|
folders |= set(recorded)
|
||||||
|
else:
|
||||||
|
self._schedule_all_folders_backfill()
|
||||||
|
|
||||||
|
return sorted(folders, key=lambda x: x.lower())
|
||||||
|
|
||||||
|
def _schedule_all_folders_backfill(self) -> None:
|
||||||
|
"""Kick off a one-shot background folder walk if none is running."""
|
||||||
|
if self._all_folders_backfill_running:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
return
|
||||||
|
self._all_folders_backfill_running = True
|
||||||
|
loop.create_task(self._run_all_folders_backfill())
|
||||||
|
|
||||||
|
async def _run_all_folders_backfill(self) -> None:
|
||||||
|
"""Walk the roots in a worker thread, then record and persist the result."""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
folders = await loop.run_in_executor(None, self._walk_all_folders_sync)
|
||||||
|
cache = self._cache
|
||||||
|
# A scan may have recorded the list while the walk was in flight;
|
||||||
|
# prefer the fresher scan data in that case.
|
||||||
|
if cache is not None and cache.all_folders is None:
|
||||||
|
cache.all_folders = folders
|
||||||
|
await self._persist_current_cache()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(
|
||||||
|
"%s Scanner: all-folders backfill failed: %s",
|
||||||
|
self.model_type.capitalize(),
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._all_folders_backfill_running = False
|
||||||
|
|
||||||
|
def _walk_all_folders_sync(self) -> List[str]:
|
||||||
"""Enumerate every directory under the model roots, live from disk.
|
"""Enumerate every directory under the model roots, live from disk.
|
||||||
|
|
||||||
Unlike the models-only ``cache.folders``, this includes empty
|
Runs in a worker thread. Hidden directories (any segment starting
|
||||||
directories, so it stays accurate even when the in-memory cache was
|
with '.') and the pending-delete staging dir are excluded.
|
||||||
hydrated from a persisted snapshot without a filesystem walk. Hidden
|
|
||||||
directories (any segment starting with '.') and the pending-delete
|
|
||||||
staging dir are excluded. The result is unioned with the model-derived
|
|
||||||
folders so it is always a superset of ``cache.folders``, and cached
|
|
||||||
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
|
|
||||||
"""
|
"""
|
||||||
now = time.monotonic()
|
|
||||||
if self._all_folders_ttl_cache is not None:
|
|
||||||
cached_at, cached_folders = self._all_folders_ttl_cache
|
|
||||||
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
|
|
||||||
return cached_folders
|
|
||||||
|
|
||||||
discovered: Set[str] = set()
|
discovered: Set[str] = set()
|
||||||
visited_real_paths: Set[str] = set()
|
visited_real_paths: Set[str] = set()
|
||||||
|
|
||||||
@@ -1307,17 +1442,7 @@ class ModelScanner:
|
|||||||
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
|
||||||
discovered.add(rel_dir)
|
discovered.add(rel_dir)
|
||||||
|
|
||||||
folders = set(discovered)
|
return sorted(discovered, key=lambda x: x.lower())
|
||||||
if self._cache is not None:
|
|
||||||
folders |= {item.get('folder', '') for item in self._cache.raw_data}
|
|
||||||
|
|
||||||
result = sorted(folders, key=lambda x: x.lower())
|
|
||||||
self._all_folders_ttl_cache = (now, result)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def invalidate_all_folders_cache(self) -> None:
|
|
||||||
"""Drop the cached get_all_folders() result (e.g. after a move)."""
|
|
||||||
self._all_folders_ttl_cache = None
|
|
||||||
|
|
||||||
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
|
||||||
"""Get model file info and metadata (extensible for different model types)"""
|
"""Get model file info and metadata (extensible for different model types)"""
|
||||||
@@ -1339,6 +1464,23 @@ class ModelScanner:
|
|||||||
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
"""Hook for subclasses: adjust entries loaded from the persisted cache."""
|
||||||
return entry
|
return entry
|
||||||
|
|
||||||
|
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
|
||||||
|
"""Hook for subclasses: decide whether a persisted entry is still managed.
|
||||||
|
|
||||||
|
Entries rejected here are dropped (with their hash/autov3 index rows)
|
||||||
|
while hydrating the persisted cache, so a scanner whose configured
|
||||||
|
roots shrank does not surface stale models before the next reconcile.
|
||||||
|
"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||||
|
"""Hook for subclasses: resolve the location-derived sub_type for a file.
|
||||||
|
|
||||||
|
Returns ``None`` when the model type has no location-derived sub-types
|
||||||
|
(the default), in which case any stored value is left untouched.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_path_value(path: Optional[str]) -> str:
|
def _normalize_path_value(path: Optional[str]) -> str:
|
||||||
if not path:
|
if not path:
|
||||||
@@ -1533,6 +1675,9 @@ class ModelScanner:
|
|||||||
else:
|
else:
|
||||||
self._cache.raw_data = list(scan_result.raw_data)
|
self._cache.raw_data = list(scan_result.raw_data)
|
||||||
|
|
||||||
|
if scan_result.all_folders is not None:
|
||||||
|
self._cache.all_folders = list(scan_result.all_folders)
|
||||||
|
|
||||||
# resort() rebuilds folders and the version index on every path, so a
|
# resort() rebuilds folders and the version index on every path, so a
|
||||||
# separate rebuild_version_index() call here would be redundant.
|
# separate rebuild_version_index() call here would be redundant.
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
@@ -1630,6 +1775,7 @@ class ModelScanner:
|
|||||||
processed_files = 0
|
processed_files = 0
|
||||||
processed_real_files: Set[str] = set()
|
processed_real_files: Set[str] = set()
|
||||||
visited_real_dirs: Set[str] = set()
|
visited_real_dirs: Set[str] = set()
|
||||||
|
discovered_folders: Set[str] = set()
|
||||||
|
|
||||||
async def handle_progress(current_name: str = '') -> None:
|
async def handle_progress(current_name: str = '') -> None:
|
||||||
if progress_callback is None:
|
if progress_callback is None:
|
||||||
@@ -1708,6 +1854,13 @@ class ModelScanner:
|
|||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
if _is_excluded_dir(entry.name):
|
if _is_excluded_dir(entry.name):
|
||||||
continue
|
continue
|
||||||
|
# Record every directory (including empty ones) so
|
||||||
|
# the folder tree can be served without a live walk.
|
||||||
|
rel_dir = os.path.relpath(
|
||||||
|
os.path.abspath(entry.path), os.path.abspath(root_path)
|
||||||
|
).replace(os.path.sep, "/")
|
||||||
|
if not _is_hidden_relative_path(rel_dir):
|
||||||
|
discovered_folders.add(rel_dir)
|
||||||
await scan_recursive(entry.path, root_path, visited_paths)
|
await scan_recursive(entry.path, root_path, visited_paths)
|
||||||
except Exception as entry_error:
|
except Exception as entry_error:
|
||||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||||
@@ -1727,7 +1880,8 @@ class ModelScanner:
|
|||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
hash_index=hash_index,
|
hash_index=hash_index,
|
||||||
tags_count=tags_count,
|
tags_count=tags_count,
|
||||||
excluded_models=excluded_models
|
excluded_models=excluded_models,
|
||||||
|
all_folders=sorted(discovered_folders, key=lambda x: x.lower()),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
async def add_model_to_cache(self, metadata_dict: Dict[str, Any], folder: str = '') -> bool:
|
||||||
@@ -1869,6 +2023,20 @@ class ModelScanner:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving metadata file: {e}")
|
logger.error(f"Error moving metadata file: {e}")
|
||||||
|
|
||||||
|
if metadata is not None:
|
||||||
|
# sub_type is derived from the model's location (e.g. a file
|
||||||
|
# moved from a checkpoints root into a unet root becomes a
|
||||||
|
# diffusion_model). Persist the recalculated value into the
|
||||||
|
# moved metadata file so later metadata-driven cache syncs
|
||||||
|
# do not revert the cache entry to the stale sub_type.
|
||||||
|
new_sub_type = self.resolve_sub_type_for_path(target_file)
|
||||||
|
if new_sub_type and metadata.get('sub_type') != new_sub_type:
|
||||||
|
metadata['sub_type'] = new_sub_type
|
||||||
|
try:
|
||||||
|
await MetadataManager.save_metadata(moved_metadata_path, metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error persisting sub_type for moved model: {e}")
|
||||||
|
|
||||||
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
update_result = await self.update_single_model_cache(source_path, target_file, metadata, recalculate_type=True)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1970,6 +2138,16 @@ class ModelScanner:
|
|||||||
all_folders = set(item['folder'] for item in cache.raw_data)
|
all_folders = set(item['folder'] for item in cache.raw_data)
|
||||||
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
cache.folders = sorted(list(all_folders), key=lambda x: x.lower())
|
||||||
|
|
||||||
|
# The move target may live in directories the last scan never saw;
|
||||||
|
# record the destination folder (and its parents) in the known
|
||||||
|
# folder list so the folder tree reflects it without a rescan.
|
||||||
|
if cache.all_folders is not None and folder_value:
|
||||||
|
parts = folder_value.split("/")
|
||||||
|
known = set(cache.all_folders)
|
||||||
|
for i in range(1, len(parts) + 1):
|
||||||
|
known.add("/".join(parts[:i]))
|
||||||
|
cache.all_folders = sorted(known, key=lambda x: x.lower())
|
||||||
|
|
||||||
for tag in cache_entry.get('tags', []):
|
for tag in cache_entry.get('tags', []):
|
||||||
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
self._tags_count[tag] = self._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
@@ -1977,10 +2155,6 @@ class ModelScanner:
|
|||||||
|
|
||||||
await cache.resort()
|
await cache.resort()
|
||||||
|
|
||||||
# A move may have created new directories; drop the cached live-walk
|
|
||||||
# result so the next include_empty request sees them.
|
|
||||||
self.invalidate_all_folders_cache()
|
|
||||||
|
|
||||||
if cache_modified:
|
if cache_modified:
|
||||||
await self._persist_current_cache()
|
await self._persist_current_cache()
|
||||||
self.bump_cache_version()
|
self.bump_cache_version()
|
||||||
@@ -2064,6 +2238,11 @@ class ModelScanner:
|
|||||||
file_path_override=file_path,
|
file_path_override=file_path,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Location-derived fields (e.g. the checkpoint sub_type) must be
|
||||||
|
# re-resolved from the file path rather than trusting the on-disk
|
||||||
|
# metadata snapshot, which may predate a cross-root move.
|
||||||
|
desired_entry = self.adjust_cached_entry(desired_entry)
|
||||||
|
|
||||||
# Ensure sha256 is populated (defensive — metadata should have it)
|
# Ensure sha256 is populated (defensive — metadata should have it)
|
||||||
if (
|
if (
|
||||||
not desired_entry.get("sha256")
|
not desired_entry.get("sha256")
|
||||||
|
|||||||
@@ -118,19 +118,24 @@ class ModelServiceFactory:
|
|||||||
|
|
||||||
|
|
||||||
def register_default_model_types():
|
def register_default_model_types():
|
||||||
"""Register the default model types (LoRA, Checkpoint, and Embedding)"""
|
"""Register the default model types (LoRA, Checkpoint, Embedding, and Other)"""
|
||||||
from ..services.lora_service import LoraService
|
from ..services.lora_service import LoraService
|
||||||
from ..services.checkpoint_service import CheckpointService
|
from ..services.checkpoint_service import CheckpointService
|
||||||
from ..services.embedding_service import EmbeddingService
|
from ..services.embedding_service import EmbeddingService
|
||||||
|
from ..services.other_model_service import OtherModelService
|
||||||
from ..routes.lora_routes import LoraRoutes
|
from ..routes.lora_routes import LoraRoutes
|
||||||
from ..routes.checkpoint_routes import CheckpointRoutes
|
from ..routes.checkpoint_routes import CheckpointRoutes
|
||||||
from ..routes.embedding_routes import EmbeddingRoutes
|
from ..routes.embedding_routes import EmbeddingRoutes
|
||||||
|
from ..routes.other_routes import OtherRoutes
|
||||||
|
|
||||||
# Register LoRA model type
|
# Register LoRA model type
|
||||||
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
ModelServiceFactory.register_model_type('lora', LoraService, LoraRoutes)
|
||||||
|
|
||||||
# Register Checkpoint model type
|
# Register Checkpoint model type
|
||||||
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
|
ModelServiceFactory.register_model_type('checkpoint', CheckpointService, CheckpointRoutes)
|
||||||
|
|
||||||
# Register Embedding model type
|
# Register Embedding model type
|
||||||
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
ModelServiceFactory.register_model_type('embedding', EmbeddingService, EmbeddingRoutes)
|
||||||
|
|
||||||
|
# Register Other model type (VAE, upscaler, text encoder, ...)
|
||||||
|
ModelServiceFactory.register_model_type('other', OtherModelService, OtherRoutes)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .base_model_service import BaseModelService
|
||||||
|
from .auto_tag_service import extract_auto_tags
|
||||||
|
from ..utils.models import OtherModelMetadata
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OtherModelService(BaseModelService):
|
||||||
|
"""Other-model-specific service implementation (VAE, upscaler, text encoder, ...)"""
|
||||||
|
|
||||||
|
def __init__(self, scanner, update_service=None):
|
||||||
|
"""Initialize Other-model service
|
||||||
|
|
||||||
|
Args:
|
||||||
|
scanner: Other-model scanner instance
|
||||||
|
update_service: Optional service for remote update tracking.
|
||||||
|
"""
|
||||||
|
super().__init__("other", scanner, OtherModelMetadata, update_service=update_service)
|
||||||
|
|
||||||
|
async def format_response(self, model_data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Format other-model data for API response.
|
||||||
|
|
||||||
|
Returns None when the entry is missing critical fields (corrupted cache
|
||||||
|
row), so the handler layer can filter it out. See issue #730.
|
||||||
|
"""
|
||||||
|
# Guard against corrupted cache entries missing critical fields
|
||||||
|
file_path = model_data.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
logger.warning(
|
||||||
|
"Skipping corrupted other-model entry (missing file_path): %s",
|
||||||
|
model_data.get("file_name", "<unknown>"),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get sub_type from cache entry (new canonical field)
|
||||||
|
sub_type = model_data.get("sub_type", "vae")
|
||||||
|
|
||||||
|
file_name = model_data.get("file_name") or ""
|
||||||
|
model_name = model_data.get("model_name") or file_name
|
||||||
|
folder = model_data.get("folder") or ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"model_name": model_name,
|
||||||
|
"file_name": file_name,
|
||||||
|
"preview_url": config.get_preview_static_url(model_data.get("preview_url", "")),
|
||||||
|
"preview_nsfw_level": model_data.get("preview_nsfw_level", 0),
|
||||||
|
"base_model": model_data.get("base_model", ""),
|
||||||
|
"folder": folder,
|
||||||
|
"sha256": model_data.get("sha256", ""),
|
||||||
|
"autov3": model_data.get("autov3"),
|
||||||
|
"file_path": file_path.replace(os.sep, "/"),
|
||||||
|
"file_size": model_data.get("size", 0),
|
||||||
|
"modified": model_data.get("modified", ""),
|
||||||
|
"tags": model_data.get("tags", []),
|
||||||
|
"from_civitai": model_data.get("from_civitai", True),
|
||||||
|
"notes": model_data.get("notes", ""),
|
||||||
|
"sub_type": sub_type,
|
||||||
|
"favorite": model_data.get("favorite", False),
|
||||||
|
"exclude": bool(model_data.get("exclude", False)),
|
||||||
|
"update_available": bool(model_data.get("update_available", False)),
|
||||||
|
"skip_metadata_refresh": bool(model_data.get("skip_metadata_refresh", False)),
|
||||||
|
"civitai": self.filter_civitai_data(model_data.get("civitai", {}), minimal=True),
|
||||||
|
"auto_tags": model_data.get("auto_tags") or extract_auto_tags(model_data),
|
||||||
|
"version_count": model_data.get("version_count"),
|
||||||
|
"hf_url": model_data.get("hf_url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
def find_duplicate_hashes(self) -> Dict[str, Any]:
|
||||||
|
"""Find other models with duplicate SHA256 hashes"""
|
||||||
|
return self.scanner._hash_index.get_duplicate_hashes()
|
||||||
|
|
||||||
|
def find_duplicate_filenames(self) -> Dict[str, Any]:
|
||||||
|
"""Find other models with conflicting filenames"""
|
||||||
|
return self.scanner._hash_index.get_duplicate_filenames()
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
# pyright: reportImportCycles=false
|
||||||
|
# Lazy (function-local) imports still count as static edges in basedpyright's
|
||||||
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from ..utils.models import OtherModelMetadata
|
||||||
|
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||||
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
from ..config import config
|
||||||
|
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||||
|
from .model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OtherScanner(ModelScanner):
|
||||||
|
"""Service for scanning and managing "other" model files.
|
||||||
|
|
||||||
|
Aggregates every enabled folder_paths category from
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES (VAE, upscalers, text encoders, CLIP vision,
|
||||||
|
opt-in ControlNet) into one scanner; sub_type is derived from the root
|
||||||
|
containing the file (mirrors CheckpointScanner's checkpoints/unet split).
|
||||||
|
|
||||||
|
Hashing is lazy (checkpoint-style): text encoders can be ~10 GB, so the
|
||||||
|
initial scan records hash_status="pending" and the SHA256 is computed
|
||||||
|
on-demand via calculate_hash_for_model (e.g. when fetching CivitAI
|
||||||
|
metadata).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Same extension set as CheckpointScanner (ComfyUI's
|
||||||
|
# supported_pt_extensions plus ".gguf").
|
||||||
|
file_extensions = {
|
||||||
|
".ckpt",
|
||||||
|
".pt",
|
||||||
|
".pt2",
|
||||||
|
".bin",
|
||||||
|
".pth",
|
||||||
|
".safetensors",
|
||||||
|
".pkl",
|
||||||
|
".sft",
|
||||||
|
".gguf",
|
||||||
|
}
|
||||||
|
super().__init__(
|
||||||
|
model_type="other",
|
||||||
|
model_class=OtherModelMetadata,
|
||||||
|
file_extensions=file_extensions,
|
||||||
|
hash_index=ModelHashIndex(),
|
||||||
|
)
|
||||||
|
if not hasattr(self, "_hash_calculation_lock"):
|
||||||
|
self._hash_calculation_lock = asyncio.Lock()
|
||||||
|
self._hash_calculation_tasks: dict[str, asyncio.Task[Optional[str]]] = {}
|
||||||
|
|
||||||
|
async def _create_default_metadata(
|
||||||
|
self, file_path: str
|
||||||
|
) -> Optional[OtherModelMetadata]:
|
||||||
|
"""Create default metadata without calculating hash (lazy hash).
|
||||||
|
|
||||||
|
Other models include multi-GB text encoders, so hash calculation is
|
||||||
|
deferred until on-demand (e.g. CivitAI metadata fetch).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
real_path = os.path.realpath(file_path)
|
||||||
|
if not os.path.exists(real_path):
|
||||||
|
logger.error(f"File not found: {file_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
base_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
dir_path = os.path.dirname(file_path)
|
||||||
|
|
||||||
|
# Find preview image
|
||||||
|
preview_url = find_preview_file(base_name, dir_path)
|
||||||
|
|
||||||
|
# AutoV3 reads only the safetensors header, so it is cheap even for
|
||||||
|
# large files; record the checked state at creation time ("" =
|
||||||
|
# checked but unavailable).
|
||||||
|
autov3 = calculate_autov3(real_path)
|
||||||
|
|
||||||
|
# Create metadata WITHOUT calculating hash
|
||||||
|
metadata = OtherModelMetadata(
|
||||||
|
file_name=base_name,
|
||||||
|
model_name=base_name,
|
||||||
|
file_path=normalize_path(file_path),
|
||||||
|
size=os.path.getsize(real_path),
|
||||||
|
modified=datetime.now().timestamp(),
|
||||||
|
sha256="", # Empty hash - will be calculated on-demand
|
||||||
|
base_model="Unknown",
|
||||||
|
preview_url=normalize_path(preview_url),
|
||||||
|
tags=[],
|
||||||
|
modelDescription="",
|
||||||
|
sub_type=self.resolve_sub_type_for_path(file_path) or "vae",
|
||||||
|
from_civitai=False, # Mark as local model since no hash yet
|
||||||
|
hash_status="pending", # Mark hash as pending
|
||||||
|
autov3=autov3 or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save the created metadata
|
||||||
|
logger.info(f"Creating other-model metadata (hash pending) for {file_path}")
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Error creating default other-model metadata for {file_path}: {e}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def calculate_hash_for_model(self, file_path: str) -> Optional[str]:
|
||||||
|
"""Calculate hash for a model on-demand with per-file singleflight.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to the model file
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SHA256 hash string, or None if calculation failed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
real_path = os.path.realpath(file_path)
|
||||||
|
if not os.path.exists(real_path):
|
||||||
|
logger.error(f"File not found for hash calculation: {file_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
metadata is not None
|
||||||
|
and metadata.hash_status == "completed"
|
||||||
|
and metadata.sha256
|
||||||
|
):
|
||||||
|
# Ensure the in-memory hash index is populated even when
|
||||||
|
# the hash was already computed and persisted to the metadata
|
||||||
|
# file. Without this, usage tracking (and any other caller
|
||||||
|
# that queries get_hash_by_filename first) will miss on every
|
||||||
|
# lookup and keep calling back into this method, creating a
|
||||||
|
# tight loop that never populates the index.
|
||||||
|
self._hash_index.add_entry(
|
||||||
|
metadata.sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
|
return metadata.sha256
|
||||||
|
|
||||||
|
async with self._hash_calculation_lock:
|
||||||
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
metadata is not None
|
||||||
|
and metadata.hash_status == "completed"
|
||||||
|
and metadata.sha256
|
||||||
|
):
|
||||||
|
self._hash_index.add_entry(
|
||||||
|
metadata.sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
|
return metadata.sha256
|
||||||
|
|
||||||
|
task = self._hash_calculation_tasks.get(real_path)
|
||||||
|
if task is None:
|
||||||
|
task = asyncio.create_task(
|
||||||
|
self._run_hash_calculation_task(file_path, real_path)
|
||||||
|
)
|
||||||
|
self._hash_calculation_tasks[real_path] = task
|
||||||
|
|
||||||
|
return await asyncio.shield(task)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _run_hash_calculation_task(
|
||||||
|
self, file_path: str, real_path: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Run a hash calculation task and remove it from the in-flight map."""
|
||||||
|
try:
|
||||||
|
return await self._calculate_hash_for_model_uncached(file_path, real_path)
|
||||||
|
finally:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
async with self._hash_calculation_lock:
|
||||||
|
if self._hash_calculation_tasks.get(real_path) is task:
|
||||||
|
del self._hash_calculation_tasks[real_path]
|
||||||
|
|
||||||
|
async def _calculate_hash_for_model_uncached(
|
||||||
|
self, file_path: str, real_path: str
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Calculate hash for a model without checking in-flight tasks."""
|
||||||
|
from ..utils.file_utils import calculate_sha256
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Load current metadata
|
||||||
|
metadata, should_skip = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
|
if metadata is None:
|
||||||
|
if should_skip:
|
||||||
|
logger.error(f"Invalid metadata found for {file_path}")
|
||||||
|
return None
|
||||||
|
created_metadata = await self._create_default_metadata(file_path)
|
||||||
|
if created_metadata is None:
|
||||||
|
logger.error(f"No metadata found for {file_path}")
|
||||||
|
return None
|
||||||
|
metadata = created_metadata
|
||||||
|
|
||||||
|
# Check if hash is already calculated
|
||||||
|
if metadata.hash_status == "completed" and metadata.sha256:
|
||||||
|
# Populate the in-memory hash index even for pre-computed
|
||||||
|
# hashes, mirroring the fix in calculate_hash_for_model.
|
||||||
|
self._hash_index.add_entry(
|
||||||
|
metadata.sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
|
return metadata.sha256
|
||||||
|
|
||||||
|
# Update status to calculating
|
||||||
|
metadata.hash_status = "calculating"
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
|
# Calculate hash
|
||||||
|
logger.info(f"Calculating hash for other model: {file_path}")
|
||||||
|
sha256 = await calculate_sha256(real_path)
|
||||||
|
|
||||||
|
# Update metadata with hash
|
||||||
|
metadata.sha256 = sha256
|
||||||
|
metadata.hash_status = "completed"
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
|
||||||
|
# Update hash index
|
||||||
|
self._hash_index.add_entry(
|
||||||
|
sha256.lower(),
|
||||||
|
file_path,
|
||||||
|
getattr(metadata, "autov3", None) or None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update the in-memory cache entry so that subsequent
|
||||||
|
# _persist_current_cache / _save_persistent_cache calls
|
||||||
|
# write the hash back to the SQLite models table. Without
|
||||||
|
# this the hash only lives in the metadata file and the
|
||||||
|
# in-memory hash index, both of which are lost across
|
||||||
|
# restarts, causing the same re-computation loop on the
|
||||||
|
# next session.
|
||||||
|
if self._cache is not None and self._cache.raw_data:
|
||||||
|
for entry in self._cache.raw_data:
|
||||||
|
if entry.get("file_path") == file_path:
|
||||||
|
entry["sha256"] = sha256.lower()
|
||||||
|
entry["hash_status"] = "completed"
|
||||||
|
self.bump_cache_version()
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"Hash calculated for other model: {file_path}")
|
||||||
|
return sha256
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||||
|
# Update status to failed
|
||||||
|
try:
|
||||||
|
metadata, _ = await MetadataManager.load_metadata(
|
||||||
|
file_path, self.model_class
|
||||||
|
)
|
||||||
|
if metadata:
|
||||||
|
metadata.hash_status = "failed"
|
||||||
|
await MetadataManager.save_metadata(file_path, metadata)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def calculate_all_pending_hashes(
|
||||||
|
self, progress_callback=None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""Calculate hashes for all other models with pending hash status.
|
||||||
|
|
||||||
|
If cache is not initialized, scans filesystem directly for metadata files
|
||||||
|
with hash_status != 'completed'.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
progress_callback: Optional callback(progress, total, current_file)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with 'completed', 'failed', 'total' counts
|
||||||
|
"""
|
||||||
|
# Try to get from cache first
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
|
if cache and cache.raw_data:
|
||||||
|
# Use cache if available
|
||||||
|
pending_models = [
|
||||||
|
item
|
||||||
|
for item in cache.raw_data
|
||||||
|
if item.get("hash_status") != "completed" or not item.get("sha256")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
# Cache not initialized, scan filesystem directly
|
||||||
|
pending_models = await self._find_pending_models_from_filesystem()
|
||||||
|
|
||||||
|
if not pending_models:
|
||||||
|
return {"completed": 0, "failed": 0, "total": 0}
|
||||||
|
|
||||||
|
total = len(pending_models)
|
||||||
|
completed = 0
|
||||||
|
failed = 0
|
||||||
|
|
||||||
|
for i, model_data in enumerate(pending_models):
|
||||||
|
file_path = model_data.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
sha256 = await self.calculate_hash_for_model(file_path)
|
||||||
|
if sha256:
|
||||||
|
completed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error calculating hash for {file_path}: {e}")
|
||||||
|
failed += 1
|
||||||
|
|
||||||
|
if progress_callback:
|
||||||
|
try:
|
||||||
|
await progress_callback(i + 1, total, file_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"completed": completed, "failed": failed, "total": total}
|
||||||
|
|
||||||
|
async def _find_pending_models_from_filesystem(self) -> List[Dict[str, Any]]:
|
||||||
|
"""Scan filesystem for other-model metadata files with pending hash status."""
|
||||||
|
pending_models = []
|
||||||
|
|
||||||
|
for root_path in self.get_model_roots():
|
||||||
|
if not os.path.exists(root_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||||
|
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||||
|
for filename in filenames:
|
||||||
|
if not filename.endswith(".metadata.json"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
metadata_path = os.path.join(dirpath, filename)
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Check if hash is pending
|
||||||
|
hash_status = data.get("hash_status", "completed")
|
||||||
|
sha256 = data.get("sha256", "")
|
||||||
|
|
||||||
|
if hash_status != "completed" or not sha256:
|
||||||
|
# Find corresponding model file
|
||||||
|
model_name = filename.replace(".metadata.json", "")
|
||||||
|
model_path = None
|
||||||
|
|
||||||
|
# Look for model file with matching name
|
||||||
|
for ext in self.file_extensions:
|
||||||
|
potential_path = os.path.join(dirpath, model_name + ext)
|
||||||
|
if os.path.exists(potential_path):
|
||||||
|
model_path = potential_path
|
||||||
|
break
|
||||||
|
|
||||||
|
if model_path:
|
||||||
|
pending_models.append(
|
||||||
|
{
|
||||||
|
"file_path": model_path.replace(os.sep, "/"),
|
||||||
|
"hash_status": hash_status,
|
||||||
|
"sha256": sha256,
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in data.items()
|
||||||
|
if k
|
||||||
|
not in [
|
||||||
|
"file_path",
|
||||||
|
"hash_status",
|
||||||
|
"sha256",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (json.JSONDecodeError, Exception) as e:
|
||||||
|
logger.debug(
|
||||||
|
f"Error reading metadata file {metadata_path}: {e}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return pending_models
|
||||||
|
|
||||||
|
def _root_sub_type_map(self) -> Dict[str, str]:
|
||||||
|
"""Return the configured business root -> sub_type map."""
|
||||||
|
root_map = getattr(config, "other_root_subtypes", None)
|
||||||
|
return root_map if isinstance(root_map, dict) else {}
|
||||||
|
|
||||||
|
def _resolve_sub_type(self, root_path: Optional[str]) -> Optional[str]:
|
||||||
|
"""Resolve the sub_type for a configured root path."""
|
||||||
|
if not root_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_root = self._normalize_path_value(root_path)
|
||||||
|
for root, sub_type in self._root_sub_type_map().items():
|
||||||
|
if self._normalize_path_value(root) == normalized_root:
|
||||||
|
return sub_type
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_sub_type_for_path(self, file_path: Optional[str]) -> Optional[str]:
|
||||||
|
"""Resolve sub_type from the configured root that contains the file.
|
||||||
|
|
||||||
|
Uses the longest-prefix match so nested roots (e.g. a controlnet root
|
||||||
|
inside a vae root) resolve to the most specific category.
|
||||||
|
"""
|
||||||
|
normalized_path = self._normalize_path_value(file_path)
|
||||||
|
if not normalized_path:
|
||||||
|
return None
|
||||||
|
|
||||||
|
best_length = 0
|
||||||
|
best_sub_type: Optional[str] = None
|
||||||
|
for root, sub_type in self._root_sub_type_map().items():
|
||||||
|
normalized_root = self._normalize_path_value(root)
|
||||||
|
if not normalized_root:
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
normalized_path == normalized_root
|
||||||
|
or normalized_path.startswith(f"{normalized_root}/")
|
||||||
|
) and len(normalized_root) > best_length:
|
||||||
|
best_length = len(normalized_root)
|
||||||
|
best_sub_type = sub_type
|
||||||
|
|
||||||
|
return best_sub_type
|
||||||
|
|
||||||
|
def adjust_metadata(self, metadata, file_path, root_path):
|
||||||
|
"""Adjust metadata during scanning to set sub_type."""
|
||||||
|
sub_type = self._resolve_sub_type(root_path) or self.resolve_sub_type_for_path(
|
||||||
|
file_path
|
||||||
|
)
|
||||||
|
if sub_type:
|
||||||
|
metadata.sub_type = sub_type
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def adjust_cached_entry(self, entry: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Adjust entries loaded from the persisted cache to ensure sub_type is set.
|
||||||
|
|
||||||
|
sub_type is location-derived: it is re-derived on cache load, never
|
||||||
|
trusted from the persisted snapshot.
|
||||||
|
"""
|
||||||
|
sub_type = self.resolve_sub_type_for_path(entry.get("file_path"))
|
||||||
|
if sub_type:
|
||||||
|
entry["sub_type"] = sub_type
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def _should_keep_cached_entry(self, entry: Dict[str, Any]) -> bool:
|
||||||
|
"""Drop persisted entries whose folder is no longer a managed root.
|
||||||
|
|
||||||
|
sub_type is location-derived and config only maps enabled roots, so a
|
||||||
|
file under a disabled sub_type - or under any other root while the
|
||||||
|
feature is off - resolves to None here and is filtered out while the
|
||||||
|
persisted cache is hydrated.
|
||||||
|
"""
|
||||||
|
return self.resolve_sub_type_for_path(entry.get("file_path")) is not None
|
||||||
|
|
||||||
|
def get_model_roots(self) -> List[str]:
|
||||||
|
"""Get other-model root directories"""
|
||||||
|
roots: List[str] = []
|
||||||
|
roots.extend(config.other_roots or [])
|
||||||
|
# Remove duplicates while preserving order
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique_roots: List[str] = []
|
||||||
|
for root in roots:
|
||||||
|
if root and root not in seen:
|
||||||
|
seen.add(root)
|
||||||
|
unique_roots.append(root)
|
||||||
|
return unique_roots
|
||||||
@@ -59,6 +59,7 @@ _MODEL_TYPE_PAGE_MAP = {
|
|||||||
"lora": "loras",
|
"lora": "loras",
|
||||||
"checkpoint": "checkpoints",
|
"checkpoint": "checkpoints",
|
||||||
"embedding": "embeddings",
|
"embedding": "embeddings",
|
||||||
|
"other": "other",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Module-level alias so tests can spy on timer task creation without patching
|
# Module-level alias so tests can spy on timer task creation without patching
|
||||||
@@ -983,6 +984,7 @@ class PendingDeleteService:
|
|||||||
"get_lora_scanner",
|
"get_lora_scanner",
|
||||||
"get_checkpoint_scanner",
|
"get_checkpoint_scanner",
|
||||||
"get_embedding_scanner",
|
"get_embedding_scanner",
|
||||||
|
"get_other_scanner",
|
||||||
):
|
):
|
||||||
getter = getattr(ServiceRegistry, getter_name, None)
|
getter = getattr(ServiceRegistry, getter_name, None)
|
||||||
if not callable(getter):
|
if not callable(getter):
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ class PersistedCacheData:
|
|||||||
hash_rows: List[Tuple[str, str]]
|
hash_rows: List[Tuple[str, str]]
|
||||||
excluded_models: List[str]
|
excluded_models: List[str]
|
||||||
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
|
autov3_hash_rows: List[Tuple[str, str]] = field(default_factory=list)
|
||||||
|
# Every directory under the model roots (including empty ones), or None
|
||||||
|
# when the snapshot predates folder recording.
|
||||||
|
all_folders: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
|
DEFAULT_LICENSE_FLAGS = 127 # 127 (0b1111111) encodes default CivitAI permissions with all commercial modes enabled.
|
||||||
@@ -128,6 +131,14 @@ class PersistentModelCache:
|
|||||||
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
"SELECT file_path FROM excluded_models WHERE model_type = ?",
|
||||||
(model_type,),
|
(model_type,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
folder_rows = conn.execute(
|
||||||
|
"SELECT path FROM folders WHERE model_type = ?",
|
||||||
|
(model_type,),
|
||||||
|
).fetchall()
|
||||||
|
folders_recorded = conn.execute(
|
||||||
|
"SELECT value FROM cache_meta WHERE key = ?",
|
||||||
|
(f"folders_recorded:{model_type}",),
|
||||||
|
).fetchone()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -216,14 +227,20 @@ class PersistentModelCache:
|
|||||||
]
|
]
|
||||||
|
|
||||||
excluded_paths = [row["file_path"] for row in excluded]
|
excluded_paths = [row["file_path"] for row in excluded]
|
||||||
|
all_folders: Optional[List[str]] = None
|
||||||
|
if folders_recorded is not None:
|
||||||
|
all_folders = sorted(
|
||||||
|
(row["path"] for row in folder_rows), key=lambda x: x.lower()
|
||||||
|
)
|
||||||
return PersistedCacheData(
|
return PersistedCacheData(
|
||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
hash_rows=hash_pairs,
|
hash_rows=hash_pairs,
|
||||||
excluded_models=excluded_paths,
|
excluded_models=excluded_paths,
|
||||||
autov3_hash_rows=autov3_pairs,
|
autov3_hash_rows=autov3_pairs,
|
||||||
|
all_folders=all_folders,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None) -> None:
|
def save_cache(self, model_type: str, raw_data: Sequence[Dict[str, Any]], hash_index: Dict[str, List[str]], excluded_models: Sequence[str], autov3_hash_index: Optional[Dict[str, List[str]]] = None, all_folders: Optional[Sequence[str]] = None) -> None:
|
||||||
if not self.is_enabled():
|
if not self.is_enabled():
|
||||||
return
|
return
|
||||||
if not self._schema_initialized:
|
if not self._schema_initialized:
|
||||||
@@ -469,6 +486,27 @@ class PersistentModelCache:
|
|||||||
excluded_inserts,
|
excluded_inserts,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if all_folders is not None:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM folders WHERE model_type = ?",
|
||||||
|
(model_type,),
|
||||||
|
)
|
||||||
|
folder_inserts = [
|
||||||
|
(model_type, path) for path in all_folders if path
|
||||||
|
]
|
||||||
|
if folder_inserts:
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT OR IGNORE INTO folders (model_type, path) VALUES (?, ?)",
|
||||||
|
folder_inserts,
|
||||||
|
)
|
||||||
|
# Mark the snapshot as having folder data even when the
|
||||||
|
# library has no subfolders, so an empty list is not
|
||||||
|
# mistaken for "never recorded" on load.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO cache_meta (key, value) VALUES (?, ?)",
|
||||||
|
(f"folders_recorded:{model_type}", "1"),
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -554,6 +592,17 @@ class PersistentModelCache:
|
|||||||
file_path TEXT NOT NULL,
|
file_path TEXT NOT NULL,
|
||||||
PRIMARY KEY (model_type, file_path)
|
PRIMARY KEY (model_type, file_path)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS folders (
|
||||||
|
model_type TEXT NOT NULL,
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (model_type, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT
|
||||||
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
self._ensure_additional_model_columns(conn)
|
self._ensure_additional_model_columns(conn)
|
||||||
|
|||||||
+159
-31
@@ -483,32 +483,43 @@ class RecipeScanner:
|
|||||||
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
suggestions.sort(key=lambda s: (-s["score"], s["file_name"].lower()))
|
||||||
return suggestions[:limit]
|
return suggestions[:limit]
|
||||||
|
|
||||||
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
def _is_rematch_candidate(
|
||||||
|
self, entry: dict[str, Any], relaxed: bool = False
|
||||||
|
) -> bool:
|
||||||
"""Return True when a recipe entry is eligible for local re-matching.
|
"""Return True when a recipe entry is eligible for local re-matching.
|
||||||
|
|
||||||
An entry counts as unresolved when its identity is known to be
|
An entry counts as unresolved when its identity is known to be
|
||||||
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
broken (``isDeleted`` or ``hashInvalid``) or when it is missing
|
||||||
identity fields (``hash``/``file_name``). A healthy entry whose
|
identity fields (``hash``/``file_name``). A healthy entry whose
|
||||||
hash is simply not present in the local library is NOT a candidate:
|
hash is simply not present in the local library is NOT a candidate
|
||||||
it may be a recipe imported without downloading the model yet, and
|
in the default strict mode: it may be a recipe imported without
|
||||||
its CivitAI-valid hash must never be overwritten by the imprecise
|
downloading the model yet, and its CivitAI-valid hash must never be
|
||||||
filename fallback.
|
overwritten by the imprecise filename fallback.
|
||||||
|
|
||||||
|
With ``relaxed=True`` any entry carrying an identifier is a
|
||||||
|
candidate, including healthy ones — the caller opted into trying to
|
||||||
|
reconnect "Not in Library" entries by file name. Entries without
|
||||||
|
any identifier are never candidates in either mode.
|
||||||
"""
|
"""
|
||||||
if not isinstance(entry, dict):
|
if not isinstance(entry, dict):
|
||||||
return False
|
return False
|
||||||
unresolved = (
|
|
||||||
entry.get("isDeleted")
|
|
||||||
or entry.get("hashInvalid")
|
|
||||||
or not entry.get("hash")
|
|
||||||
or not entry.get("file_name")
|
|
||||||
)
|
|
||||||
has_identifier = (
|
has_identifier = (
|
||||||
entry.get("hash")
|
entry.get("hash")
|
||||||
or entry.get("modelVersionId")
|
or entry.get("modelVersionId")
|
||||||
or entry.get("id")
|
or entry.get("id")
|
||||||
or entry.get("file_name")
|
or entry.get("file_name")
|
||||||
)
|
)
|
||||||
return bool(unresolved and has_identifier)
|
if not has_identifier:
|
||||||
|
return False
|
||||||
|
if relaxed:
|
||||||
|
return True
|
||||||
|
unresolved = (
|
||||||
|
entry.get("isDeleted")
|
||||||
|
or entry.get("hashInvalid")
|
||||||
|
or not entry.get("hash")
|
||||||
|
or not entry.get("file_name")
|
||||||
|
)
|
||||||
|
return bool(unresolved)
|
||||||
|
|
||||||
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
|
async def _build_rematch_autov3_cache(self) -> dict[str, dict[str, Any]]:
|
||||||
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
"""Build a version-cached map of computed AutoV3 hashes to local items.
|
||||||
@@ -809,7 +820,9 @@ class RecipeScanner:
|
|||||||
"""Check if cancellation has been requested."""
|
"""Check if cancellation has been requested."""
|
||||||
return self._cancel_requested
|
return self._cancel_requested
|
||||||
|
|
||||||
async def rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
async def rematch_recipe_by_id(
|
||||||
|
self, recipe_id: str, *, relaxed: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
Logs one INFO summary line for this run and delegates the per-recipe
|
Logs one INFO summary line for this run and delegates the per-recipe
|
||||||
@@ -817,12 +830,14 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_id: ID of the recipe to rematch
|
recipe_id: ID of the recipe to rematch
|
||||||
|
relaxed: When True, healthy entries are rematch candidates too
|
||||||
|
(see ``_rematch_single_recipe``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
Dict summary of the rematch result (see ``_rematch_recipe_by_id``).
|
||||||
Raises RecipeNotFoundError when the recipe is missing.
|
Raises RecipeNotFoundError when the recipe is missing.
|
||||||
"""
|
"""
|
||||||
result = await self._rematch_recipe_by_id(recipe_id)
|
result = await self._rematch_recipe_by_id(recipe_id, relaxed=relaxed)
|
||||||
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
|
recipe_name = (result.get("recipe") or {}).get("name") or recipe_id
|
||||||
logger.info(
|
logger.info(
|
||||||
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
"Recipe rematch %s (%s): success=%s, %d entries matched, %d unresolved, %d errors",
|
||||||
@@ -835,7 +850,9 @@ class RecipeScanner:
|
|||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
async def _rematch_recipe_by_id(
|
||||||
|
self, recipe_id: str, *, relaxed: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
Match snapshots (local hash cache, computed autov3 cache, filename
|
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||||
@@ -846,12 +863,16 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_id: ID of the recipe to rematch
|
recipe_id: ID of the recipe to rematch
|
||||||
|
relaxed: When True, healthy entries are rematch candidates too
|
||||||
|
(see ``_rematch_single_recipe``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the rematch result with unified counters
|
Dict summary of the rematch result with unified counters
|
||||||
(matched_recipes, matched_entries, unresolved_recipes,
|
(matched_recipes, matched_entries, unresolved_recipes,
|
||||||
unresolved_entries plus the legacy rematched/skipped/errors
|
unresolved_entries plus the legacy rematched/skipped/errors
|
||||||
fields) and a per-entry ``details`` report. The legacy ``skipped``
|
fields) and a per-entry ``details`` report plus a flattened
|
||||||
|
``l4_matches`` list (filename-level matches for review/undo,
|
||||||
|
consistent with the bulk/global paths). The legacy ``skipped``
|
||||||
field means "recipe not updated" and overlaps
|
field means "recipe not updated" and overlaps
|
||||||
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
``unresolved_recipes`` (a recipe with unmatched candidates counts
|
||||||
as both). Raises RecipeNotFoundError when the recipe is missing.
|
as both). Raises RecipeNotFoundError when the recipe is missing.
|
||||||
@@ -872,7 +893,8 @@ class RecipeScanner:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
rematched, _errors, details = await self._rematch_single_recipe(
|
rematched, _errors, details = await self._rematch_single_recipe(
|
||||||
recipe, local_cache, autov3_cache, filename_cache
|
recipe, local_cache, autov3_cache, filename_cache,
|
||||||
|
relaxed=relaxed,
|
||||||
)
|
)
|
||||||
except RecipePersistenceError as exc:
|
except RecipePersistenceError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -891,12 +913,16 @@ class RecipeScanner:
|
|||||||
"unresolved_recipes": 0,
|
"unresolved_recipes": 0,
|
||||||
"unresolved_entries": 0,
|
"unresolved_entries": 0,
|
||||||
"details": {"matched": [], "unresolved": []},
|
"details": {"matched": [], "unresolved": []},
|
||||||
|
"l4_matches": [],
|
||||||
"recipe": recipe,
|
"recipe": recipe,
|
||||||
"error": str(exc),
|
"error": str(exc),
|
||||||
}
|
}
|
||||||
|
|
||||||
unresolved_entries = len(details["unresolved"])
|
unresolved_entries = len(details["unresolved"])
|
||||||
unresolved_recipes = 1 if unresolved_entries > 0 else 0
|
unresolved_recipes = 1 if unresolved_entries > 0 else 0
|
||||||
|
# Flattened L4 matches for the results modal, consistent with
|
||||||
|
# the bulk/global paths.
|
||||||
|
l4_matches = self._collect_l4_matches(recipe_id, details)
|
||||||
|
|
||||||
if rematched == 0:
|
if rematched == 0:
|
||||||
return {
|
return {
|
||||||
@@ -908,6 +934,7 @@ class RecipeScanner:
|
|||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"details": details,
|
"details": details,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
"recipe": recipe,
|
"recipe": recipe,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +948,7 @@ class RecipeScanner:
|
|||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"details": details,
|
"details": details,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
"recipe": await self.get_recipe_by_id(recipe_id),
|
"recipe": await self.get_recipe_by_id(recipe_id),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -930,6 +958,8 @@ class RecipeScanner:
|
|||||||
local_cache: dict[str, dict[str, Any]],
|
local_cache: dict[str, dict[str, Any]],
|
||||||
autov3_cache: dict[str, dict[str, Any]],
|
autov3_cache: dict[str, dict[str, Any]],
|
||||||
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||||
|
*,
|
||||||
|
relaxed: bool = False,
|
||||||
) -> Tuple[int, int, Dict[str, Any]]:
|
) -> Tuple[int, int, Dict[str, Any]]:
|
||||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||||
|
|
||||||
@@ -945,16 +975,24 @@ class RecipeScanner:
|
|||||||
autov3_cache: L3 computed-autov3 cache snapshot
|
autov3_cache: L3 computed-autov3 cache snapshot
|
||||||
filename_cache: L4 filename cache snapshot, or None to disable
|
filename_cache: L4 filename cache snapshot, or None to disable
|
||||||
the filename fallback
|
the filename fallback
|
||||||
|
relaxed: When True, healthy entries ("Not in Library") are also
|
||||||
|
rematch candidates. Anti-churn rule: an entry that is a
|
||||||
|
candidate ONLY because of relaxed mode is skipped when its
|
||||||
|
hash already resolves in the L1 ``local_cache`` — it is
|
||||||
|
already correctly linked and rematching would only add noise
|
||||||
|
and a pointless snapshot.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (rematched_entries, errors, details). The errors element
|
Tuple of (rematched_entries, errors, details). The errors element
|
||||||
is always 0 on a normal return — a persistence failure RAISES
|
is always 0 on a normal return — a persistence failure RAISES
|
||||||
``RecipePersistenceError`` so callers can count it. ``details``
|
``RecipePersistenceError`` so callers can count it. ``details``
|
||||||
carries the per-entry outcome:
|
carries the per-entry outcome:
|
||||||
``{"matched": [{type, entry, file_name, match_level}],
|
``{"matched": [{type, entry, file_name, match_level, lora_index?}],
|
||||||
"unresolved": [{type, entry}]}`` where an unresolved entry is a
|
"unresolved": [{type, entry}]}`` where an unresolved entry is a
|
||||||
rematch candidate that found no local match — an expected outcome
|
rematch candidate that found no local match — an expected outcome
|
||||||
(the model may simply not exist locally), not an error.
|
(the model may simply not exist locally), not an error.
|
||||||
|
``lora_index`` is only present for lora entries (the checkpoint
|
||||||
|
restore endpoint needs no index).
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
RecipePersistenceError: when the recipe changed but
|
RecipePersistenceError: when the recipe changed but
|
||||||
@@ -963,11 +1001,23 @@ class RecipeScanner:
|
|||||||
rematched = 0
|
rematched = 0
|
||||||
details: Dict[str, Any] = {"matched": [], "unresolved": []}
|
details: Dict[str, Any] = {"matched": [], "unresolved": []}
|
||||||
|
|
||||||
|
def is_actionable_candidate(entry: Dict[str, Any]) -> bool:
|
||||||
|
"""Apply candidacy plus the relaxed-mode anti-churn rule."""
|
||||||
|
if self._is_rematch_candidate(entry):
|
||||||
|
return True
|
||||||
|
if not relaxed or not self._is_rematch_candidate(entry, relaxed=True):
|
||||||
|
return False
|
||||||
|
# Relaxed-only candidate: skip when the stored hash already
|
||||||
|
# resolves in the L1 local cache — the entry is already correctly
|
||||||
|
# linked and rematching would just add noise and a snapshot.
|
||||||
|
entry_hash = (entry.get("hash") or "").lower()
|
||||||
|
return local_cache.get(entry_hash) is None
|
||||||
|
|
||||||
# Lora entries
|
# Lora entries
|
||||||
loras = recipe.get("loras", [])
|
loras = recipe.get("loras", [])
|
||||||
if isinstance(loras, list):
|
if isinstance(loras, list):
|
||||||
for entry in loras:
|
for lora_index, entry in enumerate(loras):
|
||||||
if not self._is_rematch_candidate(entry):
|
if not is_actionable_candidate(entry):
|
||||||
continue
|
continue
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
item, level = await self._match_rematch_entry_with_level(
|
||||||
entry,
|
entry,
|
||||||
@@ -991,6 +1041,7 @@ class RecipeScanner:
|
|||||||
"entry": self._entry_identifier(entry),
|
"entry": self._entry_identifier(entry),
|
||||||
"file_name": item.get("file_name") or "",
|
"file_name": item.get("file_name") or "",
|
||||||
"match_level": level,
|
"match_level": level,
|
||||||
|
"lora_index": lora_index,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self._write_rematch_lora_entry(entry, item)
|
self._write_rematch_lora_entry(entry, item)
|
||||||
@@ -1000,7 +1051,7 @@ class RecipeScanner:
|
|||||||
# silently since ``entry.get`` on a str would raise AttributeError).
|
# silently since ``entry.get`` on a str would raise AttributeError).
|
||||||
checkpoint = recipe.get("checkpoint")
|
checkpoint = recipe.get("checkpoint")
|
||||||
if isinstance(checkpoint, dict):
|
if isinstance(checkpoint, dict):
|
||||||
if self._is_rematch_candidate(checkpoint):
|
if is_actionable_candidate(checkpoint):
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
item, level = await self._match_rematch_entry_with_level(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
local_cache,
|
local_cache,
|
||||||
@@ -1065,8 +1116,36 @@ class RecipeScanner:
|
|||||||
self._update_fts_index_for_recipe(recipe, "update")
|
self._update_fts_index_for_recipe(recipe, "update")
|
||||||
return (rematched, 0, details)
|
return (rematched, 0, details)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _collect_l4_matches(
|
||||||
|
recipe_id: Any, details: Dict[str, Any]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Flatten a recipe's L4 (filename-level) matches for review.
|
||||||
|
|
||||||
|
Returns ``[{recipe_id, type, entry, file_name, lora_index?}]`` rows —
|
||||||
|
one per matched detail at level L4. ``lora_index`` is only present
|
||||||
|
for lora entries (checkpoint restore needs no index).
|
||||||
|
"""
|
||||||
|
rows: List[Dict[str, Any]] = []
|
||||||
|
for match in details.get("matched", []):
|
||||||
|
if match.get("match_level") != "L4":
|
||||||
|
continue
|
||||||
|
row: Dict[str, Any] = {
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"type": match.get("type"),
|
||||||
|
"entry": match.get("entry"),
|
||||||
|
"file_name": match.get("file_name"),
|
||||||
|
}
|
||||||
|
if "lora_index" in match:
|
||||||
|
row["lora_index"] = match["lora_index"]
|
||||||
|
rows.append(row)
|
||||||
|
return rows
|
||||||
|
|
||||||
async def rematch_all_recipes(
|
async def rematch_all_recipes(
|
||||||
self, progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None
|
self,
|
||||||
|
progress_callback: Optional[Callable[[Dict[str, Any]], Any]] = None,
|
||||||
|
*,
|
||||||
|
relaxed: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
@@ -1080,14 +1159,19 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_callback: Optional callback for progress updates
|
progress_callback: Optional callback for progress updates
|
||||||
(started/processing/cancelled/completed events).
|
(started/processing/cancelled/completed events). The
|
||||||
|
completed/cancelled payloads carry ``l4_matches``, a
|
||||||
|
flattened list of filename-level matches for review/undo.
|
||||||
|
relaxed: When True, healthy entries are rematch candidates too
|
||||||
|
(see ``_rematch_single_recipe``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the rematch run with unified counters
|
Dict summary of the rematch run with unified counters
|
||||||
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
(matched_recipes/matched_entries/unresolved_recipes/unresolved_
|
||||||
entries plus the legacy success/status/rematched/skipped/errors/
|
entries plus the legacy success/status/rematched/skipped/errors/
|
||||||
total fields). ``rematched`` (legacy) counts updated recipes —
|
total fields) and ``l4_matches``. ``rematched`` (legacy) counts
|
||||||
use ``matched_entries`` for the entry-level total.
|
updated recipes — use ``matched_entries`` for the entry-level
|
||||||
|
total.
|
||||||
"""
|
"""
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
|
|
||||||
@@ -1109,6 +1193,7 @@ class RecipeScanner:
|
|||||||
unresolved_entries = 0
|
unresolved_entries = 0
|
||||||
skipped_count = 0
|
skipped_count = 0
|
||||||
errors_count = 0
|
errors_count = 0
|
||||||
|
l4_matches: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for i, recipe in enumerate(all_recipes):
|
for i, recipe in enumerate(all_recipes):
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
@@ -1137,6 +1222,7 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
@@ -1150,6 +1236,7 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1165,11 +1252,15 @@ class RecipeScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
rematched, _errors, details = await self._rematch_single_recipe(
|
rematched, _errors, details = await self._rematch_single_recipe(
|
||||||
recipe, local_cache, autov3_cache, filename_cache
|
recipe, local_cache, autov3_cache, filename_cache,
|
||||||
|
relaxed=relaxed,
|
||||||
)
|
)
|
||||||
if rematched > 0:
|
if rematched > 0:
|
||||||
matched_recipes += 1
|
matched_recipes += 1
|
||||||
matched_entries += rematched
|
matched_entries += rematched
|
||||||
|
l4_matches.extend(
|
||||||
|
self._collect_l4_matches(recipe.get("id"), details)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
skipped_count += 1
|
skipped_count += 1
|
||||||
|
|
||||||
@@ -1215,6 +1306,7 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1228,9 +1320,12 @@ class RecipeScanner:
|
|||||||
"matched_entries": matched_entries,
|
"matched_entries": matched_entries,
|
||||||
"unresolved_recipes": unresolved_recipes,
|
"unresolved_recipes": unresolved_recipes,
|
||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def rematch_recipes_bulk(self, recipe_ids: List[str]) -> Dict[str, Any]:
|
async def rematch_recipes_bulk(
|
||||||
|
self, recipe_ids: List[str], *, relaxed: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
"""Rematch a set of recipes by their IDs.
|
"""Rematch a set of recipes by their IDs.
|
||||||
|
|
||||||
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
Iterates ``_rematch_recipe_by_id`` over each id: not-found ids are
|
||||||
@@ -1241,14 +1336,18 @@ class RecipeScanner:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_ids: List of recipe ids to rematch.
|
recipe_ids: List of recipe ids to rematch.
|
||||||
|
relaxed: When True, healthy entries are rematch candidates too
|
||||||
|
(see ``_rematch_single_recipe``).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict summary of the bulk run with unified counters
|
Dict summary of the bulk run with unified counters
|
||||||
(matched_recipes, matched_entries, unresolved_recipes,
|
(matched_recipes, matched_entries, unresolved_recipes,
|
||||||
unresolved_entries plus the legacy total/rematched/skipped/errors
|
unresolved_entries plus the legacy total/rematched/skipped/errors
|
||||||
fields) and a per-recipe ``details`` list. The legacy ``rematched``
|
fields), a per-recipe ``details`` list, and ``l4_matches`` — a
|
||||||
field is the total entry count (same as ``matched_entries``) —
|
flattened list of filename-level matches for review/undo. The
|
||||||
unlike ``rematch_all_recipes`` where it counts updated recipes.
|
legacy ``rematched`` field is the total entry count (same as
|
||||||
|
``matched_entries``) — unlike ``rematch_all_recipes`` where it
|
||||||
|
counts updated recipes.
|
||||||
"""
|
"""
|
||||||
total = len(recipe_ids)
|
total = len(recipe_ids)
|
||||||
matched_recipes = 0
|
matched_recipes = 0
|
||||||
@@ -1259,10 +1358,13 @@ class RecipeScanner:
|
|||||||
errors = 0
|
errors = 0
|
||||||
recipes: List[Dict[str, Any]] = []
|
recipes: List[Dict[str, Any]] = []
|
||||||
details_list: List[Dict[str, Any]] = []
|
details_list: List[Dict[str, Any]] = []
|
||||||
|
l4_matches: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
try:
|
try:
|
||||||
result = await self._rematch_recipe_by_id(recipe_id)
|
result = await self._rematch_recipe_by_id(
|
||||||
|
recipe_id, relaxed=relaxed
|
||||||
|
)
|
||||||
if result.get("success"):
|
if result.get("success"):
|
||||||
matched_recipes += result.get("matched_recipes", 0)
|
matched_recipes += result.get("matched_recipes", 0)
|
||||||
matched_entries += result.get("matched_entries", 0)
|
matched_entries += result.get("matched_entries", 0)
|
||||||
@@ -1275,6 +1377,9 @@ class RecipeScanner:
|
|||||||
details_list.append(
|
details_list.append(
|
||||||
{"recipe_id": recipe_id, **result["details"]}
|
{"recipe_id": recipe_id, **result["details"]}
|
||||||
)
|
)
|
||||||
|
l4_matches.extend(
|
||||||
|
self._collect_l4_matches(recipe_id, result["details"])
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
errors += result.get("errors", 0)
|
errors += result.get("errors", 0)
|
||||||
except RecipeNotFoundError:
|
except RecipeNotFoundError:
|
||||||
@@ -1309,12 +1414,22 @@ class RecipeScanner:
|
|||||||
"unresolved_entries": unresolved_entries,
|
"unresolved_entries": unresolved_entries,
|
||||||
"recipes": recipes,
|
"recipes": recipes,
|
||||||
"details": details_list,
|
"details": details_list,
|
||||||
|
"l4_matches": l4_matches,
|
||||||
}
|
}
|
||||||
|
|
||||||
def _write_rematch_lora_entry(
|
def _write_rematch_lora_entry(
|
||||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write back a matched local model to a lora recipe entry."""
|
"""Write back a matched local model to a lora recipe entry."""
|
||||||
|
# Snapshot the pre-rematch state so the association can be restored
|
||||||
|
# later (undo), mirroring the manual reconnect flow in
|
||||||
|
# ``update_lora_entry``. Never nest snapshots.
|
||||||
|
snapshot = {
|
||||||
|
key: copy.deepcopy(value)
|
||||||
|
for key, value in entry.items()
|
||||||
|
if key != "reconnectSnapshot"
|
||||||
|
}
|
||||||
|
|
||||||
entry["isDeleted"] = False
|
entry["isDeleted"] = False
|
||||||
entry["hashInvalid"] = False
|
entry["hashInvalid"] = False
|
||||||
|
|
||||||
@@ -1338,6 +1453,8 @@ class RecipeScanner:
|
|||||||
if civitai.get("name"):
|
if civitai.get("name"):
|
||||||
entry["modelVersionName"] = civitai["name"]
|
entry["modelVersionName"] = civitai["name"]
|
||||||
|
|
||||||
|
entry["reconnectSnapshot"] = snapshot
|
||||||
|
|
||||||
def _write_rematch_checkpoint_entry(
|
def _write_rematch_checkpoint_entry(
|
||||||
self, entry: Dict[str, Any], item: Dict[str, Any]
|
self, entry: Dict[str, Any], item: Dict[str, Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -1349,6 +1466,15 @@ class RecipeScanner:
|
|||||||
when they already exist on the entry (or written fresh for the
|
when they already exist on the entry (or written fresh for the
|
||||||
identifier key when neither identifier form exists).
|
identifier key when neither identifier form exists).
|
||||||
"""
|
"""
|
||||||
|
# Snapshot the pre-rematch state so the association can be restored
|
||||||
|
# later (undo), mirroring the manual reconnect flow. Never nest
|
||||||
|
# snapshots.
|
||||||
|
snapshot = {
|
||||||
|
key: copy.deepcopy(value)
|
||||||
|
for key, value in entry.items()
|
||||||
|
if key != "reconnectSnapshot"
|
||||||
|
}
|
||||||
|
|
||||||
entry["isDeleted"] = False
|
entry["isDeleted"] = False
|
||||||
entry["hashInvalid"] = False
|
entry["hashInvalid"] = False
|
||||||
|
|
||||||
@@ -1389,6 +1515,8 @@ class RecipeScanner:
|
|||||||
else:
|
else:
|
||||||
entry["modelVersionId"] = civ_id
|
entry["modelVersionId"] = civ_id
|
||||||
|
|
||||||
|
entry["reconnectSnapshot"] = snapshot
|
||||||
|
|
||||||
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
async def _save_recipe_persistently(self, recipe: Dict[str, Any]) -> bool:
|
||||||
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
"""Helper to save a recipe to both JSON and EXIF metadata."""
|
||||||
recipe_id = recipe.get("id")
|
recipe_id = recipe.get("id")
|
||||||
|
|||||||
@@ -297,23 +297,44 @@ class ServiceRegistry:
|
|||||||
async def get_embedding_scanner(cls):
|
async def get_embedding_scanner(cls):
|
||||||
"""Get or create Embedding scanner instance"""
|
"""Get or create Embedding scanner instance"""
|
||||||
service_name = "embedding_scanner"
|
service_name = "embedding_scanner"
|
||||||
|
|
||||||
if service_name in cls._services:
|
if service_name in cls._services:
|
||||||
return cls._services[service_name]
|
return cls._services[service_name]
|
||||||
|
|
||||||
async with cls._get_lock(service_name):
|
async with cls._get_lock(service_name):
|
||||||
# Double-check after acquiring lock
|
# Double-check after acquiring lock
|
||||||
if service_name in cls._services:
|
if service_name in cls._services:
|
||||||
return cls._services[service_name]
|
return cls._services[service_name]
|
||||||
|
|
||||||
# Import here to avoid circular imports
|
# Import here to avoid circular imports
|
||||||
from .embedding_scanner import EmbeddingScanner
|
from .embedding_scanner import EmbeddingScanner
|
||||||
|
|
||||||
scanner = await EmbeddingScanner.get_instance()
|
scanner = await EmbeddingScanner.get_instance()
|
||||||
cls._services[service_name] = scanner
|
cls._services[service_name] = scanner
|
||||||
logger.debug(f"Created and registered {service_name}")
|
logger.debug(f"Created and registered {service_name}")
|
||||||
return scanner
|
return scanner
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_other_scanner(cls):
|
||||||
|
"""Get or create Other-model scanner instance"""
|
||||||
|
service_name = "other_scanner"
|
||||||
|
|
||||||
|
if service_name in cls._services:
|
||||||
|
return cls._services[service_name]
|
||||||
|
|
||||||
|
async with cls._get_lock(service_name):
|
||||||
|
# Double-check after acquiring lock
|
||||||
|
if service_name in cls._services:
|
||||||
|
return cls._services[service_name]
|
||||||
|
|
||||||
|
# Import here to avoid circular imports
|
||||||
|
from .other_scanner import OtherScanner
|
||||||
|
|
||||||
|
scanner = await OtherScanner.get_instance()
|
||||||
|
cls._services[service_name] = scanner
|
||||||
|
logger.debug(f"Created and registered {service_name}")
|
||||||
|
return scanner
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def clear_services(cls):
|
def clear_services(cls):
|
||||||
"""Clear all registered services - mainly for testing"""
|
"""Clear all registered services - mainly for testing"""
|
||||||
|
|||||||
+174
-15
@@ -25,9 +25,14 @@ from typing import (
|
|||||||
from platformdirs import user_config_dir
|
from platformdirs import user_config_dir
|
||||||
|
|
||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
|
DEFAULT_DOWNLOAD_PATH_TEMPLATES,
|
||||||
|
DEFAULT_ENABLED_OTHER_SUB_TYPES,
|
||||||
DEFAULT_HASH_CHUNK_SIZE_MB,
|
DEFAULT_HASH_CHUNK_SIZE_MB,
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||||
|
OTHER_SUB_TYPE_FOLDER_KEYS,
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
|
VALID_OTHER_SUB_TYPES,
|
||||||
|
normalize_other_sub_types,
|
||||||
)
|
)
|
||||||
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
from ..utils.preview_selection import VALID_MATURE_BLUR_LEVELS
|
||||||
from ..utils.settings_paths import (
|
from ..utils.settings_paths import (
|
||||||
@@ -83,6 +88,11 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"default_checkpoint_root": "",
|
"default_checkpoint_root": "",
|
||||||
"default_unet_root": "",
|
"default_unet_root": "",
|
||||||
"default_embedding_root": "",
|
"default_embedding_root": "",
|
||||||
|
"default_other_roots": {},
|
||||||
|
# Other Models management is opt-in: nothing is scanned, shown or offered
|
||||||
|
# for download until the user turns the feature on.
|
||||||
|
"enable_other_models": False,
|
||||||
|
"enabled_other_sub_types": list(DEFAULT_ENABLED_OTHER_SUB_TYPES),
|
||||||
"recipes_path": "",
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
@@ -309,6 +319,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
default_checkpoint_root=merged.get("default_checkpoint_root"),
|
||||||
default_unet_root=merged.get("default_unet_root"),
|
default_unet_root=merged.get("default_unet_root"),
|
||||||
default_embedding_root=merged.get("default_embedding_root"),
|
default_embedding_root=merged.get("default_embedding_root"),
|
||||||
|
default_other_roots=merged.get("default_other_roots"),
|
||||||
recipes_path=merged.get("recipes_path"),
|
recipes_path=merged.get("recipes_path"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -443,6 +454,7 @@ class SettingsManager:
|
|||||||
),
|
),
|
||||||
default_unet_root=self.settings.get("default_unet_root", ""),
|
default_unet_root=self.settings.get("default_unet_root", ""),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
default_embedding_root=self.settings.get("default_embedding_root", ""),
|
||||||
|
default_other_roots=self.settings.get("default_other_roots"),
|
||||||
recipes_path=self.settings.get("recipes_path", ""),
|
recipes_path=self.settings.get("recipes_path", ""),
|
||||||
)
|
)
|
||||||
libraries = {library_name: library_payload}
|
libraries = {library_name: library_payload}
|
||||||
@@ -494,6 +506,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=data.get("default_checkpoint_root"),
|
default_checkpoint_root=data.get("default_checkpoint_root"),
|
||||||
default_unet_root=data.get("default_unet_root"),
|
default_unet_root=data.get("default_unet_root"),
|
||||||
default_embedding_root=data.get("default_embedding_root"),
|
default_embedding_root=data.get("default_embedding_root"),
|
||||||
|
default_other_roots=data.get("default_other_roots"),
|
||||||
recipes_path=data.get("recipes_path"),
|
recipes_path=data.get("recipes_path"),
|
||||||
metadata=data.get("metadata"),
|
metadata=data.get("metadata"),
|
||||||
base=data,
|
base=data,
|
||||||
@@ -541,6 +554,9 @@ class SettingsManager:
|
|||||||
self.settings["default_embedding_root"] = active_library.get(
|
self.settings["default_embedding_root"] = active_library.get(
|
||||||
"default_embedding_root", ""
|
"default_embedding_root", ""
|
||||||
)
|
)
|
||||||
|
self.settings["default_other_roots"] = self._normalize_default_other_roots(
|
||||||
|
active_library.get("default_other_roots", {})
|
||||||
|
)
|
||||||
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
self.settings["recipes_path"] = active_library.get("recipes_path", "")
|
||||||
|
|
||||||
if save:
|
if save:
|
||||||
@@ -558,6 +574,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
base: Optional[Mapping[str, Any]] = None,
|
base: Optional[Mapping[str, Any]] = None,
|
||||||
@@ -597,6 +614,15 @@ class SettingsManager:
|
|||||||
else:
|
else:
|
||||||
payload.setdefault("default_embedding_root", "")
|
payload.setdefault("default_embedding_root", "")
|
||||||
|
|
||||||
|
if default_other_roots is not None:
|
||||||
|
payload["default_other_roots"] = self._normalize_default_other_roots(
|
||||||
|
default_other_roots
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
payload["default_other_roots"] = self._normalize_default_other_roots(
|
||||||
|
payload.get("default_other_roots", {})
|
||||||
|
)
|
||||||
|
|
||||||
if recipes_path is not None:
|
if recipes_path is not None:
|
||||||
payload["recipes_path"] = recipes_path
|
payload["recipes_path"] = recipes_path
|
||||||
else:
|
else:
|
||||||
@@ -632,6 +658,71 @@ class SettingsManager:
|
|||||||
normalized[key] = cleaned
|
normalized[key] = cleaned
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
def _normalize_default_other_roots(
|
||||||
|
self, value: Any, *, strict: bool = False
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""Normalize a ``default_other_roots`` mapping ({sub_type: root path}).
|
||||||
|
|
||||||
|
Unknown sub_type keys and non-string/empty paths are dropped; with
|
||||||
|
``strict=True`` unknown sub_type keys raise instead (used by ``set()``
|
||||||
|
so typos in API payloads surface as errors).
|
||||||
|
"""
|
||||||
|
if not isinstance(value, Mapping):
|
||||||
|
if strict and value is not None:
|
||||||
|
raise ValueError("default_other_roots must be a mapping")
|
||||||
|
return {}
|
||||||
|
normalized: Dict[str, str] = {}
|
||||||
|
for sub_type, path in value.items():
|
||||||
|
if sub_type not in VALID_OTHER_SUB_TYPES:
|
||||||
|
if strict:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown other-model sub-type '{sub_type}'; "
|
||||||
|
f"expected one of {sorted(VALID_OTHER_SUB_TYPES)}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if not isinstance(path, str):
|
||||||
|
continue
|
||||||
|
stripped = path.strip()
|
||||||
|
if stripped:
|
||||||
|
normalized[sub_type] = stripped
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def is_other_models_enabled(self) -> bool:
|
||||||
|
"""Return True when the opt-in Other Models management is enabled."""
|
||||||
|
return bool(self.settings.get("enable_other_models", False))
|
||||||
|
|
||||||
|
def get_enabled_other_sub_types(self) -> List[str]:
|
||||||
|
"""Return the enabled other-model sub_types (empty when the feature is off)."""
|
||||||
|
if not self.is_other_models_enabled():
|
||||||
|
return []
|
||||||
|
return normalize_other_sub_types(self.settings.get("enabled_other_sub_types"))
|
||||||
|
|
||||||
|
def is_other_sub_type_enabled(self, sub_type: Optional[str]) -> bool:
|
||||||
|
"""Return True when ``sub_type`` is currently managed."""
|
||||||
|
if not sub_type:
|
||||||
|
return False
|
||||||
|
return sub_type in self.get_enabled_other_sub_types()
|
||||||
|
|
||||||
|
def _apply_other_model_settings_change(self) -> None:
|
||||||
|
"""Rebuild other-model roots and refresh the other scanner after a toggle."""
|
||||||
|
try:
|
||||||
|
from ..config import config # Local import to avoid circular dependency
|
||||||
|
|
||||||
|
config.refresh_other_roots()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Failed to refresh other-model roots: %s", exc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .service_registry import ServiceRegistry # pyright: ignore[reportImportCycles]
|
||||||
|
|
||||||
|
scanner = ServiceRegistry.get_service_sync("other_scanner")
|
||||||
|
if scanner is not None and hasattr(scanner, "on_library_changed"):
|
||||||
|
# reconcile=True lets the scanner pick up newly enabled roots and
|
||||||
|
# purge rows for folders that are no longer managed.
|
||||||
|
scanner.on_library_changed(reconcile=True)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
logger.debug("Failed to refresh other scanner after settings change: %s", exc)
|
||||||
|
|
||||||
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
def _has_configured_paths(self, folder_paths: Any) -> bool:
|
||||||
if not isinstance(folder_paths, Mapping):
|
if not isinstance(folder_paths, Mapping):
|
||||||
return False
|
return False
|
||||||
@@ -744,6 +835,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
libraries = self.settings.get("libraries", {})
|
libraries = self.settings.get("libraries", {})
|
||||||
@@ -794,6 +886,14 @@ class SettingsManager:
|
|||||||
library["default_embedding_root"] = default_embedding_root
|
library["default_embedding_root"] = default_embedding_root
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
if default_other_roots is not None:
|
||||||
|
normalized_other_roots = self._normalize_default_other_roots(
|
||||||
|
default_other_roots
|
||||||
|
)
|
||||||
|
if library.get("default_other_roots") != normalized_other_roots:
|
||||||
|
library["default_other_roots"] = normalized_other_roots
|
||||||
|
changed = True
|
||||||
|
|
||||||
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
if recipes_path is not None and library.get("recipes_path") != recipes_path:
|
||||||
library["recipes_path"] = recipes_path
|
library["recipes_path"] = recipes_path
|
||||||
changed = True
|
changed = True
|
||||||
@@ -894,12 +994,53 @@ class SettingsManager:
|
|||||||
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
updated = _check_and_auto_set("unet", "default_unet_root") or updated
|
||||||
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
updated = _check_and_auto_set("embeddings", "default_embedding_root") or updated
|
||||||
|
|
||||||
|
# Other-model default roots: one entry per enabled sub_type; candidates
|
||||||
|
# are the union of that sub_type's folder_paths keys (text_encoder
|
||||||
|
# merges the legacy 'clip' key with 'text_encoders'). When the opt-in
|
||||||
|
# feature is off the existing mapping is left untouched.
|
||||||
|
other_roots = self._normalize_default_other_roots(
|
||||||
|
self.settings.get("default_other_roots")
|
||||||
|
)
|
||||||
|
if self.is_other_models_enabled():
|
||||||
|
for sub_type in self.get_enabled_other_sub_types():
|
||||||
|
candidates: List[str] = []
|
||||||
|
candidate_identities: set[str] = set()
|
||||||
|
for folder_key in OTHER_SUB_TYPE_FOLDER_KEYS.get(sub_type, []):
|
||||||
|
for candidate in self._get_valid_root_candidates(folder_key):
|
||||||
|
identity = _normalize_root_identity(candidate)
|
||||||
|
if identity in candidate_identities:
|
||||||
|
continue
|
||||||
|
candidate_identities.add(identity)
|
||||||
|
candidates.append(candidate)
|
||||||
|
if not candidates:
|
||||||
|
continue
|
||||||
|
current = other_roots.get(sub_type, "")
|
||||||
|
if current and _normalize_root_identity(current) in candidate_identities:
|
||||||
|
continue
|
||||||
|
other_roots[sub_type] = candidates[0]
|
||||||
|
if current:
|
||||||
|
logger.info(
|
||||||
|
"Repaired stale default_other_roots[%s] from '%s' to '%s' because it is not present in primary or extra roots",
|
||||||
|
sub_type,
|
||||||
|
current,
|
||||||
|
candidates[0],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"Auto-set default_other_roots[%s] to '%s'",
|
||||||
|
sub_type,
|
||||||
|
candidates[0],
|
||||||
|
)
|
||||||
|
updated = True
|
||||||
|
|
||||||
if updated:
|
if updated:
|
||||||
|
self.settings["default_other_roots"] = other_roots
|
||||||
self._update_active_library_entry(
|
self._update_active_library_entry(
|
||||||
default_lora_root=self.settings.get("default_lora_root"),
|
default_lora_root=self.settings.get("default_lora_root"),
|
||||||
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
default_checkpoint_root=self.settings.get("default_checkpoint_root"),
|
||||||
default_unet_root=self.settings.get("default_unet_root"),
|
default_unet_root=self.settings.get("default_unet_root"),
|
||||||
default_embedding_root=self.settings.get("default_embedding_root"),
|
default_embedding_root=self.settings.get("default_embedding_root"),
|
||||||
|
default_other_roots=other_roots,
|
||||||
)
|
)
|
||||||
if self._bootstrap_reason == "missing":
|
if self._bootstrap_reason == "missing":
|
||||||
self._needs_initial_save = True
|
self._needs_initial_save = True
|
||||||
@@ -1599,6 +1740,12 @@ class SettingsManager:
|
|||||||
value = self.normalize_download_skip_base_models(value)
|
value = self.normalize_download_skip_base_models(value)
|
||||||
elif key == "mature_blur_level":
|
elif key == "mature_blur_level":
|
||||||
value = self.normalize_mature_blur_level(value)
|
value = self.normalize_mature_blur_level(value)
|
||||||
|
elif key == "default_other_roots":
|
||||||
|
value = self._normalize_default_other_roots(value, strict=True)
|
||||||
|
elif key == "enabled_other_sub_types":
|
||||||
|
value = normalize_other_sub_types(value)
|
||||||
|
elif key == "enable_other_models":
|
||||||
|
value = bool(value)
|
||||||
elif key == "recipes_path":
|
elif key == "recipes_path":
|
||||||
current_recipes_dir = self._get_effective_recipes_dir()
|
current_recipes_dir = self._get_effective_recipes_dir()
|
||||||
value = self._normalize_recipes_path_value(value)
|
value = self._normalize_recipes_path_value(value)
|
||||||
@@ -1626,6 +1773,8 @@ class SettingsManager:
|
|||||||
self._update_active_library_entry(default_unet_root=str(value))
|
self._update_active_library_entry(default_unet_root=str(value))
|
||||||
elif key == "default_embedding_root":
|
elif key == "default_embedding_root":
|
||||||
self._update_active_library_entry(default_embedding_root=str(value))
|
self._update_active_library_entry(default_embedding_root=str(value))
|
||||||
|
elif key == "default_other_roots":
|
||||||
|
self._update_active_library_entry(default_other_roots=value)
|
||||||
elif key == "recipes_path":
|
elif key == "recipes_path":
|
||||||
self._update_active_library_entry(recipes_path=str(value))
|
self._update_active_library_entry(recipes_path=str(value))
|
||||||
elif key == "model_name_display":
|
elif key == "model_name_display":
|
||||||
@@ -1633,6 +1782,8 @@ class SettingsManager:
|
|||||||
self._save_settings()
|
self._save_settings()
|
||||||
if key == "recipes_path":
|
if key == "recipes_path":
|
||||||
self._notify_library_change(self.get_active_library_name())
|
self._notify_library_change(self.get_active_library_name())
|
||||||
|
if key in ("enable_other_models", "enabled_other_sub_types"):
|
||||||
|
self._apply_other_model_settings_change()
|
||||||
if portable_switch_pending:
|
if portable_switch_pending:
|
||||||
self._finalize_portable_switch()
|
self._finalize_portable_switch()
|
||||||
|
|
||||||
@@ -1796,6 +1947,7 @@ class SettingsManager:
|
|||||||
"lora_scanner",
|
"lora_scanner",
|
||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
|
"other_scanner",
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
):
|
):
|
||||||
service = ServiceRegistry.get_service_sync(service_name)
|
service = ServiceRegistry.get_service_sync(service_name)
|
||||||
@@ -1960,6 +2112,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -2004,6 +2157,11 @@ class SettingsManager:
|
|||||||
if default_embedding_root is not None
|
if default_embedding_root is not None
|
||||||
else existing.get("default_embedding_root")
|
else existing.get("default_embedding_root")
|
||||||
),
|
),
|
||||||
|
default_other_roots=(
|
||||||
|
default_other_roots
|
||||||
|
if default_other_roots is not None
|
||||||
|
else existing.get("default_other_roots")
|
||||||
|
),
|
||||||
recipes_path=(
|
recipes_path=(
|
||||||
recipes_path
|
recipes_path
|
||||||
if recipes_path is not None
|
if recipes_path is not None
|
||||||
@@ -2036,6 +2194,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: str = "",
|
default_checkpoint_root: str = "",
|
||||||
default_unet_root: str = "",
|
default_unet_root: str = "",
|
||||||
default_embedding_root: str = "",
|
default_embedding_root: str = "",
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: str = "",
|
recipes_path: str = "",
|
||||||
metadata: Optional[Mapping[str, Any]] = None,
|
metadata: Optional[Mapping[str, Any]] = None,
|
||||||
activate: bool = False,
|
activate: bool = False,
|
||||||
@@ -2054,6 +2213,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
default_other_roots=default_other_roots,
|
||||||
recipes_path=recipes_path,
|
recipes_path=recipes_path,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
activate=activate,
|
activate=activate,
|
||||||
@@ -2114,6 +2274,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root: Optional[str] = None,
|
default_checkpoint_root: Optional[str] = None,
|
||||||
default_unet_root: Optional[str] = None,
|
default_unet_root: Optional[str] = None,
|
||||||
default_embedding_root: Optional[str] = None,
|
default_embedding_root: Optional[str] = None,
|
||||||
|
default_other_roots: Optional[Mapping[str, str]] = None,
|
||||||
recipes_path: Optional[str] = None,
|
recipes_path: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update folder paths for the active library."""
|
"""Update folder paths for the active library."""
|
||||||
@@ -2127,6 +2288,7 @@ class SettingsManager:
|
|||||||
default_checkpoint_root=default_checkpoint_root,
|
default_checkpoint_root=default_checkpoint_root,
|
||||||
default_unet_root=default_unet_root,
|
default_unet_root=default_unet_root,
|
||||||
default_embedding_root=default_embedding_root,
|
default_embedding_root=default_embedding_root,
|
||||||
|
default_other_roots=default_other_roots,
|
||||||
recipes_path=recipes_path,
|
recipes_path=recipes_path,
|
||||||
activate=True,
|
activate=True,
|
||||||
)
|
)
|
||||||
@@ -2151,6 +2313,7 @@ class SettingsManager:
|
|||||||
"lora_scanner",
|
"lora_scanner",
|
||||||
"checkpoint_scanner",
|
"checkpoint_scanner",
|
||||||
"embedding_scanner",
|
"embedding_scanner",
|
||||||
|
"other_scanner",
|
||||||
"recipe_scanner",
|
"recipe_scanner",
|
||||||
"model_update_service",
|
"model_update_service",
|
||||||
):
|
):
|
||||||
@@ -2173,10 +2336,14 @@ class SettingsManager:
|
|||||||
"""Get download path template for specific model type
|
"""Get download path template for specific model type
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
model_type: The type of model ('lora', 'checkpoint', 'embedding')
|
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||||
|
'other')
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Template string for the model type, defaults to '{base_model}/{first_tag}'
|
Template string for the model type. Falls back to the per-type
|
||||||
|
default in ``DEFAULT_DOWNLOAD_PATH_TEMPLATES``; unknown model types
|
||||||
|
resolve to an empty string (flat layout) rather than silently
|
||||||
|
nesting downloads under an unconfigured subfolder.
|
||||||
"""
|
"""
|
||||||
templates = self.settings.get("download_path_templates", {})
|
templates = self.settings.get("download_path_templates", {})
|
||||||
|
|
||||||
@@ -2200,27 +2367,19 @@ class SettingsManager:
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
|
f"Failed to parse download_path_templates JSON string: {e}. Setting default values."
|
||||||
)
|
)
|
||||||
default_template = "{base_model}/{first_tag}"
|
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
|
||||||
templates = {
|
|
||||||
"lora": default_template,
|
|
||||||
"checkpoint": default_template,
|
|
||||||
"embedding": default_template,
|
|
||||||
}
|
|
||||||
self.settings["download_path_templates"] = templates
|
self.settings["download_path_templates"] = templates
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
# Ensure templates is a dictionary
|
# Ensure templates is a dictionary
|
||||||
if not isinstance(templates, dict):
|
if not isinstance(templates, dict):
|
||||||
default_template = "{base_model}/{first_tag}"
|
templates = dict(DEFAULT_DOWNLOAD_PATH_TEMPLATES)
|
||||||
templates = {
|
|
||||||
"lora": default_template,
|
|
||||||
"checkpoint": default_template,
|
|
||||||
"embedding": default_template,
|
|
||||||
}
|
|
||||||
self.settings["download_path_templates"] = templates
|
self.settings["download_path_templates"] = templates
|
||||||
self._save_settings()
|
self._save_settings()
|
||||||
|
|
||||||
return templates.get(model_type, "{base_model}/{first_tag}")
|
return templates.get(
|
||||||
|
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||||
|
|||||||
+112
-1
@@ -1,4 +1,4 @@
|
|||||||
from typing import Any
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
NSFW_LEVELS = {
|
NSFW_LEVELS = {
|
||||||
"PG": 1,
|
"PG": 1,
|
||||||
@@ -83,6 +83,103 @@ VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
|||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
VALID_EMBEDDING_SUB_TYPES = ["embedding"]
|
||||||
|
|
||||||
|
# folder_paths key -> sub_type; single source of truth for extensibility.
|
||||||
|
# Adding support for a new ComfyUI folder category is a one-line change here.
|
||||||
|
OTHER_MODEL_FOLDER_SUBTYPES = {
|
||||||
|
"vae": "vae",
|
||||||
|
"upscale_models": "upscaler",
|
||||||
|
"text_encoders": "text_encoder",
|
||||||
|
"clip": "text_encoder", # legacy ComfyUI key
|
||||||
|
"clip_vision": "clip_vision",
|
||||||
|
"controlnet": "controlnet",
|
||||||
|
}
|
||||||
|
VALID_OTHER_SUB_TYPES = ["vae", "upscaler", "text_encoder", "clip_vision", "controlnet"]
|
||||||
|
# Sub-types managed when the (opt-in) Other Models feature is switched on.
|
||||||
|
# The feature itself defaults to off (``enable_other_models`` = False), so
|
||||||
|
# nothing here is scanned until the user enables it.
|
||||||
|
#
|
||||||
|
# The default set is deliberately limited to the dependency-style assets every
|
||||||
|
# pipeline needs and where "which one am I actually using" is the real problem:
|
||||||
|
# VAE, upscalers and text encoders. ``clip_vision`` and ``controlnet`` are
|
||||||
|
# workflow-driven instead (IPAdapter/SVD, per-workflow ControlNet variants) and
|
||||||
|
# ControlNet libraries routinely run to dozens of files, so both stay opt-in
|
||||||
|
# and are treated symmetrically.
|
||||||
|
DEFAULT_ENABLED_OTHER_SUB_TYPES: List[str] = [
|
||||||
|
"vae",
|
||||||
|
"upscaler",
|
||||||
|
"text_encoder",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def other_sub_type_folder_keys() -> Dict[str, List[str]]:
|
||||||
|
"""Invert OTHER_MODEL_FOLDER_SUBTYPES into sub_type -> folder_paths keys.
|
||||||
|
|
||||||
|
``text_encoder`` maps to two folder keys (``text_encoders`` and the legacy
|
||||||
|
``clip``), so every consumer that resolves a sub_type back to folders must
|
||||||
|
merge both.
|
||||||
|
"""
|
||||||
|
mapping: Dict[str, List[str]] = {}
|
||||||
|
for folder_key, sub_type in OTHER_MODEL_FOLDER_SUBTYPES.items():
|
||||||
|
mapping.setdefault(sub_type, []).append(folder_key)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
# Precomputed inverse of OTHER_MODEL_FOLDER_SUBTYPES, keeping the table order.
|
||||||
|
OTHER_SUB_TYPE_FOLDER_KEYS: Dict[str, List[str]] = other_sub_type_folder_keys()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_other_sub_types(value: Any) -> List[str]:
|
||||||
|
"""Normalize a stored/requested enabled-sub_type list.
|
||||||
|
|
||||||
|
Unknown values and duplicates are dropped; the result follows the
|
||||||
|
canonical VALID_OTHER_SUB_TYPES order so the stored setting and the UI
|
||||||
|
stay stable. Non-list input falls back to the defaults.
|
||||||
|
"""
|
||||||
|
if isinstance(value, str):
|
||||||
|
candidates: Any = [value]
|
||||||
|
elif isinstance(value, (list, tuple, set)):
|
||||||
|
candidates = value
|
||||||
|
else:
|
||||||
|
return list(DEFAULT_ENABLED_OTHER_SUB_TYPES)
|
||||||
|
|
||||||
|
allowed = {item for item in candidates if isinstance(item, str)}
|
||||||
|
return [sub_type for sub_type in VALID_OTHER_SUB_TYPES if sub_type in allowed]
|
||||||
|
# CivitAI model.type values accepted by the "other" page's fetch-metadata
|
||||||
|
# validation (lowercased). CLIP/CLIPVision are retired upstream but still
|
||||||
|
# appear on grandfathered models.
|
||||||
|
VALID_OTHER_CIVITAI_TYPES = {
|
||||||
|
"vae",
|
||||||
|
"upscaler",
|
||||||
|
"textencoder",
|
||||||
|
"clip",
|
||||||
|
"clipvision",
|
||||||
|
"controlnet",
|
||||||
|
"other",
|
||||||
|
}
|
||||||
|
# CivitAI model.type -> internal sub_type for the "other" model page.
|
||||||
|
CIVITAI_TYPE_TO_OTHER_SUB_TYPE = {
|
||||||
|
"vae": "vae",
|
||||||
|
"upscaler": "upscaler",
|
||||||
|
"textencoder": "text_encoder",
|
||||||
|
"clip": "text_encoder",
|
||||||
|
"clipvision": "clip_vision",
|
||||||
|
"controlnet": "controlnet",
|
||||||
|
}
|
||||||
|
|
||||||
|
# CivitAI ModelFile.type values -> internal sub_type for the "other" model
|
||||||
|
# page. Used for download routing only, and strictly as an explicit user file
|
||||||
|
# pick or a fallback when model.type maps to nothing — checkpoint models
|
||||||
|
# routinely bundle VAE/Text Encoder component files, so file types must never
|
||||||
|
# override a mapped model.type.
|
||||||
|
CIVITAI_FILE_TYPE_TO_OTHER_SUB_TYPE = {
|
||||||
|
"VAE": "vae",
|
||||||
|
"Upscaler": "upscaler",
|
||||||
|
"Text Encoder": "text_encoder",
|
||||||
|
"Vision Encoder": "clip_vision",
|
||||||
|
"CLIPVision": "clip_vision",
|
||||||
|
"ControlNet": "controlnet",
|
||||||
|
}
|
||||||
|
|
||||||
# Backward compatibility alias
|
# Backward compatibility alias
|
||||||
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
VALID_LORA_TYPES = VALID_LORA_SUB_TYPES
|
||||||
|
|
||||||
@@ -91,6 +188,7 @@ CIVITAI_USER_MODEL_TYPES = [
|
|||||||
*VALID_LORA_TYPES,
|
*VALID_LORA_TYPES,
|
||||||
"textualinversion",
|
"textualinversion",
|
||||||
"checkpoint",
|
"checkpoint",
|
||||||
|
*sorted(VALID_OTHER_CIVITAI_TYPES),
|
||||||
]
|
]
|
||||||
|
|
||||||
# Default chunk size in megabytes used for hashing large files.
|
# Default chunk size in megabytes used for hashing large files.
|
||||||
@@ -159,6 +257,19 @@ DEFAULT_PRIORITY_TAG_CONFIG = {
|
|||||||
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
"embedding": ", ".join(CIVITAI_MODEL_TAGS),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Default download path template for each model type. "other" defaults to a
|
||||||
|
# flat layout (empty template) on purpose: other-model downloads are already
|
||||||
|
# separated by sub_type roots (default_other_roots), and priority_tags has no
|
||||||
|
# "other" entry, so {first_tag} would resolve to an arbitrary CivitAI tag and
|
||||||
|
# scatter files into unstable folders. Users can still opt in to a template by
|
||||||
|
# writing "other" into download_path_templates in settings.json.
|
||||||
|
DEFAULT_DOWNLOAD_PATH_TEMPLATES: Dict[str, str] = {
|
||||||
|
"lora": "{base_model}/{first_tag}",
|
||||||
|
"checkpoint": "{base_model}/{first_tag}",
|
||||||
|
"embedding": "{base_model}/{first_tag}",
|
||||||
|
"other": "",
|
||||||
|
}
|
||||||
|
|
||||||
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
# baseModel values from CivitAI that should be treated as diffusion models (unet)
|
||||||
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
# These model types are incorrectly labeled as "checkpoint" by CivitAI but are actually diffusion models
|
||||||
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
DIFFUSION_MODEL_BASE_MODELS = frozenset(
|
||||||
|
|||||||
@@ -420,6 +420,10 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", embedding_scanner))
|
scanners.append(("embedding", embedding_scanner))
|
||||||
|
|
||||||
|
if "other" in model_types:
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
scanners.append(("other", other_scanner))
|
||||||
|
|
||||||
# Load progress file to check processed models (async to avoid blocking)
|
# Load progress file to check processed models (async to avoid blocking)
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
active_library = settings_manager.get_active_library_name()
|
active_library = settings_manager.get_active_library_name()
|
||||||
@@ -600,6 +604,10 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", embedding_scanner))
|
scanners.append(("embedding", embedding_scanner))
|
||||||
|
|
||||||
|
if "other" in model_types:
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
scanners.append(("other", other_scanner))
|
||||||
|
|
||||||
# Get all models
|
# Get all models
|
||||||
all_models = []
|
all_models = []
|
||||||
for scanner_type, scanner in scanners:
|
for scanner_type, scanner in scanners:
|
||||||
@@ -1098,6 +1106,10 @@ class DownloadManager:
|
|||||||
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
embedding_scanner = await ServiceRegistry.get_embedding_scanner()
|
||||||
scanners.append(("embedding", embedding_scanner))
|
scanners.append(("embedding", embedding_scanner))
|
||||||
|
|
||||||
|
if "other" in model_types:
|
||||||
|
other_scanner = await ServiceRegistry.get_other_scanner()
|
||||||
|
scanners.append(("other", other_scanner))
|
||||||
|
|
||||||
# Find the specified models
|
# Find the specified models
|
||||||
models_to_process = []
|
models_to_process = []
|
||||||
for scanner_type, scanner in scanners:
|
for scanner_type, scanner in scanners:
|
||||||
|
|||||||
+56
-7
@@ -2,7 +2,7 @@ from dataclasses import dataclass, asdict, field
|
|||||||
from typing import Callable, Dict, Optional, List, Any
|
from typing import Callable, Dict, Optional, List, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import os
|
import os
|
||||||
from .constants import INVALID_AUTOV3_EMPTY_HASH
|
from .constants import CIVITAI_TYPE_TO_OTHER_SUB_TYPE, INVALID_AUTOV3_EMPTY_HASH
|
||||||
from .model_utils import determine_base_model
|
from .model_utils import determine_base_model
|
||||||
|
|
||||||
|
|
||||||
@@ -77,9 +77,6 @@ class BaseModelMetadata:
|
|||||||
last_checked_at: float = 0 # Last checked timestamp
|
last_checked_at: float = 0 # Last checked timestamp
|
||||||
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
hash_status: str = "completed" # Hash calculation status: pending | calculating | completed | failed
|
||||||
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
|
autov3: Optional[str] = None # CivitAI AutoV3 hash (12-char lowercase hex); "" = checked but unavailable, None = not checked
|
||||||
trainedWords: List[str] = field(
|
|
||||||
default_factory=list
|
|
||||||
) # Trigger words / activation prompts (source-agnostic)
|
|
||||||
_unknown_fields: Dict[str, Any] = field(
|
_unknown_fields: Dict[str, Any] = field(
|
||||||
default_factory=dict, repr=False, compare=False
|
default_factory=dict, repr=False, compare=False
|
||||||
) # Store unknown fields
|
) # Store unknown fields
|
||||||
@@ -92,9 +89,6 @@ class BaseModelMetadata:
|
|||||||
if self.tags is None:
|
if self.tags is None:
|
||||||
self.tags = []
|
self.tags = []
|
||||||
|
|
||||||
if self.trainedWords is None:
|
|
||||||
self.trainedWords = []
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
def from_dict(cls, data: Dict[str, Any]) -> "BaseModelMetadata":
|
||||||
"""Create instance from dictionary"""
|
"""Create instance from dictionary"""
|
||||||
@@ -324,6 +318,61 @@ class CheckpointMetadata(BaseModelMetadata):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OtherModelMetadata(BaseModelMetadata):
|
||||||
|
"""Represents the metadata structure for an "other" model (VAE, upscaler,
|
||||||
|
text encoder, CLIP vision, ControlNet, ...).
|
||||||
|
|
||||||
|
The sub_type is location-derived: the OtherScanner sets it from the
|
||||||
|
folder_paths category whose root contains the file. The dataclass default
|
||||||
|
is only a placeholder.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sub_type: str = "vae" # Placeholder; overridden by the scanner hooks
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_civitai_info(
|
||||||
|
cls, version_info: Dict[str, Any], file_info: Dict[str, Any], save_path: str
|
||||||
|
) -> "OtherModelMetadata":
|
||||||
|
"""Create OtherModelMetadata instance from Civitai version info"""
|
||||||
|
file_name = file_info.get("name", "")
|
||||||
|
base_model = determine_base_model(version_info.get("baseModel", ""))
|
||||||
|
sha256_value = (file_info.get("hashes") or {}).get("SHA256", "").lower()
|
||||||
|
# Map the CivitAI model type onto our sub_types; unknown types keep the
|
||||||
|
# placeholder until the scanner re-derives sub_type from the location.
|
||||||
|
# The type lives at version["model"]["type"], not version["type"].
|
||||||
|
civitai_type = str((version_info.get("model") or {}).get("type", "") or "").lower()
|
||||||
|
sub_type = CIVITAI_TYPE_TO_OTHER_SUB_TYPE.get(civitai_type, "vae")
|
||||||
|
|
||||||
|
# Extract tags and description if available
|
||||||
|
tags = []
|
||||||
|
description = ""
|
||||||
|
model_data = version_info.get("model") or {}
|
||||||
|
if "tags" in model_data:
|
||||||
|
tags = model_data["tags"]
|
||||||
|
if "description" in model_data:
|
||||||
|
description = model_data["description"]
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
file_name=os.path.splitext(file_name)[0],
|
||||||
|
model_name=model_data.get("name", os.path.splitext(file_name)[0]),
|
||||||
|
file_path=save_path.replace(os.sep, "/"),
|
||||||
|
size=file_info.get("sizeKB", 0) * 1024,
|
||||||
|
modified=datetime.now().timestamp(),
|
||||||
|
sha256=sha256_value,
|
||||||
|
base_model=base_model,
|
||||||
|
preview_url="", # Will be updated after preview download
|
||||||
|
preview_nsfw_level=0,
|
||||||
|
from_civitai=True,
|
||||||
|
civitai=version_info,
|
||||||
|
sub_type=sub_type,
|
||||||
|
tags=tags,
|
||||||
|
modelDescription=description,
|
||||||
|
# Direct read: the downloaded file IS file_info, no SHA256 matching.
|
||||||
|
autov3=normalize_autov3((file_info.get("hashes") or {}).get("AutoV3")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EmbeddingMetadata(BaseModelMetadata):
|
class EmbeddingMetadata(BaseModelMetadata):
|
||||||
"""Represents the metadata structure for an Embedding model"""
|
"""Represents the metadata structure for an Embedding model"""
|
||||||
|
|||||||
@@ -18,6 +18,5 @@
|
|||||||
"C:/path/to/your/embeddings_folder",
|
"C:/path/to/your/embeddings_folder",
|
||||||
"C:/path/to/another/embeddings_folder"
|
"C:/path/to/another/embeddings_folder"
|
||||||
]
|
]
|
||||||
},
|
}
|
||||||
"auto_organize_exclusions": []
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.header-container {
|
.header-container {
|
||||||
max-width: 1400px;
|
max-width: none;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -38,19 +38,6 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive header container for larger screens */
|
|
||||||
@media (min-width: 2150px) {
|
|
||||||
.header-container {
|
|
||||||
max-width: 1800px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 3000px) {
|
|
||||||
.header-container {
|
|
||||||
max-width: 2400px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Logo and title styling */
|
/* Logo and title styling */
|
||||||
.header-branding {
|
.header-branding {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -96,6 +83,12 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Opt-in pages (e.g. Other Models) hide their nav entry until enabled.
|
||||||
|
A class is used instead of [hidden] because .nav-item sets display: flex. */
|
||||||
|
.nav-item--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-item:hover,
|
.nav-item:hover,
|
||||||
.nav-item:focus-visible {
|
.nav-item:focus-visible {
|
||||||
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
background-color: var(--lora-surface-hover, oklch(95% 0.02 256));
|
||||||
@@ -120,6 +113,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
/* No hard floor: the field shrinks with the available space instead of parking
|
||||||
|
at a fixed width and crowding its own placeholder (see the 1366px query). */
|
||||||
|
min-width: 0;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
transition: opacity 0.2s ease;
|
transition: opacity 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -128,6 +124,7 @@
|
|||||||
.header-search .search-container {
|
.header-search .search-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
|
min-width: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -149,7 +146,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
padding-left: 2.25rem !important;
|
padding-left: 2.25rem !important;
|
||||||
padding-right: 6.75rem !important; /* clear room for options + filter + clear/cue toggles */
|
/* Reserve exactly the inline chrome so typed text never runs under it:
|
||||||
|
cue(58) + clear(28) + toggles(28 + 28 + 4 gap) + edges(8 + 8) = 126px.
|
||||||
|
Below 1366px the cue is hidden and the reservation drops to 68px.
|
||||||
|
!important is required: search-filter.css loads later and sets its own
|
||||||
|
right padding at equal specificity (.search-container input). */
|
||||||
|
padding-right: 7.875rem !important;
|
||||||
border: none;
|
border: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
@@ -697,6 +699,20 @@
|
|||||||
margin: 0.25rem 0;
|
margin: 0.25rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Responsive: the Ctrl+F cue is pure decoration and, above 950px, the widest
|
||||||
|
thing inside the field. Below 1366px the header (branding + full nav) leaves
|
||||||
|
too little room for it, so it steps aside and the field reclaims its 58px.
|
||||||
|
The shortcut itself keeps working - only the visual hint is dropped. */
|
||||||
|
@media (max-width: 1366px) {
|
||||||
|
.header-search .search-shortcut-cue {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search input {
|
||||||
|
padding-right: 4.25rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
/* Responsive: Early optimization at 1200px - reduce gaps and padding */
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.header-container {
|
.header-container {
|
||||||
@@ -716,11 +732,6 @@
|
|||||||
.header-controls {
|
.header-controls {
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-controls > div {
|
|
||||||
width: 30px;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Responsive: Hide nav icons at 1100px to save space */
|
/* Responsive: Hide nav icons at 1100px to save space */
|
||||||
@@ -797,13 +808,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* For very small screens - switch nav to icons only */
|
/* For narrower screens - switch nav to icons only.
|
||||||
@media (max-width: 600px) {
|
A labelled nav needs ~383px and a readable search field needs ~300px, so the
|
||||||
.header-container {
|
two cannot coexist below ~700px: at 601-700px the search input was previously
|
||||||
padding: 0 8px;
|
squeezed to 200px, leaving only ~96px of text room and overlapping the
|
||||||
gap: 0.4rem;
|
placeholder with the inline toggles. Labels therefore collapse here. */
|
||||||
}
|
@media (max-width: 700px) {
|
||||||
|
|
||||||
.main-nav {
|
.main-nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.15rem;
|
gap: 0.15rem;
|
||||||
@@ -811,8 +821,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
padding: 0.25rem;
|
padding: 0.25rem 0.4rem;
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item span {
|
.nav-item span {
|
||||||
@@ -821,6 +830,22 @@
|
|||||||
|
|
||||||
.nav-item i {
|
.nav-item i {
|
||||||
display: block;
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For very small screens - tighten container spacing */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.header-container {
|
||||||
|
padding: 0 8px;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
padding: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item i {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -592,3 +592,145 @@ button:disabled,
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recipe Rematch Options Modal */
|
||||||
|
#rematchOptionsModal .modal-body {
|
||||||
|
padding: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .confirmation-message {
|
||||||
|
color: var(--text-color);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selectable option card — click anywhere toggles the checkbox (label wrap).
|
||||||
|
Checkmark follows the batch-import modal's custom checkbox pattern. */
|
||||||
|
#rematchOptionsModal .rematch-option-card {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--surface-subtle);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-card:hover {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:checked) {
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
background: oklch(from var(--lora-accent) l c h / 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hidden but keyboard-focusable (focus ring lands on the card). */
|
||||||
|
#rematchOptionsModal .rematch-option-card input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-card:has(input[type="checkbox"]:focus-visible) {
|
||||||
|
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-checkmark {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
margin-top: 1px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition-base);
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
border-color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-card input[type="checkbox"]:checked + .rematch-option-checkmark::after {
|
||||||
|
content: '\f00c';
|
||||||
|
font-family: 'Font Awesome 6 Free', sans-serif;
|
||||||
|
font-weight: 900;
|
||||||
|
color: var(--lora-text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
color: var(--text-color);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.95em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-caveat {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchOptionsModal .rematch-option-caveat i {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recipe Rematch Summary Modal (dynamically built by RematchSummaryModal.js;
|
||||||
|
stat cards / failure table / summary header come from
|
||||||
|
metadata-refresh-result.css and download-batch-summary.css). */
|
||||||
|
.rematch-summary-modal {
|
||||||
|
max-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rematch-cancelled-note {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin: 0 0 var(--space-2) 0;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rematch-cancelled-note i {
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Review section heading uses the accent (review, not failure) instead of
|
||||||
|
the failure-section error color. */
|
||||||
|
.rematch-review-section h4 {
|
||||||
|
color: var(--lora-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchSummaryModal .rematch-undo-btn {
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rematchSummaryModal tr.undone td:not(.rematch-undo-cell) {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1744,3 +1744,39 @@ input:checked + .toggle-slider:before {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Other Models opt-in: sub_type checkbox row */
|
||||||
|
.other-subtype-checkboxes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-subtype-checkbox {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: var(--text-color);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-subtype-checkbox input[type="checkbox"] {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-subtype-toggles.is-disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-subtype-toggles.is-disabled .other-subtype-checkbox {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled default-root selects for switched-off sub_types / feature */
|
||||||
|
.select-control select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|||||||
+35
-29
@@ -59,7 +59,10 @@ body.sticky-controls .sticky-topbar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-left: auto; /* Push to the right */
|
/* Push to the right of the row. Because it is also the flex item that is
|
||||||
|
allowed to drop to a second row, an auto margin keeps it right-aligned on
|
||||||
|
either row — no width: 100% / viewport breakpoint needed. */
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
@@ -67,7 +70,11 @@ body.sticky-controls .sticky-topbar {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-wrap: nowrap;
|
/* Wrap only when the controls genuinely cannot fit, instead of at a fixed
|
||||||
|
viewport width. Viewport-based wrapping wasted space on high-DPI displays
|
||||||
|
(e.g. a 2560px monitor at 200% scaling reports a ~1280px CSS viewport even
|
||||||
|
when the window is maximized). */
|
||||||
|
flex-wrap: wrap;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +82,11 @@ body.sticky-controls .sticky-topbar {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-wrap: nowrap;
|
/* Let the group shrink rather than overflow so .controls-right only wraps
|
||||||
|
when it really has to. */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Action button styling */
|
/* Action button styling */
|
||||||
@@ -84,7 +95,9 @@ body.sticky-controls .sticky-topbar {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.control-group button {
|
.control-group button {
|
||||||
min-width: 100px;
|
/* Keeps the toolbar visually even without forcing the row to overflow (the
|
||||||
|
old 100px floor pushed the total past the container on wide screens). */
|
||||||
|
min-width: 90px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -627,49 +640,42 @@ body.sticky-controls .sticky-topbar {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Intermediate breakpoint: wrap controls-right to prevent overflow */
|
/* Intermediate breakpoint: tighten the controls so the whole bar still fits on
|
||||||
|
one row at common laptop/high-DPI widths. The buttons are allowed to shrink to
|
||||||
|
their content (min-width: 0) here, which is what reclaims the space the old
|
||||||
|
100px floor plus a forced wrap used to waste. .controls-right is deliberately
|
||||||
|
NOT forced onto its own row: it stays inline while it fits and only drops to a
|
||||||
|
second row (staying right-aligned through its auto margin) when it does not. */
|
||||||
@media (max-width: 1500px) {
|
@media (max-width: 1500px) {
|
||||||
.actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-1);
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-right {
|
.control-group button {
|
||||||
width: 100%;
|
min-width: 0;
|
||||||
justify-content: flex-end;
|
padding: 4px 8px;
|
||||||
margin-top: 8px;
|
|
||||||
padding-left: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce button sizes to fit better */
|
.control-group select {
|
||||||
.control-group button {
|
min-width: 0;
|
||||||
min-width: 80px;
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 0.8em;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.actions {
|
.actions {
|
||||||
flex-wrap: wrap;
|
gap: var(--space-2);
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-buttons {
|
.action-buttons {
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: var(--space-1);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Narrow screens: let the right-hand group wrap below the buttons, still
|
||||||
|
right-aligned. */
|
||||||
.controls-right {
|
.controls-right {
|
||||||
width: 100%;
|
flex-wrap: wrap;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 8px;
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-group button:hover {
|
.control-group button:hover {
|
||||||
|
|||||||
@@ -44,6 +44,13 @@
|
|||||||
pointer-events: auto !important;
|
pointer-events: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keep the fixed-position sidebar anchored when highlighted, otherwise
|
||||||
|
.onboarding-target-highlight's position: relative would pull it into
|
||||||
|
normal flow and it would move away from the spotlight cutout */
|
||||||
|
.folder-sidebar.onboarding-target-highlight {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-popup {
|
.onboarding-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--lora-surface);
|
background: var(--lora-surface);
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { state } from '../state/index.js';
|
|||||||
export const MODEL_TYPES = {
|
export const MODEL_TYPES = {
|
||||||
LORA: 'loras',
|
LORA: 'loras',
|
||||||
CHECKPOINT: 'checkpoints',
|
CHECKPOINT: 'checkpoints',
|
||||||
EMBEDDING: 'embeddings' // Future model type
|
EMBEDDING: 'embeddings',
|
||||||
|
OTHER: 'other'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Base API configuration for each model type
|
// Base API configuration for each model type
|
||||||
@@ -40,6 +41,15 @@ export const MODEL_CONFIG = {
|
|||||||
supportsBulkOperations: true,
|
supportsBulkOperations: true,
|
||||||
supportsMove: true,
|
supportsMove: true,
|
||||||
templateName: 'embeddings.html'
|
templateName: 'embeddings.html'
|
||||||
|
},
|
||||||
|
[MODEL_TYPES.OTHER]: {
|
||||||
|
displayName: 'Other Model',
|
||||||
|
singularName: 'other',
|
||||||
|
defaultPageSize: 100,
|
||||||
|
supportsLetterFilter: false,
|
||||||
|
supportsBulkOperations: true,
|
||||||
|
supportsMove: true,
|
||||||
|
templateName: 'other.html'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,6 +143,10 @@ export const MODEL_SPECIFIC_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
[MODEL_TYPES.EMBEDDING]: {
|
[MODEL_TYPES.EMBEDDING]: {
|
||||||
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
|
metadata: `/api/lm/${MODEL_TYPES.EMBEDDING}/metadata`,
|
||||||
|
},
|
||||||
|
[MODEL_TYPES.OTHER]: {
|
||||||
|
metadata: `/api/lm/${MODEL_TYPES.OTHER}/metadata`,
|
||||||
|
roots_by_subtype: `/api/lm/${MODEL_TYPES.OTHER}/roots_by_subtype`,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,6 +198,7 @@ export const DOWNLOAD_ENDPOINTS = {
|
|||||||
downloadGet: '/api/lm/download-model-get',
|
downloadGet: '/api/lm/download-model-get',
|
||||||
cancelGet: '/api/lm/cancel-download-get',
|
cancelGet: '/api/lm/cancel-download-get',
|
||||||
progress: '/api/lm/download-progress',
|
progress: '/api/lm/download-progress',
|
||||||
|
routing: '/api/lm/download/routing',
|
||||||
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
|
exampleImages: '/api/lm/force-download-example-images', // Re-process example images ignoring previous status
|
||||||
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
|
exampleImagesMissing: '/api/lm/download-example-images' // Download only missing example images
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { LoraApiClient } from './loraApi.js';
|
import { LoraApiClient } from './loraApi.js';
|
||||||
import { CheckpointApiClient } from './checkpointApi.js';
|
import { CheckpointApiClient } from './checkpointApi.js';
|
||||||
import { EmbeddingApiClient } from './embeddingApi.js';
|
import { EmbeddingApiClient } from './embeddingApi.js';
|
||||||
|
import { OtherApiClient } from './otherApi.js';
|
||||||
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
import { MODEL_TYPES, isValidModelType } from './apiConfig.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ export function createModelApiClient(modelType) {
|
|||||||
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
return new CheckpointApiClient(MODEL_TYPES.CHECKPOINT);
|
||||||
case MODEL_TYPES.EMBEDDING:
|
case MODEL_TYPES.EMBEDDING:
|
||||||
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
return new EmbeddingApiClient(MODEL_TYPES.EMBEDDING);
|
||||||
|
case MODEL_TYPES.OTHER:
|
||||||
|
return new OtherApiClient(MODEL_TYPES.OTHER);
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unsupported model type: ${modelType}`);
|
throw new Error(`Unsupported model type: ${modelType}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BaseModelApiClient } from './baseModelApi.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other-models-specific API client (VAE, upscalers, text encoders, etc.)
|
||||||
|
*/
|
||||||
|
export class OtherApiClient extends BaseModelApiClient {
|
||||||
|
/**
|
||||||
|
* Get other-model roots, optionally narrowed to one sub_type
|
||||||
|
* (vae/upscaler/text_encoder/clip_vision/controlnet).
|
||||||
|
*
|
||||||
|
* Without a sub_type this falls back to the merged roots list
|
||||||
|
* (GET /api/lm/other/roots); with one it reads the grouped
|
||||||
|
* roots_by_subtype map and extracts the matching list.
|
||||||
|
*/
|
||||||
|
async fetchModelRoots(subType = null) {
|
||||||
|
if (!subType) {
|
||||||
|
return super.fetchModelRoots();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await this.fetchRootsBySubType();
|
||||||
|
const groupedRoots = data.roots_by_subtype || {};
|
||||||
|
return {
|
||||||
|
success: data.success !== false,
|
||||||
|
roots: groupedRoots[subType] || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get other-model roots grouped by sub_type.
|
||||||
|
* GET /api/lm/other/roots_by_subtype
|
||||||
|
* -> { success, roots_by_subtype: {sub_type: [...]} }
|
||||||
|
*/
|
||||||
|
async fetchRootsBySubType() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.specific.roots_by_subtype);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch other-model roots by sub_type');
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching other-model roots by sub_type:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -677,7 +677,7 @@ export class RecipeSidebarApiClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async rematchBulkModels(filePaths) {
|
async rematchBulkModels(filePaths, options = {}) {
|
||||||
if (!filePaths || filePaths.length === 0) {
|
if (!filePaths || filePaths.length === 0) {
|
||||||
throw new Error('No file paths provided');
|
throw new Error('No file paths provided');
|
||||||
}
|
}
|
||||||
@@ -690,14 +690,19 @@ export class RecipeSidebarApiClient {
|
|||||||
throw new Error('No recipe IDs could be derived from file paths');
|
throw new Error('No recipe IDs could be derived from file paths');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const body = { recipe_ids: recipeIds };
|
||||||
|
// Only sent when opted in — the strict body stays exactly
|
||||||
|
// {recipe_ids} for backward compatibility.
|
||||||
|
if (options.relaxed === true) {
|
||||||
|
body.relaxed = true;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
|
const response = await fetch(this.apiConfig.endpoints.rematchBulk, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
recipe_ids: recipeIds,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ export class BulkContextMenu extends BaseContextMenu {
|
|||||||
|
|
||||||
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
|
const downloadExampleImagesSubmenu = this.menu.querySelector('[data-has-submenu="download-example-images"]');
|
||||||
if (downloadExampleImagesSubmenu) {
|
if (downloadExampleImagesSubmenu) {
|
||||||
// Show on model pages (loras, checkpoints, embeddings), hide on recipes
|
// Show on model pages (loras, checkpoints, embeddings, other), hide on recipes
|
||||||
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings'].includes(currentModelType) ? 'flex' : 'none';
|
downloadExampleImagesSubmenu.style.display = ['loras', 'checkpoints', 'embeddings', 'other'].includes(currentModelType) ? 'flex' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
const skipMetadataRefreshItem = this.menu.querySelector('[data-action="skip-metadata-refresh"]');
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export class CheckpointContextMenu extends BaseContextMenu {
|
|||||||
showMenu(x, y, card) {
|
showMenu(x, y, card) {
|
||||||
super.showMenu(x, y, card);
|
super.showMenu(x, y, card);
|
||||||
this.updateExcludeMenuItem();
|
this.updateExcludeMenuItem();
|
||||||
|
this.updateEnrichMenuItem(card);
|
||||||
|
|
||||||
// Update the "Move to other root" label based on current model type
|
// Update the "Move to other root" label based on current model type
|
||||||
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
const moveOtherItem = this.menu.querySelector('[data-action="move-other"]');
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { translate } from '../../utils/i18nHelpers.js';
|
|||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
import { getCompleteApiConfig, getCurrentModelType } from '../../api/apiConfig.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
|
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||||
|
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||||
|
|
||||||
export class GlobalContextMenu extends BaseContextMenu {
|
export class GlobalContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -368,6 +370,18 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect options (relaxed matching) before starting anything; the
|
||||||
|
// run only begins when the user confirms the dialog.
|
||||||
|
rematchModalManager.showOptionsModal({
|
||||||
|
onConfirm: ({ relaxed }) => this._startRematch(menuItem, relaxed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startRematch(menuItem, relaxed = false) {
|
||||||
|
if (this._rematchInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this._rematchInProgress = true;
|
this._rematchInProgress = true;
|
||||||
menuItem?.classList.add('disabled');
|
menuItem?.classList.add('disabled');
|
||||||
|
|
||||||
@@ -384,6 +398,7 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const response = await fetch('/api/lm/recipes/rematch', {
|
const response = await fetch('/api/lm/recipes/rematch', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ relaxed: !!relaxed }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -411,48 +426,32 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
const recipes = p.matched_recipes ?? p.rematched ?? 0;
|
||||||
const failures = p.errors || 0;
|
const failures = p.errors || 0;
|
||||||
const unresolved = p.unresolved_entries ?? 0;
|
const unresolved = p.unresolved_entries ?? 0;
|
||||||
if (entries > 0) {
|
const l4Matches = Array.isArray(p.l4_matches) ? p.l4_matches : [];
|
||||||
const successKey = failures > 0
|
// Complete no-op (nothing matched, nothing
|
||||||
? 'globalContextMenu.rematchRecipes.successErrors'
|
// unresolved, no errors) keeps the lightweight
|
||||||
: 'globalContextMenu.rematchRecipes.success';
|
// toast; anything else opens the post-run summary
|
||||||
const successText = failures > 0
|
// modal.
|
||||||
? `Matched ${entries} entries across ${recipes} recipes, ${failures} failed.`
|
const isNoop = entries === 0 && unresolved === 0 && failures === 0;
|
||||||
: `Matched ${entries} entries across ${recipes} recipes.`;
|
if (isNoop) {
|
||||||
progressUI?.complete(translate(
|
|
||||||
successKey,
|
|
||||||
{ count: recipes, recipes, entries, failures },
|
|
||||||
successText
|
|
||||||
));
|
|
||||||
showToast(successKey, { count: recipes, recipes, entries, failures }, failures > 0 ? 'warning' : 'success');
|
|
||||||
} else if (failures > 0) {
|
|
||||||
// Nothing matched and at least one recipe
|
|
||||||
// errored — "no rematch needed" would be
|
|
||||||
// actively misleading here.
|
|
||||||
progressUI?.complete(translate(
|
|
||||||
'globalContextMenu.rematchRecipes.allFailed',
|
|
||||||
{ total: p.total, recipes, entries, failures },
|
|
||||||
`Rematch failed for ${failures} of ${p.total} recipes.`
|
|
||||||
));
|
|
||||||
showToast('globalContextMenu.rematchRecipes.allFailed', { total: p.total, recipes, entries, failures }, 'error');
|
|
||||||
} else if (unresolved > 0) {
|
|
||||||
// Entries existed but have no local model —
|
|
||||||
// expected for models deleted from Civitai;
|
|
||||||
// informational, not an error.
|
|
||||||
const unresolvedRecipes = p.unresolved_recipes ?? 0;
|
|
||||||
progressUI?.complete(translate(
|
|
||||||
'globalContextMenu.rematchRecipes.noMatch',
|
|
||||||
{ entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures },
|
|
||||||
`No local match found for ${unresolved} entries in ${unresolvedRecipes} recipes.`
|
|
||||||
));
|
|
||||||
showToast('globalContextMenu.rematchRecipes.noMatch', { entries: unresolved, recipes: unresolvedRecipes, total: p.total, failures }, 'info');
|
|
||||||
} else {
|
|
||||||
// Everything was skipped (nothing to do).
|
|
||||||
progressUI?.complete(translate(
|
progressUI?.complete(translate(
|
||||||
'globalContextMenu.rematchRecipes.success',
|
'globalContextMenu.rematchRecipes.success',
|
||||||
{ count: recipes, recipes, entries, failures },
|
{ count: recipes, recipes, entries, failures },
|
||||||
`Matched ${entries} entries across ${recipes} recipes.`
|
`Matched ${entries} entries across ${recipes} recipes.`
|
||||||
));
|
));
|
||||||
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
showToast('globalContextMenu.rematchRecipes.success', { count: recipes, recipes, entries, failures }, 'success');
|
||||||
|
} else {
|
||||||
|
progressUI?.complete();
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'global',
|
||||||
|
total: p.total || 0,
|
||||||
|
matchedRecipes: recipes,
|
||||||
|
matchedEntries: entries,
|
||||||
|
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||||
|
unresolvedEntries: unresolved,
|
||||||
|
skipped: p.skipped || 0,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// Refresh recipes page if active
|
// Refresh recipes page if active
|
||||||
if (window.recipesPage) {
|
if (window.recipesPage) {
|
||||||
@@ -469,7 +468,23 @@ export class GlobalContextMenu extends BaseContextMenu {
|
|||||||
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
{ count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries },
|
||||||
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
`Rematch cancelled. ${cancelledRecipes} recipes updated (${cancelledEntries} entries).`
|
||||||
));
|
));
|
||||||
showToast('globalContextMenu.rematchRecipes.cancelled', { count: cancelledRecipes, recipes: cancelledRecipes, entries: cancelledEntries }, 'info');
|
// A cancelled run still reports partial results
|
||||||
|
// via the summary modal (marked as cancelled).
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'global',
|
||||||
|
cancelled: true,
|
||||||
|
total: p.total || 0,
|
||||||
|
matchedRecipes: cancelledRecipes,
|
||||||
|
matchedEntries: cancelledEntries,
|
||||||
|
unresolvedRecipes: p.unresolved_recipes ?? 0,
|
||||||
|
unresolvedEntries: p.unresolved_entries ?? 0,
|
||||||
|
skipped: p.skipped || 0,
|
||||||
|
errors: p.errors || 0,
|
||||||
|
l4Matches: Array.isArray(p.l4_matches) ? p.l4_matches : [],
|
||||||
|
});
|
||||||
|
if (window.recipesPage) {
|
||||||
|
window.recipesPage.refresh();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (progressResponse.status === 404) {
|
} else if (progressResponse.status === 404) {
|
||||||
// Progress might have finished quickly and been cleaned up
|
// Progress might have finished quickly and been cleaned up
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { state } from '../../state/index.js';
|
|
||||||
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax, showToast } from '../../utils/uiHelpers.js';
|
import { copyLoraSyntax, sendLoraToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js';
|
||||||
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
import { showExcludeModal, showDeleteModal } from '../../utils/modalUtils.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
@@ -27,16 +26,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
this.updateEnrichMenuItem(card);
|
this.updateEnrichMenuItem(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateEnrichMenuItem(card) {
|
|
||||||
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
|
||||||
if (!enrichItem) return;
|
|
||||||
const hasHfUrl = !!card.dataset.hf_url;
|
|
||||||
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
|
||||||
enrichItem.title = hasHfUrl
|
|
||||||
? ''
|
|
||||||
: 'Link this model to a HuggingFace repo first (Link Model \u2192 Link to HuggingFace)';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleMenuAction(action, menuItem) {
|
handleMenuAction(action, menuItem) {
|
||||||
// First try to handle with common actions
|
// First try to handle with common actions
|
||||||
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
@@ -75,9 +64,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
case 'refresh-metadata':
|
case 'refresh-metadata':
|
||||||
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
getModelApiClient().refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
case 'enrich-hf-llm':
|
|
||||||
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
|
||||||
break;
|
|
||||||
case 'exclude':
|
case 'exclude':
|
||||||
showExcludeModal(this.currentCard.dataset.filepath);
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
break;
|
break;
|
||||||
@@ -87,68 +73,6 @@ export class LoraContextMenu extends BaseContextMenu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async enrichWithAgent(filePath) {
|
|
||||||
const { agentManager } = await import('../../managers/AgentManager.js');
|
|
||||||
|
|
||||||
const configured = await agentManager.isLlmConfigured();
|
|
||||||
if (!configured) {
|
|
||||||
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
agentManager.connect();
|
|
||||||
|
|
||||||
const progressUI = state.loadingManager.showEnhancedProgress(
|
|
||||||
'Enriching metadata with AI...'
|
|
||||||
);
|
|
||||||
|
|
||||||
function cleanupCallbacks() {
|
|
||||||
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
|
||||||
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
|
||||||
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
|
||||||
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
|
||||||
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
|
||||||
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onProgress = (data) => {
|
|
||||||
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
|
||||||
if (state.virtualScroller?.updateSingleItem) {
|
|
||||||
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
|
||||||
}
|
|
||||||
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
|
||||||
const name = data.current_path.split('/').pop();
|
|
||||||
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
agentManager.onProgress(onProgress);
|
|
||||||
|
|
||||||
const onComplete = (data) => {
|
|
||||||
cleanupCallbacks();
|
|
||||||
|
|
||||||
if (data.status === 'completed') {
|
|
||||||
progressUI.complete(data.summary || 'Enrich complete');
|
|
||||||
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
agentManager.onComplete(onComplete);
|
|
||||||
|
|
||||||
const onError = (data) => {
|
|
||||||
cleanupCallbacks();
|
|
||||||
state.loadingManager.hide();
|
|
||||||
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
|
||||||
};
|
|
||||||
agentManager.onError(onError);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
|
||||||
} catch (error) {
|
|
||||||
cleanupCallbacks();
|
|
||||||
state.loadingManager.hide();
|
|
||||||
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sendLoraToWorkflow(replaceMode) {
|
sendLoraToWorkflow(replaceMode) {
|
||||||
const card = this.currentCard;
|
const card = this.currentCard;
|
||||||
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
const usageTips = JSON.parse(card.dataset.usage_tips || '{}');
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export const ModelContextMenuMixin = {
|
|||||||
const prefixMap = {
|
const prefixMap = {
|
||||||
lora: 'loras',
|
lora: 'loras',
|
||||||
checkpoint: 'checkpoints',
|
checkpoint: 'checkpoints',
|
||||||
embedding: 'embeddings'
|
embedding: 'embeddings',
|
||||||
|
other: 'other'
|
||||||
};
|
};
|
||||||
return prefixMap[this.modelType] || 'loras';
|
return prefixMap[this.modelType] || 'loras';
|
||||||
},
|
},
|
||||||
@@ -278,6 +279,79 @@ export const ModelContextMenuMixin = {
|
|||||||
setTimeout(() => urlInput.focus(), 50);
|
setTimeout(() => urlInput.focus(), 50);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// HF metadata enrichment (AI agent) methods
|
||||||
|
updateEnrichMenuItem(card) {
|
||||||
|
const enrichItem = this.menu?.querySelector('[data-action="enrich-hf-llm"]');
|
||||||
|
if (!enrichItem) return;
|
||||||
|
const hasHfUrl = !!card.dataset.hf_url;
|
||||||
|
enrichItem.classList.toggle('disabled', !hasHfUrl);
|
||||||
|
enrichItem.title = hasHfUrl
|
||||||
|
? ''
|
||||||
|
: 'Link this model to a HuggingFace repo first (Link Model → Link to HuggingFace)';
|
||||||
|
},
|
||||||
|
|
||||||
|
async enrichWithAgent(filePath) {
|
||||||
|
const { agentManager } = await import('../../managers/AgentManager.js');
|
||||||
|
|
||||||
|
const configured = await agentManager.isLlmConfigured();
|
||||||
|
if (!configured) {
|
||||||
|
showToast('toast.agent.llmNotConfigured', {}, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
agentManager.connect();
|
||||||
|
|
||||||
|
const progressUI = state.loadingManager.showEnhancedProgress(
|
||||||
|
'Enriching metadata with AI...'
|
||||||
|
);
|
||||||
|
|
||||||
|
function cleanupCallbacks() {
|
||||||
|
const pIdx = agentManager.progressCallbacks.indexOf(onProgress);
|
||||||
|
if (pIdx >= 0) agentManager.progressCallbacks.splice(pIdx, 1);
|
||||||
|
const cIdx = agentManager.completeCallbacks.indexOf(onComplete);
|
||||||
|
if (cIdx >= 0) agentManager.completeCallbacks.splice(cIdx, 1);
|
||||||
|
const eIdx = agentManager.errorCallbacks.indexOf(onError);
|
||||||
|
if (eIdx >= 0) agentManager.errorCallbacks.splice(eIdx, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProgress = (data) => {
|
||||||
|
if (data.status === 'processing' && data.current_path && data.updated_data && Object.keys(data.updated_data).length > 0) {
|
||||||
|
if (state.virtualScroller?.updateSingleItem) {
|
||||||
|
state.virtualScroller.updateSingleItem(data.current_path, data.updated_data);
|
||||||
|
}
|
||||||
|
const pct = data.total > 0 ? Math.floor((data.processed / data.total) * 100) : 0;
|
||||||
|
const name = data.current_path.split('/').pop();
|
||||||
|
progressUI.updateProgress(pct, name, `Processing ${name}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agentManager.onProgress(onProgress);
|
||||||
|
|
||||||
|
const onComplete = (data) => {
|
||||||
|
cleanupCallbacks();
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
progressUI.complete(data.summary || 'Enrich complete');
|
||||||
|
showToast('toast.agent.enrichComplete', { summary: data.summary || 'Done' }, 'success');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
agentManager.onComplete(onComplete);
|
||||||
|
|
||||||
|
const onError = (data) => {
|
||||||
|
cleanupCallbacks();
|
||||||
|
state.loadingManager.hide();
|
||||||
|
showToast('toast.agent.enrichFailed', { error: data.error || 'Unknown error' }, 'error');
|
||||||
|
};
|
||||||
|
agentManager.onError(onError);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agentManager.executeSkill('enrich_hf_metadata', [filePath]);
|
||||||
|
} catch (error) {
|
||||||
|
cleanupCallbacks();
|
||||||
|
state.loadingManager.hide();
|
||||||
|
showToast('toast.agent.enrichFailed', { error: error.message }, 'error');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
parseModelId(value) {
|
parseModelId(value) {
|
||||||
if (value === undefined || value === null || value === '') {
|
if (value === undefined || value === null || value === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -372,7 +446,10 @@ export const ModelContextMenuMixin = {
|
|||||||
this.downloadExampleImages(true);
|
this.downloadExampleImages(true);
|
||||||
return true;
|
return true;
|
||||||
case 'civitai':
|
case 'civitai':
|
||||||
if (this.currentCard.dataset.from_civitai === 'true') {
|
// Gate on actual CivitAI data (not the `from_civitai` flag) so
|
||||||
|
// that linking HuggingFace does not make the model look like it
|
||||||
|
// has no CivitAI info (#1094).
|
||||||
|
if (this.currentCard.dataset.has_civitai === 'true') {
|
||||||
if (this.currentCard.querySelector('.fa-globe')) {
|
if (this.currentCard.querySelector('.fa-globe')) {
|
||||||
this.currentCard.querySelector('.fa-globe').click();
|
this.currentCard.querySelector('.fa-globe').click();
|
||||||
} else {
|
} else {
|
||||||
@@ -388,6 +465,9 @@ export const ModelContextMenuMixin = {
|
|||||||
case 'link-hf':
|
case 'link-hf':
|
||||||
this.showLinkHfModal();
|
this.showLinkHfModal();
|
||||||
return true;
|
return true;
|
||||||
|
case 'enrich-hf-llm':
|
||||||
|
this.enrichWithAgent(this.currentCard.dataset.filepath);
|
||||||
|
return true;
|
||||||
case 'set-nsfw':
|
case 'set-nsfw':
|
||||||
this.showNSFWLevelSelector(null, null, this.currentCard);
|
this.showNSFWLevelSelector(null, null, this.currentCard);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
import { showDeleteModal, showExcludeModal } from '../../utils/modalUtils.js';
|
||||||
|
|
||||||
|
export class OtherContextMenu extends BaseContextMenu {
|
||||||
|
constructor() {
|
||||||
|
super('otherContextMenu', '.model-card');
|
||||||
|
this.nsfwSelector = document.getElementById('nsfwLevelSelector');
|
||||||
|
this.modelType = 'other';
|
||||||
|
this.resetAndReload = resetAndReload;
|
||||||
|
|
||||||
|
this.initNSFWSelector();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implementation needed by the mixin
|
||||||
|
async saveModelMetadata(filePath, data) {
|
||||||
|
return getModelApiClient().saveModelMetadata(filePath, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
showMenu(x, y, card) {
|
||||||
|
super.showMenu(x, y, card);
|
||||||
|
this.updateExcludeMenuItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMenuAction(action) {
|
||||||
|
// First try to handle with common actions
|
||||||
|
if (ModelContextMenuMixin.handleCommonMenuActions.call(this, action)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClient = getModelApiClient();
|
||||||
|
|
||||||
|
// Otherwise handle other-models-specific actions
|
||||||
|
switch(action) {
|
||||||
|
case 'details':
|
||||||
|
// Show model details
|
||||||
|
this.currentCard.click();
|
||||||
|
break;
|
||||||
|
case 'replace-preview':
|
||||||
|
// Add new action for replacing preview images
|
||||||
|
apiClient.replaceModelPreview(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'delete':
|
||||||
|
showDeleteModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'copyname':
|
||||||
|
// Copy model name
|
||||||
|
if (this.currentCard.querySelector('.fa-copy')) {
|
||||||
|
this.currentCard.querySelector('.fa-copy').click();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'refresh-metadata':
|
||||||
|
// Refresh metadata from CivitAI
|
||||||
|
apiClient.refreshSingleModelMetadata(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'move':
|
||||||
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'exclude':
|
||||||
|
showExcludeModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
case 'restore':
|
||||||
|
this.restoreExcludedModel(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mix in shared methods
|
||||||
|
Object.assign(OtherContextMenu.prototype, ModelContextMenuMixin);
|
||||||
@@ -6,6 +6,8 @@ import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js
|
|||||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
import { moveManager } from '../../managers/MoveManager.js';
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
import { rematchModalManager } from '../../managers/RematchModalManager.js';
|
||||||
|
import { showRematchSummary } from '../RematchSummaryModal.js';
|
||||||
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../../utils/extensionReimportBridge.js';
|
||||||
|
|
||||||
export class RecipeContextMenu extends BaseContextMenu {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
@@ -303,26 +305,36 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Capture before any await: the menu's click handler nulls currentCard
|
// Capture before any await: the menu's click handler nulls currentCard
|
||||||
const filePath = this.currentCard?.dataset?.filepath;
|
const filePath = this.currentCard?.dataset?.filepath;
|
||||||
|
|
||||||
|
// Collect options (relaxed matching) before starting anything; the
|
||||||
|
// run only begins when the user confirms the dialog.
|
||||||
|
rematchModalManager.showOptionsModal({
|
||||||
|
scope: 'single',
|
||||||
|
onConfirm: ({ relaxed }) => this._startRematchRecipe(recipeId, filePath, relaxed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startRematchRecipe(recipeId, filePath, relaxed = false) {
|
||||||
try {
|
try {
|
||||||
showToast('Rematching recipe to local models...', {}, 'info');
|
showToast('Rematching recipe to local models...', {}, 'info');
|
||||||
|
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
|
const response = await fetch(`/api/lm/recipe/${recipeId}/rematch`, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ relaxed: !!relaxed }),
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const matchedEntries = result.matched_entries || result.rematched || 0;
|
const matchedEntries = result.matched_entries || result.rematched || 0;
|
||||||
const failures = result.errors || 0;
|
const failures = result.errors || 0;
|
||||||
|
const unresolvedEntries = result.unresolved_entries || 0;
|
||||||
|
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||||
|
// Complete no-op (nothing matched, nothing unresolved, no
|
||||||
|
// errors) keeps the lightweight toast; anything else opens
|
||||||
|
// the post-run summary modal.
|
||||||
|
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||||
|
|
||||||
if (matchedEntries > 0) {
|
if (matchedEntries > 0) {
|
||||||
const toastKey = failures > 0
|
|
||||||
? 'toast.recipes.rematchCompleteErrors'
|
|
||||||
: 'toast.recipes.rematchComplete';
|
|
||||||
showToast(
|
|
||||||
toastKey,
|
|
||||||
{ rematched: matchedEntries, skipped: result.skipped || 0, total: 1, entries: matchedEntries, recipes: 1, failures },
|
|
||||||
failures > 0 ? 'warning' : 'success'
|
|
||||||
);
|
|
||||||
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
const detailResponse = await fetch(`/api/lm/recipe/${recipeId}`);
|
||||||
if (detailResponse.ok) {
|
if (detailResponse.ok) {
|
||||||
const updatedRecipe = await detailResponse.json();
|
const updatedRecipe = await detailResponse.json();
|
||||||
@@ -330,16 +342,22 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
state.virtualScroller.updateSingleItem(filePath, updatedRecipe);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (result.unresolved_entries > 0) {
|
}
|
||||||
// Entries existed but have no local model — expected for
|
|
||||||
// models deleted from Civitai; informational, not an error.
|
if (isNoop) {
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchUnmatched',
|
|
||||||
{ entries: result.unresolved_entries, recipes: 1, total: 1 },
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
showToast('toast.recipes.rematchSkipped', { total: 1 }, 'info');
|
||||||
|
} else {
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'single',
|
||||||
|
total: 1,
|
||||||
|
matchedRecipes: result.matched_recipes || (matchedEntries > 0 ? 1 : 0),
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes: result.unresolved_recipes || 0,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped: result.skipped || 0,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || 'Rematch failed');
|
throw new Error(result.error || 'Rematch failed');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
export { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
export { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
export { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
|
export { OtherContextMenu } from './OtherContextMenu.js';
|
||||||
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
export { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
export { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ import { LoraContextMenu } from './LoraContextMenu.js';
|
|||||||
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
import { RecipeContextMenu } from './RecipeContextMenu.js';
|
||||||
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
import { CheckpointContextMenu } from './CheckpointContextMenu.js';
|
||||||
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
import { EmbeddingContextMenu } from './EmbeddingContextMenu.js';
|
||||||
|
import { OtherContextMenu } from './OtherContextMenu.js';
|
||||||
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
import { GlobalContextMenu } from './GlobalContextMenu.js';
|
||||||
|
|
||||||
// Factory method to create page-specific context menu instances
|
// Factory method to create page-specific context menu instances
|
||||||
@@ -22,6 +24,8 @@ export function createPageContextMenu(pageType) {
|
|||||||
return new CheckpointContextMenu();
|
return new CheckpointContextMenu();
|
||||||
case 'embeddings':
|
case 'embeddings':
|
||||||
return new EmbeddingContextMenu();
|
return new EmbeddingContextMenu();
|
||||||
|
case 'other':
|
||||||
|
return new OtherContextMenu();
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export class HeaderManager {
|
|||||||
if (path.includes('/loras/recipes')) return 'recipes';
|
if (path.includes('/loras/recipes')) return 'recipes';
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
|
if (path.includes('/other')) return 'other';
|
||||||
if (path.includes('/statistics')) return 'statistics';
|
if (path.includes('/statistics')) return 'statistics';
|
||||||
if (path.includes('/loras')) return 'loras';
|
if (path.includes('/loras')) return 'loras';
|
||||||
return 'unknown';
|
return 'unknown';
|
||||||
@@ -49,6 +50,18 @@ export class HeaderManager {
|
|||||||
initializeCommonElements() {
|
initializeCommonElements() {
|
||||||
this.initializeThemePopover();
|
this.initializeThemePopover();
|
||||||
|
|
||||||
|
// Header icon buttons are divs with role="button"; make Enter/Space activate them
|
||||||
|
const headerControls = document.getElementById('headerControls');
|
||||||
|
if (headerControls) {
|
||||||
|
headerControls.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
const target = e.target.closest('[role="button"]');
|
||||||
|
if (!target || !headerControls.contains(target)) return;
|
||||||
|
e.preventDefault();
|
||||||
|
target.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const settingsToggle = document.querySelector('.settings-toggle');
|
const settingsToggle = document.querySelector('.settings-toggle');
|
||||||
if (settingsToggle) {
|
if (settingsToggle) {
|
||||||
settingsToggle.addEventListener('click', () => {
|
settingsToggle.addEventListener('click', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Modal Component
|
// Recipe Modal Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow, isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
||||||
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||||
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
import { buildCivitaiUrl } from '../utils/civitaiUtils.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -1078,8 +1078,9 @@ class RecipeModal {
|
|||||||
|
|
||||||
// Mirror the checkpoint "broken" rule: deleted, an
|
// Mirror the checkpoint "broken" rule: deleted, an
|
||||||
// unresolvable hash, or a name-only remnant with no CivitAI
|
// unresolvable hash, or a name-only remnant with no CivitAI
|
||||||
// identifiers at all cannot be fixed by downloading —
|
// identifiers at all cannot be fixed by downloading, so no
|
||||||
// reconnecting a local LoRA is the only remediation.
|
// download button is offered. Reconnect is always available
|
||||||
|
// for missing entries (see renderLoraItemActions).
|
||||||
const needsReconnect = !existsLocally
|
const needsReconnect = !existsLocally
|
||||||
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
&& (isDeleted || lora.hashInvalid || !this.canDownloadLora(lora));
|
||||||
|
|
||||||
@@ -1180,7 +1181,7 @@ class RecipeModal {
|
|||||||
</div>
|
</div>
|
||||||
${actionsRow}
|
${actionsRow}
|
||||||
</div>
|
</div>
|
||||||
${needsReconnect ? `
|
${!existsLocally ? `
|
||||||
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
<div class="lora-reconnect-container" data-lora-index="${loraIndex}">
|
||||||
<div class="reconnect-instructions">
|
<div class="reconnect-instructions">
|
||||||
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
<p>${escapeHtml(translate('recipes.resources.reconnectInstructions', {}, 'Enter LoRA syntax or name to reconnect:'))}</p>
|
||||||
@@ -2853,11 +2854,7 @@ class RecipeModal {
|
|||||||
* the model cannot be resolved — never for transient transport errors.
|
* the model cannot be resolved — never for transient transport errors.
|
||||||
*/
|
*/
|
||||||
_isUnresolvableDownloadError(message) {
|
_isUnresolvableDownloadError(message) {
|
||||||
if (!message) {
|
return isUnresolvableDownloadError(message);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const text = String(message).toLowerCase();
|
|
||||||
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getResourceCivitaiUrl(resource) {
|
getResourceCivitaiUrl(resource) {
|
||||||
@@ -2915,19 +2912,9 @@ class RecipeModal {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const controls = [];
|
const controls = [];
|
||||||
if (needsReconnect) {
|
if (!needsReconnect) {
|
||||||
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
|
|
||||||
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
|
|
||||||
controls.push(`
|
|
||||||
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
|
|
||||||
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
|
|
||||||
<i class="fas fa-link" aria-hidden="true"></i>
|
|
||||||
<span>${escapeHtml(reconnectLabel)}</span>
|
|
||||||
</button>
|
|
||||||
`);
|
|
||||||
} else {
|
|
||||||
// needsReconnect already implies canDownloadLora() here, so the
|
// needsReconnect already implies canDownloadLora() here, so the
|
||||||
// download action is unconditional.
|
// download action is unconditional in this branch.
|
||||||
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
const downloadLabel = translate('recipes.resources.download', {}, 'Download');
|
||||||
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
const downloadTooltip = translate('recipes.resources.downloadLoraTooltip', {}, 'Download this LoRA');
|
||||||
controls.push(`
|
controls.push(`
|
||||||
@@ -2938,6 +2925,18 @@ class RecipeModal {
|
|||||||
</button>
|
</button>
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
// Reconnect is always offered for missing entries — when the LoRA
|
||||||
|
// already exists locally under a different hash, downloading first
|
||||||
|
// just to flip the button would be a waste.
|
||||||
|
const reconnectLabel = translate('recipes.resources.reconnect', {}, 'Reconnect');
|
||||||
|
const reconnectTooltip = translate('recipes.resources.reconnectTooltip', {}, 'Reconnect with a local LoRA');
|
||||||
|
controls.push(`
|
||||||
|
<button type="button" class="resource-action ghost compact lora-reconnect" data-lora-index="${loraIndex}"
|
||||||
|
title="${escapeHtml(reconnectTooltip)}" aria-label="${escapeHtml(reconnectTooltip)}">
|
||||||
|
<i class="fas fa-link" aria-hidden="true"></i>
|
||||||
|
<span>${escapeHtml(reconnectLabel)}</span>
|
||||||
|
</button>
|
||||||
|
`);
|
||||||
|
|
||||||
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
|
return `<div class="recipe-lora-actions">${controls.join('')}</div>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML entities in a string to prevent injection when interpolating
|
||||||
|
* into innerHTML (same approach as DownloadBatchSummaryModal).
|
||||||
|
* @param {string} str - The string to escape
|
||||||
|
* @returns {string} - The escaped string
|
||||||
|
*/
|
||||||
|
function _escapeHtml(str) {
|
||||||
|
if (str === null || str === undefined) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = String(str);
|
||||||
|
return div.innerHTML.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the 3-state summary header (mirrors the batch download/import
|
||||||
|
* summary semantics).
|
||||||
|
*
|
||||||
|
* - error: nothing matched and at least one recipe errored
|
||||||
|
* - warning: errors, unresolved entries, filename-level (L4) matches to
|
||||||
|
* review, or a cancelled run
|
||||||
|
* - success: otherwise
|
||||||
|
*/
|
||||||
|
function _resolveHeader({ matchedEntries, errors, unresolvedEntries, l4Count, cancelled }) {
|
||||||
|
if (matchedEntries === 0 && errors > 0) {
|
||||||
|
return {
|
||||||
|
state: 'error',
|
||||||
|
icon: 'fa-times-circle',
|
||||||
|
text: translate('modals.rematchSummary.failed', {}, 'Rematch failed'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (errors > 0 || unresolvedEntries > 0 || l4Count > 0 || cancelled) {
|
||||||
|
return {
|
||||||
|
state: 'warning',
|
||||||
|
icon: 'fa-exclamation-circle',
|
||||||
|
text: translate('modals.rematchSummary.completedWithWarnings', {}, 'Rematch completed — review recommended'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
state: 'success',
|
||||||
|
icon: 'fa-check-circle',
|
||||||
|
text: translate('modals.rematchSummary.successMessage', { entries: matchedEntries }, `Matched ${matchedEntries} entries`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a plain-text report of the rematch run. `undoneIndexes` carries the
|
||||||
|
* L4 rows undone so far, so the report reflects the undo status at copy time.
|
||||||
|
*/
|
||||||
|
function _buildReportText({ scope, cancelled, total, matchedRecipes, matchedEntries, unresolvedRecipes, unresolvedEntries, skipped, errors, l4Matches, undoneIndexes }) {
|
||||||
|
const scopeFallbacks = {
|
||||||
|
global: 'All recipes',
|
||||||
|
bulk: 'Selected recipes',
|
||||||
|
single: 'Single recipe',
|
||||||
|
};
|
||||||
|
const scopeLabel = translate(
|
||||||
|
`modals.rematchSummary.scope_${scope}`,
|
||||||
|
{},
|
||||||
|
scopeFallbacks[scope] || scope
|
||||||
|
);
|
||||||
|
const lines = [
|
||||||
|
'=== Recipe Rematch Report ===',
|
||||||
|
`Date: ${new Date().toLocaleString()}`,
|
||||||
|
`Scope: ${scopeLabel}`,
|
||||||
|
`Cancelled: ${cancelled ? 'yes' : 'no'}`,
|
||||||
|
`Total recipes: ${total}`,
|
||||||
|
`Matched recipes: ${matchedRecipes}`,
|
||||||
|
`Matched entries: ${matchedEntries}`,
|
||||||
|
`Needs review (filename matches): ${l4Matches.length}`,
|
||||||
|
`Unresolved entries: ${unresolvedEntries} (in ${unresolvedRecipes} recipes)`,
|
||||||
|
`Skipped: ${skipped}`,
|
||||||
|
`Errors: ${errors}`,
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
if (l4Matches.length > 0) {
|
||||||
|
lines.push('--- Filename matches (L4) ---');
|
||||||
|
l4Matches.forEach((match, i) => {
|
||||||
|
const undone = undoneIndexes.has(i) ? ' [undone]' : '';
|
||||||
|
lines.push(`${i + 1}. [${match.recipe_id}] ${match.entry} -> ${match.file_name}${undone}`);
|
||||||
|
});
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
lines.push('====================');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a successful clipboard write: confirm via toast and briefly swap the
|
||||||
|
* trigger button to a "Copied!" state (mirrors the batch summary modal).
|
||||||
|
*/
|
||||||
|
function _onCopyReportSuccess(btn) {
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
if (btn) {
|
||||||
|
const origHTML = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = origHTML; }, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fallback for environments without the async Clipboard API (e.g. insecure
|
||||||
|
* contexts over LAN http): copy via a hidden textarea and execCommand.
|
||||||
|
*/
|
||||||
|
function _copyReportWithExecCommand(text) {
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
showToast('toast.api.copiedToClipboard', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _copyReport(btn, reportArgs) {
|
||||||
|
const text = _buildReportText(reportArgs);
|
||||||
|
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
.then(() => _onCopyReportSuccess(btn))
|
||||||
|
.catch(() => _copyReportWithExecCommand(text));
|
||||||
|
} else {
|
||||||
|
_copyReportWithExecCommand(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo a single L4 match via the existing restore endpoints (moved from
|
||||||
|
* RematchModalManager). Checkpoint restore needs only recipe_id; lora
|
||||||
|
* restore additionally takes lora_index.
|
||||||
|
*/
|
||||||
|
async function _undoMatch(match) {
|
||||||
|
const isCheckpoint = match.type === 'checkpoint';
|
||||||
|
const body = isCheckpoint
|
||||||
|
? { recipe_id: match.recipe_id }
|
||||||
|
: { recipe_id: match.recipe_id, lora_index: match.lora_index };
|
||||||
|
const response = await fetch(
|
||||||
|
isCheckpoint
|
||||||
|
? '/api/lm/recipe/checkpoint/restore'
|
||||||
|
: '/api/lm/recipe/lora/restore',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || 'Restore failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the post-run rematch summary modal. Mirrors the batch download
|
||||||
|
* summary lifecycle: the modal element is appended directly to
|
||||||
|
* document.body and removed on close; it is not registered with
|
||||||
|
* ModalManager.
|
||||||
|
*
|
||||||
|
* @param {Object} options
|
||||||
|
* @param {'global'|'bulk'|'single'} options.scope - Which entry point ran
|
||||||
|
* @param {boolean} options.cancelled - Whether the run was cancelled
|
||||||
|
* @param {number} options.total - Recipes scanned
|
||||||
|
* @param {number} options.matchedRecipes - Recipes updated
|
||||||
|
* @param {number} options.matchedEntries - Entries reconnected
|
||||||
|
* @param {number} options.unresolvedRecipes - Recipes with unresolved entries
|
||||||
|
* @param {number} options.unresolvedEntries - Candidate entries with no local match
|
||||||
|
* @param {number} options.skipped - Recipes left untouched
|
||||||
|
* @param {number} options.errors - Per-recipe errors
|
||||||
|
* @param {Array} options.l4Matches - Filename-level matches for review/undo
|
||||||
|
* ({ recipe_id, type, entry, file_name, lora_index? })
|
||||||
|
*/
|
||||||
|
export function showRematchSummary({
|
||||||
|
scope = 'global',
|
||||||
|
cancelled = false,
|
||||||
|
total = 0,
|
||||||
|
matchedRecipes = 0,
|
||||||
|
matchedEntries = 0,
|
||||||
|
unresolvedRecipes = 0,
|
||||||
|
unresolvedEntries = 0,
|
||||||
|
skipped = 0,
|
||||||
|
errors = 0,
|
||||||
|
l4Matches = [],
|
||||||
|
} = {}) {
|
||||||
|
const matches = Array.isArray(l4Matches) ? l4Matches : [];
|
||||||
|
const undoneIndexes = new Set();
|
||||||
|
const header = _resolveHeader({
|
||||||
|
matchedEntries,
|
||||||
|
errors,
|
||||||
|
unresolvedEntries,
|
||||||
|
l4Count: matches.length,
|
||||||
|
cancelled,
|
||||||
|
});
|
||||||
|
|
||||||
|
const matchRows = matches.map((match, i) => `
|
||||||
|
<tr data-l4-index="${i}">
|
||||||
|
<td class="failure-index">${i + 1}</td>
|
||||||
|
<td class="failure-name" title="${_escapeHtml(match.recipe_id)}">${_escapeHtml(match.recipe_id)}</td>
|
||||||
|
<td class="failure-name" title="${_escapeHtml(match.entry)}">${_escapeHtml(match.entry)}</td>
|
||||||
|
<td class="failure-name" title="${_escapeHtml(match.file_name)}">${_escapeHtml(match.file_name)}</td>
|
||||||
|
<td class="rematch-undo-cell">
|
||||||
|
<button class="secondary-btn rematch-undo-btn" data-action="undo-match" data-index="${i}">
|
||||||
|
${translate('modals.rematchResults.undo', {}, 'Undo')}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>`).join('');
|
||||||
|
|
||||||
|
const modalHtml = `
|
||||||
|
<div id="rematchSummaryModal" class="modal" style="display: block;">
|
||||||
|
<div class="modal-content rematch-summary-modal">
|
||||||
|
<button class="close" data-action="close-modal">×</button>
|
||||||
|
|
||||||
|
<h2>${translate('modals.rematchSummary.title', {}, 'Rematch Summary')}</h2>
|
||||||
|
|
||||||
|
<div class="summary-header ${header.state}">
|
||||||
|
<i class="fas ${header.icon}"></i>
|
||||||
|
<span class="summary-title">${header.text}</span>
|
||||||
|
<span class="summary-hint">${matchedRecipes}/${total}</span>
|
||||||
|
</div>
|
||||||
|
${cancelled ? `
|
||||||
|
<p class="rematch-cancelled-note">
|
||||||
|
<i class="fas fa-info-circle"></i>
|
||||||
|
${translate('modals.rematchSummary.cancelledNote', {}, 'Run cancelled before completion — counts are partial.')}
|
||||||
|
</p>` : ''}
|
||||||
|
|
||||||
|
<div class="refresh-summary-stats">
|
||||||
|
<div class="stat-card stat-card-success">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statMatched', {}, 'Matched entries')}</span>
|
||||||
|
<span class="stat-card-value">${matchedEntries}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-skipped">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statReview', {}, 'Needs review')}</span>
|
||||||
|
<span class="stat-card-value">${matches.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-total">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statUnresolved', {}, 'Unresolved')}</span>
|
||||||
|
<span class="stat-card-value">${unresolvedEntries}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card stat-card-failure">
|
||||||
|
<div class="stat-card-body">
|
||||||
|
<span class="stat-card-label">${translate('modals.rematchSummary.statErrors', {}, 'Errors')}</span>
|
||||||
|
<span class="stat-card-value">${errors}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${matches.length > 0 ? `
|
||||||
|
<div class="refresh-failures-section rematch-review-section">
|
||||||
|
<h4><i class="fas fa-exclamation-triangle"></i> ${translate('modals.rematchSummary.reviewSection', { count: matches.length }, `Filename matches to review (${matches.length})`)}</h4>
|
||||||
|
<div class="failure-table-wrapper">
|
||||||
|
<table class="failure-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnRecipe', {}, 'Recipe')}</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnEntry', {}, 'Entry')}</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnFile', {}, 'Matched file')}</th>
|
||||||
|
<th>${translate('modals.rematchSummary.columnUndo', {}, 'Undo')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${matchRows}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="secondary-btn" data-action="copy-report"><i class="fas fa-copy"></i> ${translate('modals.rematchSummary.copyReport', {}, 'Copy Report')}</button>
|
||||||
|
<button class="cancel-btn" data-action="close-modal">${translate('modals.rematchSummary.close', {}, 'Close')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const existing = document.getElementById('rematchSummaryModal');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const container = document.createElement('div');
|
||||||
|
container.innerHTML = modalHtml;
|
||||||
|
const modal = container.firstElementChild;
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const reportArgs = {
|
||||||
|
scope,
|
||||||
|
cancelled,
|
||||||
|
total,
|
||||||
|
matchedRecipes,
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped,
|
||||||
|
errors,
|
||||||
|
l4Matches: matches,
|
||||||
|
undoneIndexes,
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.addEventListener('click', async (e) => {
|
||||||
|
const actionEl = e.target.closest('[data-action]');
|
||||||
|
const action = actionEl?.dataset.action;
|
||||||
|
if (!action) return;
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'close-modal':
|
||||||
|
modal.remove();
|
||||||
|
break;
|
||||||
|
case 'copy-report':
|
||||||
|
_copyReport(actionEl, reportArgs);
|
||||||
|
break;
|
||||||
|
case 'undo-match': {
|
||||||
|
const index = Number(actionEl.dataset.index);
|
||||||
|
const match = matches[index];
|
||||||
|
if (!match || actionEl.disabled) break;
|
||||||
|
const row = modal.querySelector(`tr[data-l4-index="${index}"]`);
|
||||||
|
try {
|
||||||
|
await _undoMatch(match);
|
||||||
|
undoneIndexes.add(index);
|
||||||
|
row?.classList.add('undone');
|
||||||
|
actionEl.disabled = true;
|
||||||
|
actionEl.textContent = translate('modals.rematchResults.undone', {}, 'Undone');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to undo rematch match:', error);
|
||||||
|
showToast(
|
||||||
|
'modals.rematchResults.undoFailed',
|
||||||
|
{ message: error.message },
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
|
|||||||
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
import { escapeHtml, escapeAttribute } from './shared/utils.js';
|
||||||
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
|
||||||
|
|
||||||
|
// Pages whose folder sidebar starts hidden. "other" downloads default to a flat
|
||||||
|
// layout (no subfolders are created), so on a fresh library the tree is empty
|
||||||
|
// there and the sidebar would only consume horizontal space. The preference is
|
||||||
|
// still persisted per page once the user toggles it, and the edge indicator
|
||||||
|
// makes the hidden sidebar discoverable/recoverable.
|
||||||
|
const SIDEBAR_DEFAULT_HIDDEN_PAGES = new Set(['other']);
|
||||||
|
|
||||||
export class SidebarManager {
|
export class SidebarManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pageControls = null;
|
this.pageControls = null;
|
||||||
@@ -1126,6 +1133,7 @@ export class SidebarManager {
|
|||||||
recipes: 'Recipes',
|
recipes: 'Recipes',
|
||||||
checkpoints: 'Checkpoints',
|
checkpoints: 'Checkpoints',
|
||||||
embeddings: 'Embeddings',
|
embeddings: 'Embeddings',
|
||||||
|
other: 'Other Models',
|
||||||
};
|
};
|
||||||
return names[this.pageType] || this.pageType;
|
return names[this.pageType] || this.pageType;
|
||||||
}
|
}
|
||||||
@@ -1784,7 +1792,10 @@ export class SidebarManager {
|
|||||||
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
const expandedPaths = getStorageItem(`${this.pageType}_expandedNodes`, []);
|
||||||
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
const displayMode = getStorageItem(`${this.pageType}_displayMode`, 'tree'); // 'tree' or 'list', default to 'tree'
|
||||||
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
const recursiveSearchEnabled = getStorageItem(`${this.pageType}_recursiveSearch`, true);
|
||||||
this.isDisabledByPage = getStorageItem(`${this.pageType}_sidebarDisabled`, false);
|
this.isDisabledByPage = getStorageItem(
|
||||||
|
`${this.pageType}_sidebarDisabled`,
|
||||||
|
SIDEBAR_DEFAULT_HIDDEN_PAGES.has(this.pageType)
|
||||||
|
);
|
||||||
|
|
||||||
this.expandedNodes = new Set(expandedPaths);
|
this.expandedNodes = new Set(expandedPaths);
|
||||||
this.displayMode = displayMode;
|
this.displayMode = displayMode;
|
||||||
@@ -1804,7 +1815,7 @@ export class SidebarManager {
|
|||||||
_migrateOldSettings() {
|
_migrateOldSettings() {
|
||||||
if (getStorageItem('_sidebar_migration_done')) return;
|
if (getStorageItem('_sidebar_migration_done')) return;
|
||||||
|
|
||||||
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings'];
|
const PAGES = ['loras', 'recipes', 'checkpoints', 'embeddings', 'other'];
|
||||||
|
|
||||||
// 1. Migrate global hide setting to per-page
|
// 1. Migrate global hide setting to per-page
|
||||||
if (state?.global?.settings?.show_folder_sidebar === false) {
|
if (state?.global?.settings?.show_folder_sidebar === false) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// OtherControls.js - Specific implementation for the Other Models page
|
||||||
|
import { PageControls } from './PageControls.js';
|
||||||
|
import { getModelApiClient, resetAndReload } from '../../api/modelApiFactory.js';
|
||||||
|
import { showToast } from '../../utils/uiHelpers.js';
|
||||||
|
import { downloadManager } from '../../managers/DownloadManager.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OtherControls class - Extends PageControls for the Other Models page
|
||||||
|
* (VAE, upscalers, text encoders, CLIP vision, ControlNet, ...)
|
||||||
|
*/
|
||||||
|
export class OtherControls extends PageControls {
|
||||||
|
constructor() {
|
||||||
|
// Initialize with 'other' page type
|
||||||
|
super('other');
|
||||||
|
|
||||||
|
// Register API methods specific to the Other Models page
|
||||||
|
this.registerOtherAPI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register Other-models-specific API methods
|
||||||
|
*/
|
||||||
|
registerOtherAPI() {
|
||||||
|
const otherAPI = {
|
||||||
|
// Core API functions
|
||||||
|
loadMoreModels: async (resetPage = false, updateFolders = false) => {
|
||||||
|
return await getModelApiClient().loadMoreWithVirtualScroll(resetPage, updateFolders);
|
||||||
|
},
|
||||||
|
|
||||||
|
resetAndReload: async (updateFolders = false) => {
|
||||||
|
return await resetAndReload(updateFolders);
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshModels: async (fullRebuild = false) => {
|
||||||
|
return await getModelApiClient().refreshModels(fullRebuild);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add fetch from Civitai functionality for other models
|
||||||
|
fetchFromCivitai: async () => {
|
||||||
|
return await getModelApiClient().fetchCivitaiMetadata();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Add show download modal functionality
|
||||||
|
showDownloadModal: () => {
|
||||||
|
downloadManager.showDownloadModal();
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleBulkMode: () => {
|
||||||
|
if (window.bulkManager) {
|
||||||
|
window.bulkManager.toggleBulkMode();
|
||||||
|
} else {
|
||||||
|
console.error('Bulk manager not available');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// No clearCustomFilter implementation is needed for other models
|
||||||
|
// as custom filters are currently only used for LoRAs
|
||||||
|
clearCustomFilter: async () => {
|
||||||
|
showToast('toast.filters.noCustomFilterToClear', {}, 'info');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register the API
|
||||||
|
this.registerAPI(otherAPI);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ import { PageControls } from './PageControls.js';
|
|||||||
import { LorasControls } from './LorasControls.js';
|
import { LorasControls } from './LorasControls.js';
|
||||||
import { CheckpointsControls } from './CheckpointsControls.js';
|
import { CheckpointsControls } from './CheckpointsControls.js';
|
||||||
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
import { EmbeddingsControls } from './EmbeddingsControls.js';
|
||||||
|
import { OtherControls } from './OtherControls.js';
|
||||||
|
|
||||||
// Export the classes
|
// Export the classes
|
||||||
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls };
|
export { PageControls, LorasControls, CheckpointsControls, EmbeddingsControls, OtherControls };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Factory function to create the appropriate controls based on page type
|
* Factory function to create the appropriate controls based on page type
|
||||||
* @param {string} pageType - The type of page ('loras', 'checkpoints', or 'embeddings')
|
* @param {string} pageType - The type of page ('loras', 'checkpoints', 'embeddings', or 'other')
|
||||||
* @returns {PageControls} - The appropriate controls instance
|
* @returns {PageControls} - The appropriate controls instance
|
||||||
*/
|
*/
|
||||||
export function createPageControls(pageType) {
|
export function createPageControls(pageType) {
|
||||||
@@ -19,8 +20,10 @@ export function createPageControls(pageType) {
|
|||||||
return new CheckpointsControls();
|
return new CheckpointsControls();
|
||||||
} else if (pageType === 'embeddings') {
|
} else if (pageType === 'embeddings') {
|
||||||
return new EmbeddingsControls();
|
return new EmbeddingsControls();
|
||||||
|
} else if (pageType === 'other') {
|
||||||
|
return new OtherControls();
|
||||||
} else {
|
} else {
|
||||||
console.error(`Unknown page type: ${pageType}`);
|
console.error(`Unknown page type: ${pageType}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ class InitializationManager {
|
|||||||
this.pageType = 'recipes';
|
this.pageType = 'recipes';
|
||||||
} else if (path.includes('/checkpoints')) {
|
} else if (path.includes('/checkpoints')) {
|
||||||
this.pageType = 'checkpoints';
|
this.pageType = 'checkpoints';
|
||||||
|
} else if (path.includes('/other')) {
|
||||||
|
this.pageType = 'other';
|
||||||
} else if (path.includes('/loras')) {
|
} else if (path.includes('/loras')) {
|
||||||
this.pageType = 'loras';
|
this.pageType = 'loras';
|
||||||
} else if (path.includes('/embeddings')) {
|
} else if (path.includes('/embeddings')) {
|
||||||
@@ -221,6 +223,7 @@ class InitializationManager {
|
|||||||
'lora': 'loras',
|
'lora': 'loras',
|
||||||
'checkpoint': 'checkpoints',
|
'checkpoint': 'checkpoints',
|
||||||
'embedding': 'embeddings',
|
'embedding': 'embeddings',
|
||||||
|
'other': 'other',
|
||||||
'recipe': 'recipes'
|
'recipe': 'recipes'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../../state/index.js';
|
import { state, getCurrentPageState } from '../../state/index.js';
|
||||||
import { showModelModal } from './ModelModal.js';
|
import { showModelModal } from './ModelModal.js';
|
||||||
|
import { hasCivitaiSource } from './utils.js';
|
||||||
import { bulkManager } from '../../managers/BulkManager.js';
|
import { bulkManager } from '../../managers/BulkManager.js';
|
||||||
import { modalManager } from '../../managers/ModalManager.js';
|
import { modalManager } from '../../managers/ModalManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
|
||||||
@@ -63,7 +64,10 @@ function handleModelCardEvent_internal(event, modelType) {
|
|||||||
|
|
||||||
if (event.target.closest('.fa-globe')) {
|
if (event.target.closest('.fa-globe')) {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (card.dataset.from_civitai === 'true') {
|
// CivitAI wins when the model actually has CivitAI data; otherwise fall
|
||||||
|
// back to HuggingFace. Relying on `from_civitai` here made the two
|
||||||
|
// sources mutually exclusive whenever one of them was (re)linked (#1094).
|
||||||
|
if (card.dataset.has_civitai === 'true') {
|
||||||
openCivitai(card.dataset.filepath);
|
openCivitai(card.dataset.filepath);
|
||||||
} else if (card.dataset.hf_url) {
|
} else if (card.dataset.hf_url) {
|
||||||
openHuggingFace(card.dataset.hf_url);
|
openHuggingFace(card.dataset.hf_url);
|
||||||
@@ -250,6 +254,11 @@ function handleCopyAction(card, modelType) {
|
|||||||
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
const embeddingCode = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
|
||||||
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
const message = translate('modelCard.actions.embeddingNameCopied', {}, 'Embedding syntax copied');
|
||||||
copyToClipboard(embeddingCode, message);
|
copyToClipboard(embeddingCode, message);
|
||||||
|
} else {
|
||||||
|
// Other model types (VAE, upscalers, ...) - copy the file name
|
||||||
|
const fileName = card.dataset.file_name;
|
||||||
|
const message = translate('modelCard.actions.modelNameCopied', {}, 'Model name copied');
|
||||||
|
copyToClipboard(fileName, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -473,6 +482,9 @@ export function createModelCard(model, modelType) {
|
|||||||
card.dataset.modified = model.modified;
|
card.dataset.modified = model.modified;
|
||||||
card.dataset.file_size = model.file_size;
|
card.dataset.file_size = model.file_size;
|
||||||
card.dataset.from_civitai = model.from_civitai;
|
card.dataset.from_civitai = model.from_civitai;
|
||||||
|
// Independent of `from_civitai`: a model can have both CivitAI data and an
|
||||||
|
// HF link, and the card globe must keep pointing at CivitAI when it does.
|
||||||
|
card.dataset.has_civitai = hasCivitaiSource(model.civitai) ? 'true' : 'false';
|
||||||
card.dataset.usage_count = String(model.usage_count);
|
card.dataset.usage_count = String(model.usage_count);
|
||||||
card.dataset.notes = model.notes || '';
|
card.dataset.notes = model.notes || '';
|
||||||
card.dataset.base_model = model.base_model || 'Unknown';
|
card.dataset.base_model = model.base_model || 'Unknown';
|
||||||
@@ -595,12 +607,13 @@ export function createModelCard(model, modelType) {
|
|||||||
const favoriteTitle = isFavorite ?
|
const favoriteTitle = isFavorite ?
|
||||||
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
translate('modelCard.actions.removeFromFavorites', {}, 'Remove from favorites') :
|
||||||
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
translate('modelCard.actions.addToFavorites', {}, 'Add to favorites');
|
||||||
const globeTitle = model.from_civitai ?
|
const hasCivitai = hasCivitaiSource(model.civitai);
|
||||||
|
const globeTitle = hasCivitai ?
|
||||||
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
translate('modelCard.actions.viewOnCivitai', {}, 'View on Civitai') :
|
||||||
model.hf_url ?
|
model.hf_url ?
|
||||||
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
translate('modelCard.actions.viewOnHuggingFace', {}, 'View on Hugging Face') :
|
||||||
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
translate('modelCard.actions.notAvailableFromCivitai', {}, 'Not available from Civitai');
|
||||||
const globeEnabled = model.from_civitai || !!model.hf_url;
|
const globeEnabled = hasCivitai || !!model.hf_url;
|
||||||
let sendTitle;
|
let sendTitle;
|
||||||
let copyTitle;
|
let copyTitle;
|
||||||
if (modelType === MODEL_TYPES.LORA) {
|
if (modelType === MODEL_TYPES.LORA) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from './ModelMetadata.js';
|
} from './ModelMetadata.js';
|
||||||
import { setupTagEditMode } from './ModelTags.js';
|
import { setupTagEditMode } from './ModelTags.js';
|
||||||
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
import { getModelApiClient } from '../../api/modelApiFactory.js';
|
||||||
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml } from './utils.js';
|
import { renderCompactTags, setupTagTooltip, formatFileSize, escapeAttribute, escapeHtml, hasCivitaiSource } from './utils.js';
|
||||||
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
import { renderTriggerWords, setupTriggerWordsEditMode } from './TriggerWords.js';
|
||||||
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
import { parsePresets, renderPresetTags } from './PresetTags.js';
|
||||||
import { initVersionsTab } from './ModelVersionsTab.js';
|
import { initVersionsTab } from './ModelVersionsTab.js';
|
||||||
@@ -389,7 +389,11 @@ export async function showModelModal(model, modelType) {
|
|||||||
const licenseIcons = useNewIcons
|
const licenseIcons = useNewIcons
|
||||||
? renderNewLicenseIcons(modelWithFullData)
|
? renderNewLicenseIcons(modelWithFullData)
|
||||||
: renderLicenseIcons(modelWithFullData);
|
: renderLicenseIcons(modelWithFullData);
|
||||||
const viewOnCivitaiAction = modelWithFullData.from_civitai ? `
|
// Gate the CivitAI link on actual CivitAI data, not the `from_civitai`
|
||||||
|
// provenance flag: a model can be linked to HuggingFace and to CivitAI at
|
||||||
|
// the same time, and both links must coexist (#1094).
|
||||||
|
const hasCivitai = hasCivitaiSource(modelWithFullData.civitai);
|
||||||
|
const viewOnCivitaiAction = hasCivitai ? `
|
||||||
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
<div class="civitai-view" title="${translate('modals.model.actions.viewOnCivitai', {}, 'View on Civitai')}" data-action="view-civitai" data-filepath="${escapedFilePathAttr}">
|
||||||
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
<i class="fas fa-globe"></i> ${translate('modals.model.actions.viewOnCivitaiText', {}, 'View on Civitai')}
|
||||||
</div>`.trim() : '';
|
</div>`.trim() : '';
|
||||||
|
|||||||
@@ -1390,8 +1390,22 @@ export function initVersionsTab({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const client = ensureClient();
|
const client = ensureClient();
|
||||||
const rootsData = await client.fetchModelRoots();
|
// On the checkpoints page a diffusion model lives under the unet
|
||||||
const roots = rootsData?.roots;
|
// roots, so both root sets are needed to locate the current file.
|
||||||
|
let roots;
|
||||||
|
if (modelType === 'checkpoints') {
|
||||||
|
const [checkpointRoots, unetRoots] = await Promise.all([
|
||||||
|
client.fetchModelRoots(),
|
||||||
|
client.fetchModelRoots('diffusion_model'),
|
||||||
|
]);
|
||||||
|
roots = [
|
||||||
|
...(checkpointRoots?.roots || []),
|
||||||
|
...(unetRoots?.roots || []),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
const rootsData = await client.fetchModelRoots();
|
||||||
|
roots = rootsData?.roots;
|
||||||
|
}
|
||||||
if (!Array.isArray(roots) || roots.length === 0) {
|
if (!Array.isArray(roots) || roots.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ export function formatFileSize(bytes) {
|
|||||||
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
return `${size.toFixed(1)} ${units[unitIndex]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a model has usable CivitAI metadata to link to.
|
||||||
|
*
|
||||||
|
* CivitAI links must be gated on the presence of actual CivitAI data rather
|
||||||
|
* than the `from_civitai` provenance flag: linking a model to HuggingFace used
|
||||||
|
* to flip `from_civitai` to false, which hid the CivitAI link even though the
|
||||||
|
* model still had CivitAI metadata. See issue #1094.
|
||||||
|
*
|
||||||
|
* @param {Object} [civitaiData] - The model's `civitai` payload
|
||||||
|
* @returns {boolean} True when a CivitAI model/version id is available
|
||||||
|
*/
|
||||||
|
export function hasCivitaiSource(civitaiData) {
|
||||||
|
if (!civitaiData || typeof civitaiData !== 'object') return false;
|
||||||
|
return Boolean(
|
||||||
|
civitaiData.modelId ?? civitaiData.model_id ?? civitaiData.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render compact tags
|
* Render compact tags
|
||||||
* @param {Array} tags - Array of tags
|
* @param {Array} tags - Array of tags
|
||||||
|
|||||||
+3
-1
@@ -7,6 +7,7 @@ import { HeaderManager } from './components/Header.js';
|
|||||||
import { settingsManager } from './managers/SettingsManager.js';
|
import { settingsManager } from './managers/SettingsManager.js';
|
||||||
import { moveManager } from './managers/MoveManager.js';
|
import { moveManager } from './managers/MoveManager.js';
|
||||||
import { bulkManager } from './managers/BulkManager.js';
|
import { bulkManager } from './managers/BulkManager.js';
|
||||||
|
import { rematchModalManager } from './managers/RematchModalManager.js';
|
||||||
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
import { ExampleImagesManager } from './managers/ExampleImagesManager.js';
|
||||||
import { helpManager } from './managers/HelpManager.js';
|
import { helpManager } from './managers/HelpManager.js';
|
||||||
import { doctorManager } from './managers/DoctorManager.js';
|
import { doctorManager } from './managers/DoctorManager.js';
|
||||||
@@ -68,6 +69,7 @@ export class AppCore {
|
|||||||
window.doctorManager = doctorManager;
|
window.doctorManager = doctorManager;
|
||||||
window.moveManager = moveManager;
|
window.moveManager = moveManager;
|
||||||
window.bulkManager = bulkManager;
|
window.bulkManager = bulkManager;
|
||||||
|
window.rematchModalManager = rematchModalManager;
|
||||||
|
|
||||||
// Initialize UI components
|
// Initialize UI components
|
||||||
window.headerManager = new HeaderManager();
|
window.headerManager = new HeaderManager();
|
||||||
@@ -114,7 +116,7 @@ export class AppCore {
|
|||||||
initializePageFeatures() {
|
initializePageFeatures() {
|
||||||
const pageType = this.getPageType();
|
const pageType = this.getPageType();
|
||||||
|
|
||||||
if (['loras', 'recipes', 'checkpoints', 'embeddings'].includes(pageType)) {
|
if (['loras', 'recipes', 'checkpoints', 'embeddings', 'other'].includes(pageType)) {
|
||||||
this.initializeContextMenus(pageType);
|
this.initializeContextMenus(pageType);
|
||||||
initializeInfiniteScroll(pageType);
|
initializeInfiniteScroll(pageType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import {
|
|||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { enableOtherModels, openOtherModelsSettings } from '../utils/otherModels.js';
|
||||||
|
|
||||||
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
const COMMUNITY_SUPPORT_BANNER_ID = 'community-support';
|
||||||
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
|
const CACHE_HEALTH_BANNER_ID = 'cache-health-warning';
|
||||||
|
const OTHER_MODELS_BANNER_ID = 'other-models-announcement';
|
||||||
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
|
const COMMUNITY_SUPPORT_BANNER_DELAY_MS = 5 * 24 * 60 * 60 * 1000; // 5 days
|
||||||
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
|
const COMMUNITY_SUPPORT_FIRST_SEEN_AT_KEY = 'community_support_banner_first_seen_at';
|
||||||
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
const COMMUNITY_SUPPORT_VERSION_KEY = 'community_support_banner_state_version';
|
||||||
@@ -80,6 +82,7 @@ class BannerService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.prepareCommunitySupportBanner();
|
this.prepareCommunitySupportBanner();
|
||||||
|
this.prepareOtherModelsBanner();
|
||||||
|
|
||||||
await this.showActiveBanners();
|
await this.showActiveBanners();
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
@@ -424,12 +427,13 @@ class BannerService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the current page type from the URL
|
* Get the current page type from the URL
|
||||||
* @returns {string} Page type (loras, checkpoints, embeddings, recipes)
|
* @returns {string} Page type (loras, checkpoints, embeddings, other, recipes)
|
||||||
*/
|
*/
|
||||||
getCurrentPageType() {
|
getCurrentPageType() {
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
if (path.includes('/checkpoints')) return 'checkpoints';
|
if (path.includes('/checkpoints')) return 'checkpoints';
|
||||||
if (path.includes('/embeddings')) return 'embeddings';
|
if (path.includes('/embeddings')) return 'embeddings';
|
||||||
|
if (path.includes('/other')) return 'other';
|
||||||
if (path.includes('/recipes')) return 'recipes';
|
if (path.includes('/recipes')) return 'recipes';
|
||||||
return 'loras';
|
return 'loras';
|
||||||
}
|
}
|
||||||
@@ -443,7 +447,8 @@ class BannerService {
|
|||||||
const endpoints = {
|
const endpoints = {
|
||||||
'loras': '/api/lm/loras/reload?rebuild=true',
|
'loras': '/api/lm/loras/reload?rebuild=true',
|
||||||
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
|
'checkpoints': '/api/lm/checkpoints/reload?rebuild=true',
|
||||||
'embeddings': '/api/lm/embeddings/reload?rebuild=true'
|
'embeddings': '/api/lm/embeddings/reload?rebuild=true',
|
||||||
|
'other': '/api/lm/other/reload?rebuild=true'
|
||||||
};
|
};
|
||||||
return endpoints[pageType] || endpoints['loras'];
|
return endpoints[pageType] || endpoints['loras'];
|
||||||
}
|
}
|
||||||
@@ -539,6 +544,99 @@ class BannerService {
|
|||||||
this.updateContainerVisibility();
|
this.updateContainerVisibility();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Announce the opt-in Other Models management to users who have not turned
|
||||||
|
* it on yet. Dismissal is persisted through the shared dismissed_banners
|
||||||
|
* setting, so users who are not interested are not nagged again.
|
||||||
|
*/
|
||||||
|
prepareOtherModelsBanner() {
|
||||||
|
if (state.global.settings.enable_other_models) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only announce when the host can actually resolve other-model folders.
|
||||||
|
// Standalone installs only know the folder_paths keys present in
|
||||||
|
// settings.json, so announcing there would land the user on an empty
|
||||||
|
// page. `=== false` (not falsy) keeps older payloads working.
|
||||||
|
if (state.global.settings.other_models_paths_available === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.isBannerDismissed(OTHER_MODELS_BANNER_ID)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.registerBanner(OTHER_MODELS_BANNER_ID, {
|
||||||
|
id: OTHER_MODELS_BANNER_ID,
|
||||||
|
title: translate(
|
||||||
|
'banners.otherModels.title',
|
||||||
|
{},
|
||||||
|
'Other Models Management is available'
|
||||||
|
),
|
||||||
|
content: translate(
|
||||||
|
'banners.otherModels.content',
|
||||||
|
{},
|
||||||
|
'Scan and manage VAE, upscaler, text encoder and CLIP vision files — and download them from CivitAI — from one dedicated page.'
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
text: translate(
|
||||||
|
'banners.otherModels.enable',
|
||||||
|
{},
|
||||||
|
'Enable Other Models'
|
||||||
|
),
|
||||||
|
icon: 'fas fa-shapes',
|
||||||
|
type: 'primary',
|
||||||
|
action: 'enable-other-models'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: translate(
|
||||||
|
'banners.otherModels.openSettings',
|
||||||
|
{},
|
||||||
|
'Open Settings'
|
||||||
|
),
|
||||||
|
icon: 'fas fa-cog',
|
||||||
|
type: 'secondary',
|
||||||
|
action: 'open-other-models-settings'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
dismissible: true,
|
||||||
|
priority: 0,
|
||||||
|
onRegister: (bannerElement) => {
|
||||||
|
const enableButton = bannerElement.querySelector(
|
||||||
|
'.banner-action[data-action="enable-other-models"]'
|
||||||
|
);
|
||||||
|
if (enableButton) {
|
||||||
|
enableButton.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
enableOtherModels().catch((error) => {
|
||||||
|
console.error('Failed to enable Other Models:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsButton = bannerElement.querySelector(
|
||||||
|
'.banner-action[data-action="open-other-models-settings"]'
|
||||||
|
);
|
||||||
|
if (settingsButton) {
|
||||||
|
settingsButton.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
openOtherModelsSettings();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateContainerVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the Other Models announcement once the feature is enabled.
|
||||||
|
* Dismissal is deliberately NOT persisted, so the announcement can come
|
||||||
|
* back if the user switches the feature off again.
|
||||||
|
*/
|
||||||
|
removeOtherModelsAnnouncement() {
|
||||||
|
this.removeBannerElement(OTHER_MODELS_BANNER_ID);
|
||||||
|
}
|
||||||
|
|
||||||
initializeCommunitySupportState() {
|
initializeCommunitySupportState() {
|
||||||
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
|
const storedVersion = getStorageItem(COMMUNITY_SUPPORT_VERSION_KEY, null);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class BatchImportManager {
|
|||||||
this.results = null;
|
this.results = null;
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
this.isImporting = false;
|
this.isImporting = false;
|
||||||
|
this.currentParentPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -718,9 +719,10 @@ export class BatchImportManager {
|
|||||||
browser.style.display = isVisible ? 'none' : 'block';
|
browser.style.display = isVisible ? 'none' : 'block';
|
||||||
|
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
// Load initial directory when opening
|
// Load initial directory when opening. An empty path lets the
|
||||||
|
// server pick its default (user home); "/" would be POSIX-only.
|
||||||
const currentPath = document.getElementById('batchDirectoryInput').value;
|
const currentPath = document.getElementById('batchDirectoryInput').value;
|
||||||
this.loadDirectory(currentPath || '/');
|
this.loadDirectory(currentPath || '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -761,6 +763,10 @@ export class BatchImportManager {
|
|||||||
const directoryCount = document.getElementById('batchDirectoryCount');
|
const directoryCount = document.getElementById('batchDirectoryCount');
|
||||||
const imageCount = document.getElementById('batchImageCount');
|
const imageCount = document.getElementById('batchImageCount');
|
||||||
|
|
||||||
|
// Remember the server-computed parent path so the "up" navigation
|
||||||
|
// works with Windows paths too (they cannot be split on "/").
|
||||||
|
this.currentParentPath = data.parent_path || null;
|
||||||
|
|
||||||
if (currentPathEl) {
|
if (currentPathEl) {
|
||||||
currentPathEl.textContent = data.current_path;
|
currentPathEl.textContent = data.current_path;
|
||||||
}
|
}
|
||||||
@@ -811,11 +817,9 @@ export class BatchImportManager {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
item.addEventListener('click', () => {
|
item.addEventListener('click', () => {
|
||||||
if (isParent) {
|
// The parent entry uses the server-provided parent_path (or the
|
||||||
this.navigateToParentDirectory();
|
// Windows drive-list token) directly — both are plain load targets.
|
||||||
} else {
|
this.loadDirectory(path);
|
||||||
this.loadDirectory(path);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
@@ -839,15 +843,12 @@ export class BatchImportManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigate to parent directory
|
* Navigate to parent directory using the path reported by the server.
|
||||||
|
* Deriving it client-side by splitting on "/" breaks Windows paths.
|
||||||
*/
|
*/
|
||||||
navigateToParentDirectory() {
|
navigateToParentDirectory() {
|
||||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
if (this.currentParentPath) {
|
||||||
if (currentPath) {
|
this.loadDirectory(this.currentParentPath);
|
||||||
// Get parent path using path manipulation
|
|
||||||
const lastSeparator = currentPath.lastIndexOf('/');
|
|
||||||
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
|
|
||||||
this.loadDirectory(parentPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -857,8 +858,14 @@ export class BatchImportManager {
|
|||||||
selectCurrentDirectory() {
|
selectCurrentDirectory() {
|
||||||
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
|
||||||
const directoryInput = document.getElementById('batchDirectoryInput');
|
const directoryInput = document.getElementById('batchDirectoryInput');
|
||||||
|
|
||||||
if (currentPath && directoryInput) {
|
if (!currentPath) {
|
||||||
|
// Virtual levels (e.g. the Windows drive list) have no path.
|
||||||
|
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directoryInput) {
|
||||||
directoryInput.value = currentPath;
|
directoryInput.value = currentPath;
|
||||||
this.toggleDirectoryBrowser(); // Close browser
|
this.toggleDirectoryBrowser(); // Close browser
|
||||||
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEm
|
|||||||
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { rematchModalManager } from './RematchModalManager.js';
|
||||||
|
import { showRematchSummary } from '../components/RematchSummaryModal.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
@@ -91,6 +93,20 @@ export class BulkManager {
|
|||||||
setFavorite: true,
|
setFavorite: true,
|
||||||
unfavorite: true
|
unfavorite: true
|
||||||
},
|
},
|
||||||
|
[MODEL_TYPES.OTHER]: {
|
||||||
|
addTags: true,
|
||||||
|
sendToWorkflow: false,
|
||||||
|
copyAll: false,
|
||||||
|
refreshAll: true,
|
||||||
|
checkUpdates: true,
|
||||||
|
moveAll: true,
|
||||||
|
autoOrganize: true,
|
||||||
|
deleteAll: true,
|
||||||
|
setContentRating: true,
|
||||||
|
skipMetadataRefresh: true,
|
||||||
|
setFavorite: true,
|
||||||
|
unfavorite: true
|
||||||
|
},
|
||||||
recipes: {
|
recipes: {
|
||||||
addTags: true,
|
addTags: true,
|
||||||
sendToWorkflow: false,
|
sendToWorkflow: false,
|
||||||
@@ -978,6 +994,15 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collect options (relaxed matching) before starting anything; the
|
||||||
|
// run only begins when the user confirms the dialog.
|
||||||
|
rematchModalManager.showOptionsModal({
|
||||||
|
recipeCount: state.selectedModels.size,
|
||||||
|
onConfirm: ({ relaxed }) => this._startRematchSelectedRecipes(relaxed),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async _startRematchSelectedRecipes(relaxed = false) {
|
||||||
try {
|
try {
|
||||||
const apiClient = this.getActiveApiClient();
|
const apiClient = this.getActiveApiClient();
|
||||||
const filePaths = Array.from(state.selectedModels);
|
const filePaths = Array.from(state.selectedModels);
|
||||||
@@ -989,7 +1014,7 @@ export class BulkManager {
|
|||||||
|
|
||||||
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
|
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
|
||||||
|
|
||||||
const result = await apiClient.rematchBulkModels(filePaths);
|
const result = await apiClient.rematchBulkModels(filePaths, { relaxed: !!relaxed });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const total = result.total || filePaths.length;
|
const total = result.total || filePaths.length;
|
||||||
@@ -1015,38 +1040,29 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchedEntries > 0) {
|
// Complete no-op (nothing matched, nothing unresolved, no
|
||||||
const hasFailures = failures > 0;
|
// errors) keeps the lightweight toast; anything else opens
|
||||||
const toastKey = hasFailures
|
// the post-run summary modal.
|
||||||
? 'toast.recipes.rematchCompleteErrors'
|
const l4Matches = Array.isArray(result.l4_matches) ? result.l4_matches : [];
|
||||||
: 'toast.recipes.rematchComplete';
|
const isNoop = matchedEntries === 0 && unresolvedEntries === 0 && failures === 0;
|
||||||
showToast(
|
if (isNoop) {
|
||||||
toastKey,
|
|
||||||
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
|
|
||||||
hasFailures ? 'warning' : 'success'
|
|
||||||
);
|
|
||||||
} else if (failures > 0) {
|
|
||||||
// Nothing matched and at least one recipe errored —
|
|
||||||
// "no rematch needed" would be actively misleading here.
|
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchAllFailed',
|
|
||||||
{ total, failures },
|
|
||||||
'error'
|
|
||||||
);
|
|
||||||
} else if (unresolvedEntries > 0) {
|
|
||||||
// Entries existed but have no local model — expected for
|
|
||||||
// models deleted from Civitai; informational, not an error.
|
|
||||||
showToast(
|
|
||||||
'toast.recipes.rematchUnmatched',
|
|
||||||
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
|
|
||||||
'info'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
showToast(
|
showToast(
|
||||||
'toast.recipes.rematchSkipped',
|
'toast.recipes.rematchSkipped',
|
||||||
{ total },
|
{ total },
|
||||||
'info'
|
'info'
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
showRematchSummary({
|
||||||
|
scope: 'bulk',
|
||||||
|
total,
|
||||||
|
matchedRecipes,
|
||||||
|
matchedEntries,
|
||||||
|
unresolvedRecipes,
|
||||||
|
unresolvedEntries,
|
||||||
|
skipped,
|
||||||
|
errors: failures,
|
||||||
|
l4Matches,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.bulkMode) this.toggleBulkMode();
|
if (state.bulkMode) this.toggleBulkMode();
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
import { isUnresolvableDownloadError } from '../utils/uiHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
|
import { extractRecipeId } from '../api/recipeApi.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
|
|
||||||
@@ -13,6 +15,7 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
this.loraApiClient = getModelApiClient(MODEL_TYPES.LORA);
|
||||||
this.pendingLoras = [];
|
this.pendingLoras = [];
|
||||||
this.pendingRecipes = [];
|
this.pendingRecipes = [];
|
||||||
|
this.pendingMissingByRecipe = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +139,7 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
// Execute download
|
// Execute download
|
||||||
await this.executeDownload(this.pendingLoras);
|
await this.executeDownload(this.pendingLoras);
|
||||||
this.pendingLoras = [];
|
this.pendingLoras = [];
|
||||||
|
this.pendingMissingByRecipe = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +157,9 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
// Collect missing LoRAs with deduplication
|
// Collect missing LoRAs with deduplication
|
||||||
const stats = this.collectMissingLoras(selectedRecipes);
|
const stats = this.collectMissingLoras(selectedRecipes);
|
||||||
|
// Kept so executeDownload can mark unresolvable failures back onto
|
||||||
|
// every recipe occurrence (hashInvalid → reconnect candidacy).
|
||||||
|
this.pendingMissingByRecipe = stats.missingLorasByRecipe;
|
||||||
|
|
||||||
if (stats.uniqueCount === 0) {
|
if (stats.uniqueCount === 0) {
|
||||||
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
showToast('toast.recipes.noMissingLorasInSelection', {}, 'info');
|
||||||
@@ -196,6 +203,7 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
|
|
||||||
let completedDownloads = 0;
|
let completedDownloads = 0;
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
|
let markedInvalidCount = 0;
|
||||||
let currentLoraProgress = 0;
|
let currentLoraProgress = 0;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -304,6 +312,12 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
console.error(`Failed to download LoRA ${lora.name || lora.file_name}: ${response.error}`);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
|
// An unresolvable failure (model gone on CivitAI) flips
|
||||||
|
// every recipe occurrence to reconnect candidacy — same
|
||||||
|
// rule as the single-LoRA download in RecipeModal.
|
||||||
|
if (isUnresolvableDownloadError(response.error)) {
|
||||||
|
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
completedDownloads++;
|
completedDownloads++;
|
||||||
updateProgress(100, completedDownloads, '');
|
updateProgress(100, completedDownloads, '');
|
||||||
@@ -312,6 +326,9 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
console.error(`Error downloading LoRA ${lora.name || lora.file_name}:`, error);
|
||||||
failedDownloads++;
|
failedDownloads++;
|
||||||
|
if (isUnresolvableDownloadError(error?.message)) {
|
||||||
|
markedInvalidCount += await this.markLoraHashInvalidInRecipes(lora);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,9 +352,16 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}, 'warning');
|
}, 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unresolvable failures were marked hash-invalid during the loop;
|
||||||
|
// tell the user those entries now offer reconnect instead of download.
|
||||||
|
if (markedInvalidCount > 0) {
|
||||||
|
showToast('toast.recipes.unresolvableMarkedForReconnect', {
|
||||||
|
count: markedInvalidCount
|
||||||
|
}, 'info', `${markedInvalidCount} unresolvable entr(ies) marked — they can now be reconnected to a local LoRA.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
// Update each affected recipe card with fresh data (LoRA inLibrary flags changed)
|
||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
const { extractRecipeId } = await import('../api/recipeApi.js');
|
|
||||||
for (const recipe of this.pendingRecipes) {
|
for (const recipe of this.pendingRecipes) {
|
||||||
const recipeId = extractRecipeId(recipe.file_path);
|
const recipeId = extractRecipeId(recipe.file_path);
|
||||||
if (!recipeId) continue;
|
if (!recipeId) continue;
|
||||||
@@ -354,6 +378,59 @@ export class BulkMissingLoraDownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark every recipe occurrence of a failed LoRA as hash-invalid.
|
||||||
|
*
|
||||||
|
* Mirrors RecipeModal.markLoraHashInvalid for the bulk flow: the flag
|
||||||
|
* makes each occurrence an unresolved rematch candidate and swaps its
|
||||||
|
* action from download to reconnect. Only called for unresolvable
|
||||||
|
* failures — transient errors leave entries untouched.
|
||||||
|
*
|
||||||
|
* @param {Object} failedLora - The deduplicated LoRA that failed
|
||||||
|
* @returns {Promise<number>} - How many recipe entries were marked
|
||||||
|
*/
|
||||||
|
async markLoraHashInvalidInRecipes(failedLora) {
|
||||||
|
const failedKey = failedLora.hash || failedLora.id || failedLora.modelVersionId;
|
||||||
|
if (!failedKey || !this.pendingMissingByRecipe) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let marked = 0;
|
||||||
|
for (const { recipe, missingLoras } of this.pendingMissingByRecipe.values()) {
|
||||||
|
const recipeId = extractRecipeId(recipe.file_path) || recipe.id;
|
||||||
|
if (!recipeId || !Array.isArray(recipe.loras)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const entry of missingLoras) {
|
||||||
|
const entryKey = entry.hash || entry.id || entry.modelVersionId;
|
||||||
|
if (entryKey !== failedKey) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const loraIndex = recipe.loras.indexOf(entry);
|
||||||
|
if (loraIndex < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/recipe/lora/mark-hash-invalid', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipe_id: recipeId,
|
||||||
|
lora_index: loraIndex,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
entry.hashInvalid = true;
|
||||||
|
marked++;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to mark LoRA hash invalid:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return marked;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get LoRA root directory from API
|
* Get LoRA root directory from API
|
||||||
* @returns {Promise<string|null>} - LoRA root directory or null
|
* @returns {Promise<string|null>} - LoRA root directory or null
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
import { showToast, showActionToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
|
import { DOWNLOAD_ENDPOINTS } from '../api/apiConfig.js';
|
||||||
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { MODEL_SUBTYPE_DISPLAY_NAMES } from '../utils/constants.js';
|
||||||
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
|
import { buildCivitaiUrl, extractCivitaiModelUrlParts, normalizeCivitaiPageHost } from '../utils/civitaiUtils.js';
|
||||||
import { formatFileSize } from '../utils/formatters.js';
|
import { formatFileSize } from '../utils/formatters.js';
|
||||||
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
import { showDownloadBatchSummary } from '../components/DownloadBatchSummaryModal.js';
|
||||||
|
import { openOtherModelsSettings } from '../utils/otherModels.js';
|
||||||
|
|
||||||
export class DownloadManager {
|
export class DownloadManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -489,8 +492,9 @@ export class DownloadManager {
|
|||||||
return { type: 'civitai' };
|
return { type: 'civitai' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hugging Face resolve URL → direct file
|
// Hugging Face resolve/blob URL → direct file
|
||||||
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/resolve\/([^/\s]+)\/(.+)/i);
|
// "blob" is the web preview page; it maps 1:1 to the "resolve" download URL
|
||||||
|
const hfResolveMatch = trimmed.match(/huggingface\.co\/([^/\s]+\/[^/\s]+)\/(?:resolve|blob)\/([^/\s]+)\/(.+)/i);
|
||||||
if (hfResolveMatch) {
|
if (hfResolveMatch) {
|
||||||
return {
|
return {
|
||||||
type: 'hf-resolve',
|
type: 'hf-resolve',
|
||||||
@@ -953,17 +957,18 @@ export class DownloadManager {
|
|||||||
async proceedToLocationContent() {
|
async proceedToLocationContent() {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const _isDiffusionModel = this.selectedFile
|
this._isDiffusionModel = await this._resolveIsDiffusionModel();
|
||||||
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
this._otherSubType = await this._resolveOtherSubType();
|
||||||
: (this.currentVersion?.files || []).some(
|
|
||||||
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
|
||||||
);
|
|
||||||
this._isDiffusionModel = _isDiffusionModel;
|
|
||||||
|
|
||||||
let rootsData;
|
let rootsData;
|
||||||
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
if (this._isDiffusionModel && this.apiClient.modelType === 'checkpoints') {
|
||||||
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
rootsData = await this.apiClient.fetchModelRoots('diffusion_model');
|
||||||
|
} else if (this.apiClient.modelType === 'other' && this._otherSubType) {
|
||||||
|
rootsData = await this.apiClient.fetchModelRoots(this._otherSubType);
|
||||||
} else {
|
} else {
|
||||||
|
// An undecidable other sub_type (null) intentionally lands
|
||||||
|
// here: fetchModelRoots() lists all other roots so the user
|
||||||
|
// can pick manually.
|
||||||
rootsData = await this.apiClient.fetchModelRoots();
|
rootsData = await this.apiClient.fetchModelRoots();
|
||||||
}
|
}
|
||||||
const modelRoot = document.getElementById('modelRoot');
|
const modelRoot = document.getElementById('modelRoot');
|
||||||
@@ -971,19 +976,29 @@ export class DownloadManager {
|
|||||||
`<option value="${root}">${root}</option>`
|
`<option value="${root}">${root}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
const singularType = this._isDiffusionModel
|
let defaultRoot;
|
||||||
? 'unet'
|
let subtypeDisplay;
|
||||||
: this.apiClient.modelType.replace(/s$/, '');
|
if (this.apiClient.modelType === 'other') {
|
||||||
const defaultRootKey = `default_${singularType}_root`;
|
const otherDefaultRoots = state.global.settings.default_other_roots || {};
|
||||||
const defaultRoot = state.global.settings[defaultRootKey];
|
defaultRoot = this._otherSubType ? (otherDefaultRoots[this._otherSubType] || '') : '';
|
||||||
console.log(`Default root for ${singularType}:`, defaultRoot);
|
subtypeDisplay = this._otherSubType
|
||||||
|
? (MODEL_SUBTYPE_DISPLAY_NAMES[this._otherSubType] || this._otherSubType)
|
||||||
|
: this.apiClient.apiConfig.config.displayName;
|
||||||
|
} else {
|
||||||
|
const singularType = this._isDiffusionModel
|
||||||
|
? 'unet'
|
||||||
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
|
const defaultRootKey = `default_${singularType}_root`;
|
||||||
|
defaultRoot = state.global.settings[defaultRootKey];
|
||||||
|
subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
||||||
|
}
|
||||||
|
console.log('Default root:', defaultRoot);
|
||||||
console.log('Available roots:', rootsData.roots);
|
console.log('Available roots:', rootsData.roots);
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
console.log(`Setting default root: ${defaultRoot}`);
|
console.log(`Setting default root: ${defaultRoot}`);
|
||||||
modelRoot.value = defaultRoot;
|
modelRoot.value = defaultRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const subtypeDisplay = this._isDiffusionModel ? 'Diffusion Model' : this.apiClient.apiConfig.config.displayName;
|
|
||||||
document.getElementById('modelRootLabel').textContent =
|
document.getElementById('modelRootLabel').textContent =
|
||||||
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
translate('modals.download.selectTypeRoot', { type: subtypeDisplay });
|
||||||
|
|
||||||
@@ -1019,6 +1034,109 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether this download routes to the diffusion model (unet)
|
||||||
|
* roots rather than the checkpoint roots. The backend owns the routing
|
||||||
|
* rule (file type first, baseModel fallback), so the location step asks
|
||||||
|
* it; if the endpoint is unavailable we degrade to the local file-type
|
||||||
|
* signal, which matches the backend for well-annotated models.
|
||||||
|
*/
|
||||||
|
async _resolveIsDiffusionModel() {
|
||||||
|
const localFileTypeCheck = this.selectedFile
|
||||||
|
? (this.selectedFile.type === 'UNet' || this.selectedFile.type === 'Diffusion Model')
|
||||||
|
: (this.currentVersion?.files || []).some(
|
||||||
|
f => f.type === 'UNet' || f.type === 'Diffusion Model'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Only checkpoint downloads can route to the diffusion model roots;
|
||||||
|
// without version metadata (e.g. Hugging Face downloads) the local
|
||||||
|
// signal is all we have.
|
||||||
|
if (this.apiClient.modelType !== 'checkpoints'
|
||||||
|
|| (!this.selectedFile && !this.currentVersion)) {
|
||||||
|
return localFileTypeCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileTypes = this.selectedFile
|
||||||
|
? [this.selectedFile.type]
|
||||||
|
: (this.currentVersion?.files || []).map(f => f.type);
|
||||||
|
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_type: 'checkpoint',
|
||||||
|
base_model: this.currentVersion?.baseModel || '',
|
||||||
|
file_types: fileTypes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`routing endpoint returned ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (typeof data.is_diffusion_model === 'boolean') {
|
||||||
|
return data.is_diffusion_model;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[download] routing endpoint unavailable, '
|
||||||
|
+ 'falling back to local file-type check:', error);
|
||||||
|
}
|
||||||
|
return localFileTypeCheck;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve which other-page sub_type (vae/upscaler/text_encoder/
|
||||||
|
* clip_vision/controlnet) this download routes to. The backend owns the
|
||||||
|
* routing rule (explicit file pick first, model.type next, file.type
|
||||||
|
* fallback), so the location step sends both the picked file's type
|
||||||
|
* (selected_file_type) and the version's full file-type list and lets
|
||||||
|
* the backend apply its priority chain. Returns null when the sub_type
|
||||||
|
* cannot be decided; the location step then lists all other roots for
|
||||||
|
* manual selection instead of guessing a folder.
|
||||||
|
*/
|
||||||
|
async _resolveOtherSubType() {
|
||||||
|
// Only other-page downloads route by sub_type; without version
|
||||||
|
// metadata (e.g. Hugging Face downloads) there is nothing to route on.
|
||||||
|
if (this.apiClient.modelType !== 'other'
|
||||||
|
|| (!this.selectedFile && !this.currentVersion)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileTypes = (this.currentVersion?.files || []).map(f => f.type);
|
||||||
|
const response = await fetch(DOWNLOAD_ENDPOINTS.routing, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model_type: 'other',
|
||||||
|
base_model: this.currentVersion?.baseModel || '',
|
||||||
|
file_types: fileTypes,
|
||||||
|
...(this.selectedFile
|
||||||
|
? { selected_file_type: this.selectedFile.type }
|
||||||
|
: {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`routing endpoint returned ${response.status}`);
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.disabled) {
|
||||||
|
// The matching sub_type (or the whole Other Models feature) is
|
||||||
|
// switched off: auto-routing is refused, so offer the settings
|
||||||
|
// shortcut while the user's intent is clear.
|
||||||
|
showActionToast('other.disabled.downloadBlocked', {}, 'warning', {
|
||||||
|
actionText: translate('other.disabled.enableAction', {}, 'Enable Other Models'),
|
||||||
|
onAction: () => openOtherModelsSettings(),
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.sub_type || null;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[download] other routing endpoint unavailable, '
|
||||||
|
+ 'falling back to manual root selection:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
loadDefaultPathSetting() {
|
loadDefaultPathSetting() {
|
||||||
const modelType = this.apiClient.modelType;
|
const modelType = this.apiClient.modelType;
|
||||||
const storageKey = `use_default_path_${modelType}`;
|
const storageKey = `use_default_path_${modelType}`;
|
||||||
@@ -2402,9 +2520,14 @@ export class DownloadManager {
|
|||||||
const singularType = this._isDiffusionModel
|
const singularType = this._isDiffusionModel
|
||||||
? 'unet'
|
? 'unet'
|
||||||
: this.apiClient.modelType.replace(/s$/, '');
|
: this.apiClient.modelType.replace(/s$/, '');
|
||||||
const templates = state.global.settings.download_path_templates;
|
const templates = state.global?.settings?.download_path_templates;
|
||||||
const template = templates[singularType];
|
const template = templates?.[singularType];
|
||||||
fullPath += `/${template}`;
|
// An empty or absent template means a flat layout: keep the
|
||||||
|
// root as-is instead of appending "/undefined" or a
|
||||||
|
// dangling slash.
|
||||||
|
if (template) {
|
||||||
|
fullPath += `/${template}`;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch template:', error);
|
console.error('Failed to fetch template:', error);
|
||||||
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
||||||
|
|||||||
@@ -805,7 +805,7 @@ export class FilterManager {
|
|||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
}
|
}
|
||||||
@@ -904,7 +904,7 @@ export class FilterManager {
|
|||||||
// Reload data using the appropriate method for the current page
|
// Reload data using the appropriate method for the current page
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
await window.recipeManager.loadRecipes(true);
|
await window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') {
|
||||||
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
await getModelApiClient().loadMoreWithVirtualScroll(true, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -347,6 +347,19 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register rematchOptionsModal
|
||||||
|
const rematchOptionsModal = document.getElementById('rematchOptionsModal');
|
||||||
|
if (rematchOptionsModal) {
|
||||||
|
this.registerModal('rematchOptionsModal', {
|
||||||
|
element: rematchOptionsModal,
|
||||||
|
onClose: () => {
|
||||||
|
this.getModal('rematchOptionsModal').element.style.display = 'none';
|
||||||
|
document.body.classList.remove('modal-open');
|
||||||
|
},
|
||||||
|
closeOnOutsideClick: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', this.boundHandleEscape);
|
document.addEventListener('keydown', this.boundHandleEscape);
|
||||||
this.initialized = true;
|
this.initialized = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ class MoveManager {
|
|||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
const apiClient = this._getApiClient(modelType);
|
const apiClient = this._getApiClient(modelType);
|
||||||
const currentPageType = state.currentPageType;
|
|
||||||
const modelConfig = apiClient.apiConfig.config;
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
// Handle bulk mode
|
// Handle bulk mode
|
||||||
@@ -113,7 +112,7 @@ class MoveManager {
|
|||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
// Set default root if available
|
// Set default root if available
|
||||||
const settingsKey = `default_${currentPageType.slice(0, -1)}_root`;
|
const settingsKey = `default_${modelConfig.singularName}_root`;
|
||||||
const defaultRoot = state.global.settings[settingsKey];
|
const defaultRoot = state.global.settings[settingsKey];
|
||||||
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
if (defaultRoot && rootsData.roots.includes(defaultRoot)) {
|
||||||
modelRootSelect.value = defaultRoot;
|
modelRootSelect.value = defaultRoot;
|
||||||
@@ -227,15 +226,13 @@ class MoveManager {
|
|||||||
|
|
||||||
if (modelRoot) {
|
if (modelRoot) {
|
||||||
if (this.useDefaultPath) {
|
if (this.useDefaultPath) {
|
||||||
// Show actual template path
|
// Show actual template path; an empty/absent template means a
|
||||||
try {
|
// flat layout, so keep the root as-is.
|
||||||
const singularType = apiClient.modelType.replace(/s$/, '');
|
const singularType = config.singularName || apiClient.modelType.replace(/s$/, '');
|
||||||
const templates = state.global.settings.download_path_templates;
|
const templates = state.global?.settings?.download_path_templates;
|
||||||
const template = templates[singularType];
|
const template = templates?.[singularType];
|
||||||
|
if (template) {
|
||||||
fullPath += `/${template}`;
|
fullPath += `/${template}`;
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch template:', error);
|
|
||||||
fullPath += '/' + translate('modals.download.autoOrganizedPath');
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Show manual path selection
|
// Show manual path selection
|
||||||
@@ -329,7 +326,11 @@ class MoveManager {
|
|||||||
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
const results = await apiClient.moveBulkModels(this.bulkFilePaths, targetPath, this.useDefaultPath);
|
||||||
movedFiles = (results || [])
|
movedFiles = (results || [])
|
||||||
.filter(r => r.success)
|
.filter(r => r.success)
|
||||||
.map(r => ({ original_file_path: r.original_file_path, new_file_path: r.new_file_path }));
|
.map(r => ({
|
||||||
|
original_file_path: r.original_file_path,
|
||||||
|
new_file_path: r.new_file_path,
|
||||||
|
sub_type: r.cache_entry?.sub_type
|
||||||
|
}));
|
||||||
|
|
||||||
// Deselect moving items and exit bulk mode
|
// Deselect moving items and exit bulk mode
|
||||||
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
this.bulkFilePaths.forEach(path => bulkManager.deselectItem(path));
|
||||||
@@ -340,7 +341,11 @@ class MoveManager {
|
|||||||
if (result) {
|
if (result) {
|
||||||
movedFiles.push({
|
movedFiles.push({
|
||||||
original_file_path: result.original_file_path || this.currentFilePath,
|
original_file_path: result.original_file_path || this.currentFilePath,
|
||||||
new_file_path: result.new_file_path
|
new_file_path: result.new_file_path,
|
||||||
|
// The backend recalculates location-derived fields
|
||||||
|
// (e.g. checkpoint -> diffusion_model) during the move;
|
||||||
|
// carry them so the card re-renders with the new type.
|
||||||
|
sub_type: result.cache_entry?.sub_type
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,24 +384,28 @@ class MoveManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (stillVisible) {
|
if (stillVisible) {
|
||||||
|
const newData = {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: newRelativeFolder
|
||||||
|
};
|
||||||
|
if (moved.sub_type) newData.sub_type = moved.sub_type;
|
||||||
pathsToUpdate.push({
|
pathsToUpdate.push({
|
||||||
originalPath: moved.original_file_path,
|
originalPath: moved.original_file_path,
|
||||||
newData: {
|
newData
|
||||||
file_path: moved.new_file_path,
|
|
||||||
folder: newRelativeFolder
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
pathsToRemove.push(moved.original_file_path);
|
pathsToRemove.push(moved.original_file_path);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No folder filter active — items remain visible, just update path
|
// No folder filter active — items remain visible, just update path
|
||||||
|
const newData = {
|
||||||
|
file_path: moved.new_file_path,
|
||||||
|
folder: this._getRelativeFolder(moved.new_file_path)
|
||||||
|
};
|
||||||
|
if (moved.sub_type) newData.sub_type = moved.sub_type;
|
||||||
pathsToUpdate.push({
|
pathsToUpdate.push({
|
||||||
originalPath: moved.original_file_path,
|
originalPath: moved.original_file_path,
|
||||||
newData: {
|
newData
|
||||||
file_path: moved.new_file_path,
|
|
||||||
folder: this._getRelativeFolder(moved.new_file_path)
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { modalManager } from './ModalManager.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the recipe-rematch options modal (rematchOptionsModal), shown BEFORE
|
||||||
|
* a global/bulk/single rematch run; collects the "relaxed matching" opt-in
|
||||||
|
* and only then invokes the run callback. Post-run reporting lives in
|
||||||
|
* static/js/components/RematchSummaryModal.js.
|
||||||
|
*/
|
||||||
|
export class RematchModalManager {
|
||||||
|
constructor() {
|
||||||
|
this._optionsConfirmCallback = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the options modal. `onConfirm({ relaxed })` fires only when the
|
||||||
|
* user clicks Rematch — Cancel/X runs nothing.
|
||||||
|
*
|
||||||
|
* @param {{ scope?: 'global'|'bulk'|'single', recipeCount?: number|null, onConfirm?: function }} options
|
||||||
|
*/
|
||||||
|
showOptionsModal({ scope = null, recipeCount = null, onConfirm } = {}) {
|
||||||
|
const resolvedScope = scope || (recipeCount != null ? 'bulk' : 'global');
|
||||||
|
const message = document.getElementById('rematchOptionsMessage');
|
||||||
|
if (message) {
|
||||||
|
if (resolvedScope === 'bulk') {
|
||||||
|
message.textContent = translate(
|
||||||
|
'modals.rematchOptions.messageBulk',
|
||||||
|
{ count: recipeCount },
|
||||||
|
`${recipeCount} selected recipe(s) will be scanned against your local model library.`
|
||||||
|
);
|
||||||
|
} else if (resolvedScope === 'single') {
|
||||||
|
message.textContent = translate(
|
||||||
|
'modals.rematchOptions.messageSingle',
|
||||||
|
{},
|
||||||
|
'This recipe will be scanned against your local model library.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
message.textContent = translate(
|
||||||
|
'modals.rematchOptions.messageGlobal',
|
||||||
|
{},
|
||||||
|
'All recipes will be scanned against your local model library.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||||
|
if (checkbox) {
|
||||||
|
checkbox.checked = false;
|
||||||
|
}
|
||||||
|
this._optionsConfirmCallback = typeof onConfirm === 'function' ? onConfirm : null;
|
||||||
|
modalManager.showModal('rematchOptionsModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmOptions() {
|
||||||
|
const checkbox = document.getElementById('rematchOptionsRelaxed');
|
||||||
|
const relaxed = checkbox ? !!checkbox.checked : false;
|
||||||
|
const callback = this._optionsConfirmCallback;
|
||||||
|
this._optionsConfirmCallback = null;
|
||||||
|
modalManager.closeModal('rematchOptionsModal');
|
||||||
|
if (callback) {
|
||||||
|
// Returned so callers (and tests) can await the started run.
|
||||||
|
return callback({ relaxed });
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelOptions() {
|
||||||
|
this._optionsConfirmCallback = null;
|
||||||
|
modalManager.closeModal('rematchOptionsModal');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rematchModalManager = new RematchModalManager();
|
||||||
@@ -298,7 +298,7 @@ export class SearchManager {
|
|||||||
pageState.searchOptions.loraName = options.loraName || false;
|
pageState.searchOptions.loraName = options.loraName || false;
|
||||||
pageState.searchOptions.loraModel = options.loraModel || false;
|
pageState.searchOptions.loraModel = options.loraModel || false;
|
||||||
pageState.searchOptions.prompt = options.prompt || false;
|
pageState.searchOptions.prompt = options.prompt || false;
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'checkpoints' || this.currentPage === 'embeddings' || this.currentPage === 'other') {
|
||||||
// Update only the relevant fields in searchOptions instead of replacing the whole object
|
// Update only the relevant fields in searchOptions instead of replacing the whole object
|
||||||
pageState.searchOptions.filename = options.filename || false;
|
pageState.searchOptions.filename = options.filename || false;
|
||||||
pageState.searchOptions.modelname = options.modelname || false;
|
pageState.searchOptions.modelname = options.modelname || false;
|
||||||
@@ -311,7 +311,7 @@ export class SearchManager {
|
|||||||
// Call the appropriate manager's load method based on page type
|
// Call the appropriate manager's load method based on page type
|
||||||
if (this.currentPage === 'recipes' && window.recipeManager) {
|
if (this.currentPage === 'recipes' && window.recipeManager) {
|
||||||
window.recipeManager.loadRecipes(true);
|
window.recipeManager.loadRecipes(true);
|
||||||
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints') {
|
} else if (this.currentPage === 'loras' || this.currentPage === 'embeddings' || this.currentPage === 'checkpoints' || this.currentPage === 'other') {
|
||||||
// For models page, reset the page and reload
|
// For models page, reset the page and reload
|
||||||
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
getModelApiClient().loadMoreWithVirtualScroll(true, false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1153,6 +1153,10 @@ export class SettingsManager {
|
|||||||
// Load default unet root
|
// Load default unet root
|
||||||
await this.loadUnetRoots();
|
await this.loadUnetRoots();
|
||||||
|
|
||||||
|
// Load default other-model roots (per sub_type)
|
||||||
|
await this.loadOtherRoots();
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
|
||||||
// Load extra folder paths
|
// Load extra folder paths
|
||||||
this.loadExtraFolderPaths();
|
this.loadExtraFolderPaths();
|
||||||
|
|
||||||
@@ -1658,6 +1662,51 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadOtherRoots() {
|
||||||
|
const selects = document.querySelectorAll('select[data-other-root-subtype]');
|
||||||
|
if (!selects.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch other-model roots grouped by sub_type
|
||||||
|
const response = await fetch('/api/lm/other/roots_by_subtype');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch other model roots');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const groupedRoots = data.roots_by_subtype || {};
|
||||||
|
const defaultRoots = state.global.settings.default_other_roots || {};
|
||||||
|
|
||||||
|
selects.forEach((select) => {
|
||||||
|
const subType = select.dataset.otherRootSubtype;
|
||||||
|
const roots = groupedRoots[subType] || [];
|
||||||
|
if (!roots.length) {
|
||||||
|
this.showNoRootsPlaceholder(select);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.innerHTML = '';
|
||||||
|
select.disabled = false;
|
||||||
|
|
||||||
|
// Add options for each root
|
||||||
|
roots.forEach(root => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = root;
|
||||||
|
option.textContent = root;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
const defaultRoot = defaultRoots[subType] || '';
|
||||||
|
select.value = roots.includes(defaultRoot) ? defaultRoot : roots[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading other model roots:', error);
|
||||||
|
selects.forEach((select) => this.showNoRootsPlaceholder(select));
|
||||||
|
showToast('toast.settings.otherRootsFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadEmbeddingRoots() {
|
async loadEmbeddingRoots() {
|
||||||
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
const defaultEmbeddingRootSelect = document.getElementById('defaultEmbeddingRoot');
|
||||||
if (!defaultEmbeddingRootSelect) return;
|
if (!defaultEmbeddingRootSelect) return;
|
||||||
@@ -2256,6 +2305,16 @@ export class SettingsManager {
|
|||||||
await this.updateBackupStatus();
|
await this.updateBackupStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (settingKey === 'enable_other_models') {
|
||||||
|
// Roots only exist while the feature is on, so re-fetch them
|
||||||
|
// after the backend rebuilt the other-model root set.
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
await this.loadOtherRoots();
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
this.updateOtherModelsNavVisibility(value);
|
||||||
|
this.removeOtherModelsAnnouncement(value);
|
||||||
|
}
|
||||||
|
|
||||||
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
showToast('toast.settings.settingsUpdated', { setting: settingKey.replace(/_/g, ' ') }, 'success');
|
||||||
|
|
||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
@@ -2339,6 +2398,103 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save one sub_type entry of the default_other_roots dict setting
|
||||||
|
* (read-modify-write: the backend stores the whole mapping).
|
||||||
|
*/
|
||||||
|
async saveOtherRootSetting(subType, value) {
|
||||||
|
try {
|
||||||
|
const defaultRoots = { ...(state.global.settings.default_other_roots || {}) };
|
||||||
|
if (value) {
|
||||||
|
defaultRoots[subType] = value;
|
||||||
|
} else {
|
||||||
|
delete defaultRoots[subType];
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveSetting('default_other_roots', defaultRoots);
|
||||||
|
|
||||||
|
showToast('toast.settings.settingsUpdated', { setting: `default ${subType} root` }, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reflect the opt-in Other Models state in the settings UI: the master
|
||||||
|
* toggle gates every sub_type checkbox, and a switched-off sub_type has
|
||||||
|
* its default-root select disabled. Never force-enables a select (the
|
||||||
|
* no-roots placeholder owns that state).
|
||||||
|
*/
|
||||||
|
updateOtherModelsControls() {
|
||||||
|
const enableOtherModels = !!state.global.settings.enable_other_models;
|
||||||
|
const enabledSubTypes = new Set(
|
||||||
|
state.global.settings.enabled_other_sub_types
|
||||||
|
|| ['vae', 'upscaler', 'text_encoder']
|
||||||
|
);
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-other-subtype-toggle]').forEach((input) => {
|
||||||
|
input.checked = enabledSubTypes.has(input.value);
|
||||||
|
input.disabled = !enableOtherModels;
|
||||||
|
});
|
||||||
|
|
||||||
|
const container = document.getElementById('otherSubTypeToggles');
|
||||||
|
if (container) {
|
||||||
|
container.classList.toggle('is-disabled', !enableOtherModels);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('select[data-other-root-subtype]').forEach((select) => {
|
||||||
|
const subType = select.dataset.otherRootSubtype;
|
||||||
|
if (!enableOtherModels || !enabledSubTypes.has(subType)) {
|
||||||
|
select.disabled = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the whole enabled_other_sub_types list (the backend stores an
|
||||||
|
* allow-list) and refresh the per-sub_type default-root selects.
|
||||||
|
*/
|
||||||
|
async saveEnabledOtherSubTypes() {
|
||||||
|
const values = Array.from(
|
||||||
|
document.querySelectorAll('[data-other-subtype-toggle]')
|
||||||
|
)
|
||||||
|
.filter((input) => input.checked)
|
||||||
|
.map((input) => input.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.saveSetting('enabled_other_sub_types', values);
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
await this.loadOtherRoots();
|
||||||
|
this.updateOtherModelsControls();
|
||||||
|
|
||||||
|
showToast('toast.settings.settingsUpdated', { setting: 'other model types' }, 'success');
|
||||||
|
} catch (error) {
|
||||||
|
showToast('toast.settings.settingSaveFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show or hide the Other Models nav entry. The nav is server-rendered, so
|
||||||
|
* toggling the class here keeps it in sync when the switch is flipped from
|
||||||
|
* the settings modal (no reload needed).
|
||||||
|
*/
|
||||||
|
updateOtherModelsNavVisibility(enabled) {
|
||||||
|
const navItem = document.getElementById('otherNavItem');
|
||||||
|
if (navItem) {
|
||||||
|
navItem.classList.toggle('nav-item--hidden', !enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the Other Models announcement banner once the feature is on.
|
||||||
|
*/
|
||||||
|
removeOtherModelsAnnouncement(enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bannerService.removeOtherModelsAnnouncement();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
|
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
|
||||||
* Shared entry point for the settings modal segmented control and the
|
* Shared entry point for the settings modal segmented control and the
|
||||||
@@ -3360,6 +3516,9 @@ export class SettingsManager {
|
|||||||
} else if (this.currentPage === 'embeddings') {
|
} else if (this.currentPage === 'embeddings') {
|
||||||
// Reload the embeddings without updating folders
|
// Reload the embeddings without updating folders
|
||||||
await resetAndReload(false);
|
await resetAndReload(false);
|
||||||
|
} else if (this.currentPage === 'other') {
|
||||||
|
// Reload the other models without updating folders
|
||||||
|
await resetAndReload(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { appCore } from './core.js';
|
||||||
|
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
|
||||||
|
import { createPageControls } from './components/controls/index.js';
|
||||||
|
import { ModelDuplicatesManager } from './components/ModelDuplicatesManager.js';
|
||||||
|
import { MODEL_TYPES } from './api/apiConfig.js';
|
||||||
|
import { initActiveFiltersSync } from './utils/activeFiltersSync.js';
|
||||||
|
|
||||||
|
// Initialize the Other Models page
|
||||||
|
class OtherPageManager {
|
||||||
|
constructor() {
|
||||||
|
// Initialize page controls
|
||||||
|
this.pageControls = createPageControls(MODEL_TYPES.OTHER);
|
||||||
|
|
||||||
|
// Initialize the ModelDuplicatesManager
|
||||||
|
this.duplicatesManager = new ModelDuplicatesManager(this, MODEL_TYPES.OTHER);
|
||||||
|
|
||||||
|
// Expose only necessary functions to global scope
|
||||||
|
this._exposeRequiredGlobalFunctions();
|
||||||
|
}
|
||||||
|
|
||||||
|
_exposeRequiredGlobalFunctions() {
|
||||||
|
// Minimal set of functions that need to remain global
|
||||||
|
window.confirmDelete = confirmDelete;
|
||||||
|
window.closeDeleteModal = closeDeleteModal;
|
||||||
|
window.confirmExclude = confirmExclude;
|
||||||
|
window.closeExcludeModal = closeExcludeModal;
|
||||||
|
|
||||||
|
// Expose duplicates manager
|
||||||
|
window.modelDuplicatesManager = this.duplicatesManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
// Initialize common page features (including context menus)
|
||||||
|
appCore.initializePageFeatures();
|
||||||
|
|
||||||
|
// Mirror active filters to the backend for the ComfyUI-side autocomplete
|
||||||
|
initActiveFiltersSync(MODEL_TYPES.OTHER);
|
||||||
|
|
||||||
|
console.log('Other Models Manager initialized');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeOtherPage() {
|
||||||
|
// Initialize core application
|
||||||
|
await appCore.initialize();
|
||||||
|
|
||||||
|
// Initialize other models page
|
||||||
|
const otherPage = new OtherPageManager();
|
||||||
|
await otherPage.initialize();
|
||||||
|
|
||||||
|
return otherPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize everything when DOM is ready
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeOtherPage);
|
||||||
|
|
||||||
|
export { OtherPageManager, initializeOtherPage };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { appCore } from './core.js';
|
||||||
|
import { showToast } from './utils/uiHelpers.js';
|
||||||
|
import { enableOtherModels, openOtherModelsSettings } from './utils/otherModels.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Other Models is an opt-in feature. While it is disabled this page renders an
|
||||||
|
* empty state whose button turns the feature on; the backend then rebuilds the
|
||||||
|
* other-model roots and starts scanning, so a reload lands on the real page.
|
||||||
|
*
|
||||||
|
* The same module backs the "enabled but no folders found" state, where the
|
||||||
|
* only useful action is jumping to Settings instead of enabling anything.
|
||||||
|
*/
|
||||||
|
async function handleEnableClick() {
|
||||||
|
const button = document.getElementById('enableOtherModelsBtn');
|
||||||
|
if (!button || button.disabled) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
try {
|
||||||
|
await enableOtherModels();
|
||||||
|
} catch (error) {
|
||||||
|
button.disabled = false;
|
||||||
|
showToast('other.disabled.enableFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Settings on the Library section for the "no folders found" state, so a
|
||||||
|
* misconfigured install can be fixed without hand-editing unknown keys.
|
||||||
|
*/
|
||||||
|
function handleOpenSettingsClick(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
openOtherModelsSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeOtherDisabledPage() {
|
||||||
|
// appCore.initialize() wires the shared header (theme, settings modal,
|
||||||
|
// language) so this page is not a dead end.
|
||||||
|
await appCore.initialize();
|
||||||
|
|
||||||
|
const button = document.getElementById('enableOtherModelsBtn');
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', handleEnableClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsButton = document.getElementById('openOtherModelsSettingsBtn');
|
||||||
|
if (settingsButton) {
|
||||||
|
settingsButton.addEventListener('click', handleOpenSettingsClick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initializeOtherDisabledPage);
|
||||||
|
|
||||||
|
export { handleEnableClick as enableOtherModels, initializeOtherDisabledPage };
|
||||||
@@ -24,6 +24,9 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
default_lora_root: '',
|
default_lora_root: '',
|
||||||
default_checkpoint_root: '',
|
default_checkpoint_root: '',
|
||||||
default_embedding_root: '',
|
default_embedding_root: '',
|
||||||
|
default_other_roots: {},
|
||||||
|
enable_other_models: false,
|
||||||
|
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||||
recipes_path: '',
|
recipes_path: '',
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
@@ -72,6 +75,8 @@ export function createDefaultSettings() {
|
|||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
|
default_other_roots: {},
|
||||||
|
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +84,7 @@ export function createDefaultSettings() {
|
|||||||
const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
|
const loraPreviewVersions = getMapFromStorage('loras_preview_versions');
|
||||||
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
|
const checkpointPreviewVersions = getMapFromStorage('checkpoints_preview_versions');
|
||||||
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
|
const embeddingPreviewVersions = getMapFromStorage('embeddings_preview_versions');
|
||||||
|
const otherPreviewVersions = getMapFromStorage('other_preview_versions');
|
||||||
|
|
||||||
export const state = {
|
export const state = {
|
||||||
// Global state
|
// Global state
|
||||||
@@ -234,6 +240,44 @@ export const state = {
|
|||||||
search: '',
|
search: '',
|
||||||
},
|
},
|
||||||
activeViewSnapshot: null,
|
activeViewSnapshot: null,
|
||||||
|
},
|
||||||
|
|
||||||
|
[MODEL_TYPES.OTHER]: {
|
||||||
|
currentPage: 1,
|
||||||
|
isLoading: false,
|
||||||
|
hasMore: true,
|
||||||
|
sortBy: 'name',
|
||||||
|
activeFolder: getStorageItem(`${MODEL_TYPES.OTHER}_activeFolder`),
|
||||||
|
previewVersions: otherPreviewVersions,
|
||||||
|
searchManager: null,
|
||||||
|
searchOptions: {
|
||||||
|
filename: true,
|
||||||
|
modelname: true,
|
||||||
|
tags: false,
|
||||||
|
creator: false,
|
||||||
|
hash: false,
|
||||||
|
recursive: getStorageItem(`${MODEL_TYPES.OTHER}_recursiveSearch`, true),
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
baseModel: [],
|
||||||
|
tags: {},
|
||||||
|
license: {},
|
||||||
|
modelTypes: [],
|
||||||
|
search: '',
|
||||||
|
tagLogic: 'any',
|
||||||
|
},
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
metadataCache: new Map(),
|
||||||
|
showFavoritesOnly: false,
|
||||||
|
showUpdateAvailableOnly: false,
|
||||||
|
duplicatesMode: false,
|
||||||
|
viewMode: 'active',
|
||||||
|
excludedViewState: {
|
||||||
|
sortBy: 'name:asc',
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
activeViewSnapshot: null,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function syncActiveFilters(pageType) {
|
|||||||
* Register the storage listener and push the current (restored) state once.
|
* Register the storage listener and push the current (restored) state once.
|
||||||
* The initial push covers server restarts, where the backend store is empty
|
* The initial push covers server restarts, where the backend store is empty
|
||||||
* until the manager page re-publishes its localStorage-restored filters.
|
* until the manager page re-publishes its localStorage-restored filters.
|
||||||
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings'
|
* @param {string} pageType - 'loras' | 'checkpoints' | 'embeddings' | 'other'
|
||||||
*/
|
*/
|
||||||
export function initActiveFiltersSync(pageType) {
|
export function initActiveFiltersSync(pageType) {
|
||||||
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
|
setActiveFiltersListener((changedPageType) => syncActiveFilters(changedPageType));
|
||||||
|
|||||||
@@ -106,6 +106,12 @@ export const MODEL_SUBTYPE_DISPLAY_NAMES = {
|
|||||||
diffusion_model: "Diffusion Model",
|
diffusion_model: "Diffusion Model",
|
||||||
// Embedding sub-types
|
// Embedding sub-types
|
||||||
embedding: "Embedding",
|
embedding: "Embedding",
|
||||||
|
// Other model sub-types
|
||||||
|
vae: "VAE",
|
||||||
|
upscaler: "Upscaler",
|
||||||
|
text_encoder: "Text Encoder",
|
||||||
|
clip_vision: "CLIP Vision",
|
||||||
|
controlnet: "ControlNet",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Backward compatibility alias
|
// Backward compatibility alias
|
||||||
@@ -119,6 +125,11 @@ export const MODEL_SUBTYPE_ABBREVIATIONS = {
|
|||||||
checkpoint: "CKPT",
|
checkpoint: "CKPT",
|
||||||
diffusion_model: "DM",
|
diffusion_model: "DM",
|
||||||
embedding: "EMB",
|
embedding: "EMB",
|
||||||
|
vae: "VAE",
|
||||||
|
upscaler: "UPS",
|
||||||
|
text_encoder: "TE",
|
||||||
|
clip_vision: "CV",
|
||||||
|
controlnet: "CN",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getSubTypeAbbreviation(subType) {
|
export function getSubTypeAbbreviation(subType) {
|
||||||
@@ -342,7 +353,11 @@ export const DEFAULT_PATH_TEMPLATES = {
|
|||||||
lora: '{base_model}/{first_tag}',
|
lora: '{base_model}/{first_tag}',
|
||||||
checkpoint: '{base_model}',
|
checkpoint: '{base_model}',
|
||||||
unet: '{base_model}',
|
unet: '{base_model}',
|
||||||
embedding: '{first_tag}'
|
embedding: '{first_tag}',
|
||||||
|
// Other models (VAE/upscaler/...) default to a flat layout: their root is
|
||||||
|
// already split per sub_type, and priority_tags has no "other" entry, so
|
||||||
|
// {first_tag} would resolve to an arbitrary CivitAI tag.
|
||||||
|
other: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// Model type labels for UI
|
// Model type labels for UI
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ async function getCardCreator(pageType) {
|
|||||||
|
|
||||||
// Function to get the appropriate data fetcher based on page type
|
// Function to get the appropriate data fetcher based on page type
|
||||||
async function getDataFetcher(pageType) {
|
async function getDataFetcher(pageType) {
|
||||||
if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints') {
|
if (pageType === 'loras' || pageType === 'embeddings' || pageType === 'checkpoints' || pageType === 'other') {
|
||||||
return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize);
|
return (page = 1, pageSize = 100) => getModelApiClient().fetchModelsPage(page, pageSize);
|
||||||
} else if (pageType === 'recipes') {
|
} else if (pageType === 'recipes') {
|
||||||
// Import the recipeApi module and use the fetchRecipesPage function
|
// Import the recipeApi module and use the fetchRecipesPage function
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Shared helpers for the opt-in Other Models feature.
|
||||||
|
*
|
||||||
|
* Used by the disabled page, the announcement banner and the download modal so
|
||||||
|
* that enabling the feature always goes through the same settings API call and
|
||||||
|
* lands on the same settings section.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn on Other Models management and reload so the server-rendered nav and
|
||||||
|
* the scanner state pick up the change.
|
||||||
|
*/
|
||||||
|
export async function enableOtherModels() {
|
||||||
|
const response = await fetch('/api/lm/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enable_other_models: true }),
|
||||||
|
});
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok || data.success === false) {
|
||||||
|
throw new Error(data.error || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the settings modal on the Library section and scroll the Other Models
|
||||||
|
* toggle into view. Mirrors DoctorManager's open-settings-syntax-format flow.
|
||||||
|
*/
|
||||||
|
export function openOtherModelsSettings() {
|
||||||
|
const modalManager = window.modalManager;
|
||||||
|
if (modalManager && typeof modalManager.showModal === 'function') {
|
||||||
|
modalManager.showModal('settingsModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
document.querySelectorAll('.settings-section').forEach((section) => {
|
||||||
|
section.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.getElementById('section-library')?.classList.add('active');
|
||||||
|
|
||||||
|
document.querySelectorAll('.settings-nav-item').forEach((item) => {
|
||||||
|
item.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelector('.settings-nav-item[data-section="library"]')?.classList.add('active');
|
||||||
|
|
||||||
|
document.getElementById('enableOtherModels')?.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center',
|
||||||
|
});
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ const STORAGE_PREFIX = 'lora_manager_';
|
|||||||
|
|
||||||
// Matches keys that carry the manager page's active filter state
|
// Matches keys that carry the manager page's active filter state
|
||||||
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
|
// (e.g. 'loras_activeFolder', 'checkpoints_filters').
|
||||||
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings)_(activeFolder|recursiveSearch|filters)$/;
|
const ACTIVE_FILTER_KEY_PATTERN = /^(loras|checkpoints|embeddings|other)_(activeFolder|recursiveSearch|filters)$/;
|
||||||
|
|
||||||
let activeFiltersListener = null;
|
let activeFiltersListener = null;
|
||||||
|
|
||||||
|
|||||||
@@ -325,6 +325,23 @@ export function isTypingContext(target) {
|
|||||||
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
return target.isContentEditable || tagName === 'input' || tagName === 'textarea' || tagName === 'select';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide whether a download failure means the model is unrecoverable.
|
||||||
|
*
|
||||||
|
* The hash-invalid flag (and the resulting rematch/reconnect candidacy) is
|
||||||
|
* only set when CivitAI explicitly says the model cannot be resolved — never
|
||||||
|
* for transient transport errors (network, 5xx).
|
||||||
|
* @param {*} message - The error message carried by the failed download
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isUnresolvableDownloadError(message) {
|
||||||
|
if (!message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const text = String(message).toLowerCase();
|
||||||
|
return /(not found|no longer available|deleted|removed|404|410|gone)/.test(text);
|
||||||
|
}
|
||||||
|
|
||||||
export function restoreFolderFilter() {
|
export function restoreFolderFilter() {
|
||||||
const activeFolder = getStorageItem('activeFolder');
|
const activeFolder = getStorageItem('activeFolder');
|
||||||
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
const folderTag = activeFolder && document.querySelector(`.tag[data-folder="${activeFolder}"]`);
|
||||||
|
|||||||
@@ -25,6 +25,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="enrich-hf-llm">
|
||||||
|
<i class="fas fa-wand-magic-sparkles"></i> <span>{{ t('loras.contextMenu.enrichHfAgent') }}</span>
|
||||||
|
</div>
|
||||||
<div class="context-menu-separator menu-section-break"></div>
|
<div class="context-menu-separator menu-section-break"></div>
|
||||||
<!-- Workflow -->
|
<!-- Workflow -->
|
||||||
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
<div class="context-menu-item" data-action="copyname"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyFilename') }}</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user