Compare commits

...

32 Commits

Author SHA1 Message Date
Will Miao 41e9883daa test(recipe): cover send-workflow frontend paths 2026-08-21 21:09:58 +08:00
Will Miao ae461ebc81 fix(registry): replace one %s placeholder per log argument 2026-08-21 21:09:58 +08:00
Will Miao 3ebf256c5d feat(recipe): send embedded recipe workflow to ComfyUI canvas 2026-08-21 21:09:58 +08:00
Will Miao 0905e2be6e fix(recipes): restore primary style on checkpoint Send to ComfyUI button 2026-08-21 10:00:08 +08:00
Will Miao bd380bc1a1 fix(ui): replace stale command abbreviations in autocomplete messages 2026-08-21 09:20:11 +08:00
Will Miao cb4fd3a0e6 refactor(ui): split settings modal into section templates with shared macros 2026-08-21 09:14:52 +08:00
Will Miao bbe0acac5c fix(ui): keep loras widget context menu within viewport bounds 2026-08-21 08:07:50 +08:00
Will Miao 45e7c25308 feat(recipes): redesign import modal with URL-first input and unified drop zone 2026-08-21 00:01:50 +08:00
Will Miao 86aa1d8059 fix(ui): position toasts below header to avoid overlapping page controls 2026-08-20 22:08:27 +08:00
Will Miao 74254756ef fix(ui): unify modal backdrop blur across all modals 2026-08-20 21:25:05 +08:00
Will Miao 259e08e47c feat(download): expose per-file downloadedFiles in check-model-exists (#1058)
The version branch of check-model-exists now returns
downloadedFiles: [{fileId, fileName, filePath}] so clients (e.g. the
browser extension) can tell a partially downloaded version apart from a
fully downloaded one. Reuses ModelCivitaiHandler._match_downloaded_files
(D2 rule) against the local cache; unmatchable local files are reported
with fileId: None. No CivitAI API call added.
2026-08-20 20:59:19 +08:00
Will Miao 6647c45731 fix(download): include file identity in queue/history dedup (#1058)
Distinct files of the same model version queued before a backend restart
were silently collapsed by deduplicate(), which grouped rows by
(model_id, model_version_id) only. Extract the file id from file_params
via json_extract and add it to the dedup key; rows without file identity
keep the old per-version behavior (NULL matches NULL).
2026-08-20 20:58:44 +08:00
Will Miao b614a5c447 docs: remove broken star history chart (#1066) 2026-08-20 20:56:22 +08:00
Will Miao b80830913c refactor(nodes): declare loras widget as LORAS input type on lora nodes 2026-08-20 13:22:11 +08:00
Will Miao e57e11897e refactor(services): share weight-file extension set between rematch and find_matching_models 2026-08-19 21:54:22 +08:00
Will Miao 8a16034135 refactor(services): unify local model name matching with uniqueness and base-model guards (#1065)
Consolidate the duplicate name-matching logic into ModelScanner:
find_matching_models is now the single core, using each scanner's own
file_extensions for suffix stripping. get_model_info_by_name gains
require_unique/base_model kwargs while legacy route behavior is kept
byte-identical. reconnect_lora passes the recipe base model as a guard
and distinguishes ambiguous, base-model-mismatched, and missing LoRAs
in its error messages.
2026-08-19 21:02:04 +08:00
Will Miao 7fc3b7e5be docs: fix star history chart with official token-based embed (#1066) 2026-08-19 20:51:23 +08:00
Aaalice b0c7a1baae Fix recipe parsing for metadata-free local LoRAs (#1065)
* fix(recipes): resolve metadata-free local LoRAs

* fix(recipes): prioritize LoRA hashes over names
2026-08-19 19:07:17 +08:00
Will Miao 6411d83d46 fix(i18n): translate remaining untranslated UI strings 2026-08-19 18:59:55 +08:00
Will Miao 74a063b0e5 fix(i18n): complete translations for per-file download UI (#1058) 2026-08-19 18:53:31 +08:00
Will Miao 96376e5cce fix(download): hide URL step when file dialog opens from versions tab (#1058) 2026-08-19 18:35:16 +08:00
Will Miao e7c26bf722 feat(download): per-file download status and multi-file selection (#1058) 2026-08-19 17:51:31 +08:00
Will Miao cef4129fc9 fix(download): allow downloading additional files of an in-library model version (#1058) 2026-08-19 16:29:59 +08:00
Will Miao 0a28500848 fix(loaders): default control_after_generate to fixed on checkpoint/unet loaders
The previous boolean 'control_after_generate': true defaulted the control
widget to 'randomize', silently changing existing workflows into random
model selection on every queue. A string value sets the default mode, so
'fixed' preserves the prior behavior; users opt into randomization
explicitly.
2026-08-19 10:33:23 +08:00
Will Miao fc3f3f3bdb feat(loaders): add control_after_generate random model selection to checkpoint/unet loaders
The Checkpoint/Unet Loader (LoraManager) nodes now support ComfyUI's
built-in control_after_generate mechanism on the ckpt_name/unet_name combos,
letting users pick a random model on every queue with the selected model
written back into the widget (visible, and lockable via the 'fixed' mode).

A base_model input narrows the random pool: a front-end extension fetches
the name/base_model mapping from the new /api/lm/checkpoints/loader-pool
endpoint and filters the combo options, wired through the node callback,
the refreshComboInNodes extension hook, and a graph.onConfigure hook
installed from onAdded (onNodeCreated fires before the node is attached to
a graph, so the graph reference is unavailable there).
2026-08-19 05:13:51 +08:00
Will Miao fa58297973 fix(ui): stop media viewer Escape from closing underlying modal 2026-08-18 20:51:56 +08:00
Will Miao 5d1a22fb8f fix(ui): ignore internal card drags in model card preview drop (#1034)
Tag move-to-folder drags with a custom dataTransfer MIME type so card
preview-drop handlers skip them entirely (no highlight, no upload), and
mark the preview image non-draggable so the browser no longer synthesizes
a File payload when a drag starts on the image. Fixes card-on-card drops
and click-jitter self-drops replacing the preview with itself.
2026-08-18 20:38:29 +08:00
Will Miao d2f50f26f1 feat(ui): redesign model modal showcase as on-demand gallery 2026-08-18 20:38:29 +08:00
hein 4a6042d0b4 fix: include locally available LoRAs in recipe syntax even if deleted from Civitai (#948)
get_recipe_syntax_tokens() previously skipped all LoRAs with
isDeleted=True unconditionally. Now it tries to resolve the file
locally first (via hash index or modelVersionId); only skips if
the LoRA is truly unavailable.

This is a companion fix to #946 (AutoV2 hash matching).

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-08-18 15:10:53 +08:00
Will Miao 846206d958 fix(ui): add model modal backdrop blur to match recipe modal 2026-08-18 09:09:05 +08:00
Will Miao 0daf4924f0 feat(recipes): redesign recipe detail modal with three-column workspace layout
- Three-column layout (preview | generation parameters | resources) with
  independent per-pane scrolling and a content-sized modal shell that
  shrinks to fit short recipes and caps at viewport height for long ones
- Blurred, darker backdrop to focus attention on the modal
- Preview frame hugs the image instead of a fixed-size box
- Move recipe-level 'Send to ComfyUI' into the header actions row to match
  the model detail modal convention; remove the modal 'Copy Recipe Syntax'
  button (context menu action is unaffected)
- Add recipes.actions.sendRecipe i18n keys with translations
- Sync modal test fixtures to the new structure
2026-08-18 09:09:05 +08:00
willmiao d38a3d091d docs: auto-update supporters list in README 2026-08-16 11:47:29 +00:00
111 changed files with 9331 additions and 3287 deletions
+2 -7
View File
File diff suppressed because one or more lines are too long
@@ -0,0 +1,206 @@
# Plan: Multi-File Downloads Within a Single CivitAI Model Version
**Issue:** [#1058 — Cannot download multiple file variants from the same model version](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1058)
**Status:** v2 — revised after adversarial review (backend correctness + frontend/tests)
**Scope:** CivitAI/CivArchive downloads of `lora`, `checkpoint`, `embedding` model types. HuggingFace downloads are out of scope (already per-file).
> v2 changelog: incorporated 18 review findings. Key changes vs v1:
> shared file resolver + `resolved_version_id` for the gate (R1); `file_params` normalization at API boundary (R2); D2 hash-matching rule fixed for empty-hash cases (R6/R7); D3 extended to re-point `version_index` on removal (R4); D4 replaced with a child table (R3); `delete_model_version` interaction documented (R5); `ModelVersionsTab` surface added to phase 2 (F6); phase-2 multi-file loop requires a reload-deferred download variant (F7); queue-retry `file_params=NULL` known issue recorded (R9); test-fixture gaps and revised estimates (F10).
---
## 1. Problem Statement
A CivitAI model version can contain multiple downloadable weight files (e.g. fp16/fp32, safetensors/ckpt, different sizes). LoRA Manager already has a working file-selection pipeline (frontend file dialog → `fileParams` → backend file matching), but downloaded state is tracked at the **model-version** level. After any single file of a version is downloaded:
1. The version is marked **In Library** and the file-selection entry point disappears.
2. The backend rejects further download attempts for that version.
There is no way to download the remaining files of the same version through LoRA Manager.
## 2. Current State (verified against code; all references confirmed by review)
### 2.1 Download gating — backend (`py/services/download_manager.py`)
`_execute_original_download` enforces two version-level gates:
- **Library gate, early** (lines 11571184, before metadata fetch, fires when `model_version_id` given) and **late** (lines 13501376, fires only when `model_version_id is None`): `scanner.check_model_version_exists(version_id)` across lora/checkpoint/embedding scanners → hard error `"Model version already exists in ... library"`.
- **History gate** (lines 12381279): when `skip_previously_downloaded_model_versions` setting is on, `_has_been_downloaded(model_type, version_id)` → silent skip. History DB primary key is `(model_type, version_id)` (`py/services/downloaded_version_history_service.py:61`).
File selection works: `file_params {id, type, format, size, fp}` is matched against `version_info.files` (lines 14981569), **but only under `if file_params and model_version_id:` (line 1499)** — with `model_id`-only requests the selection silently falls back to the primary file (15711619). `file_params` currently carries no file `name` or hash.
### 2.2 Downloaded-state surfacing — backend (`py/routes/handlers/model_handlers.py`)
`get_civitai_versions` (lines 21482188) sets per-version `existsLocally` via `cache.version_index.get(version_id)` (plus a single `localPath` from that entry) and `hasBeenDownloaded` via the history service. No per-file granularity.
### 2.3 Frontend blockers (`static/js/managers/DownloadManager.js`)
Three independent gates prevent re-entering the file dialog:
1. **Line 598:** file-select badge rendered only when `modelFiles.length > 1 && !existsLocally`.
2. **Lines 666681 (`updateNextButtonState`):** Next button disabled with "Already in Library" when `currentVersion.existsLocally`.
3. **Lines 784787 (`proceedToLocation`):** toast + abort when `currentVersion.existsLocally`.
The badge path (`confirmFileSelection` lines 737759 → `proceedToLocationContent``startDownload` single mode → `executeDownloadWithProgress` → POST `file_params`, `static/js/api/baseModelApi.js:12361250`) has **zero** `existsLocally` guards (all 12 occurrences enumerated; none on this path; `import/DownloadManager.js` has none either). The `.exists-locally` CSS class is purely visual (`download-modal.css:496499`). **Making the badge visible again is sufficient to unlock the flow** for phase 1.
Post-download refresh is clean: the modal closes and `resetAndReload(true)` performs a full library refetch (`DownloadManager.js:1063`); dialog reopen resets state and refetches versions with no client-side cache. No same-session staleness.
### 2.4 Local identity of the downloaded file
`LoraMetadata/CheckpointMetadata/EmbeddingMetadata.from_civitai_info(version_info, file_info, ...)` (`py/utils/models.py:245369`) persists:
- `sha256` = `file_info.hashes.SHA256` (lowercased, defaults to `""`) — a stable per-file identity;
- `civitai` = the full `version_info` payload (including the `files` list).
Metadata refresh (`metadata_sync_service.py:104105`) replaces the `civitai` blob wholesale but never overwrites top-level `sha256`; `verify_duplicate_hashes` (481526) corrects it to the on-disk hash. Top-level-sha256 matching is refresh-robust.
**Caveats (review R6/R7):**
- SHA256 is not guaranteed: CivArchive's transform only sets `hashes` when source data carries it (`civarchive_client.py:185189`); `from_civitai_info` defaults to `""`.
- Name fallback is unreliable exactly when it matters: local `file_name` is extension-less (`models.py:264`) and `generate_unique_filename` rewrites it with a hash suffix on conflict (`download_manager.py:11251136`); checkpoints with `hash_status='pending'` keep empty sha256 until on-demand hashing (`model_scanner.py:12321240`).
### 2.5 Version index collision (pre-existing hazard)
`ModelCache.version_index` is single-valued (`model_cache.py:133`: `version_index[version_id] = item`). Two files of the same version in the library → second entry overwrites the first; `remove_from_version_index` (lines 151181) drops the whole version key when the indexed entry is removed, even if a sibling file remains. ~10 read sites depend on this index (48 grep touch points total; readers include `recipe_scanner.py:26822726`, `recipe_format.py:3740`, `misc_handlers.py:24402444`, `model_handlers.py`, `model_scanner.check_model_version_exists:2444`).
Review correction (F3): bulk paths `remove_models` (`model_scanner.py:2376`) and `update_single_model_cache` (`:1689`) call `rebuild_version_index()` right after, so a sibling re-enters the index in those flows — the hazard is narrower than v1 stated, but direct `remove_from_version_index` callers (e.g. `model_scanner.py:1018`) still drop the key, and the user-visible artifact in phase 1 is real: `localPath` in the dialog flips to whichever file was indexed last.
### 2.6 Entry points that send / don't send `file_params` (fully enumerated by review)
**Send `file_params` (user-initiated dialog flows only):** `DownloadManager.js:16111639` (single mode). API surface accepting arbitrary JSON `file_params`: GET `/api/lm/download-model-get` (`model_handlers.py:16341686`), POST `/api/lm/downloads/queue/add` (`model_handlers.py:17991832`).
**Never send `file_params` (keep version-level semantics):** batch download (`DownloadManager.js:17561766`; batch also filters out in-library versions at `:1648`), `downloadVersionWithDefaults` (`:18101830`), recipe import (`import/DownloadManager.js:269276`), bulk missing-LoRA (`BulkMissingLoraDownloadManager.js:292299`), `RecipeModal.js:17281736`, `ModelVersionsTab.js:1427`. `web/comfyui/` and `vue-widgets/src` contain **no** download triggers at all (grep-verified). `py/services/use_cases/` has only `download_model_use_case.py` (pass-through).
### 2.7 Paths that do NOT need changes (verified)
- **aria2 pause/resume** (`_resume_restored_aria2_download`, line 754+): resumes from persisted `resume_context`; never re-runs existence gates.
- **`download_coordinator.py:90`**: pure pass-through of `file_params`.
- **Update checker / plugin self-update** (`update_routes.py:496501`): only closes the history DB handle.
- **History delete semantics**: `mark_as_deleted` sets `is_deleted_override=1` and `has_been_downloaded` then returns False (`downloaded_version_history_service.py:276`) — LM-initiated deletes already reset the history skip.
### 2.8 Related pre-existing issues (record, not necessarily fix)
- **Queue retry drops file selection** (R9): `download_queue_service.retry_from_history` / `retry_all_failed` re-queue with `file_params=NULL` (`download_queue_service.py:705, 758`) although the queue table has a `file_params` column (`:43`) — a retried non-primary download silently reverts to the primary file. Fix alongside phase 1 (small: persist and reuse the column).
- **`delete_model_version`** (`misc_handlers.py:24102487`): resolves the file via the single-valued `version_index` (24402444), deletes only that one file, and `mark_as_deleted` flags the **entire version** as deleted in history (2479) even when a sibling file remains in the library. See phase 2 item 6.1.5.
## 3. Goals / Non-Goals
**Goals**
- G1: A user can download any not-yet-downloaded file of a version already partially in the library (issue repro steps 68).
- G2: True duplicates stay blocked: downloading the *same* file of the same version twice is rejected.
- G3: Per-file downloaded state visible in the file dialog; multiple files selectable and downloadable in one pass.
- G4: No regression for version-level semantics relied on by batch download, recipe missing-LoRA detection, and `skip_previously_downloaded_model_versions`.
**Non-Goals**
- No change to recipe `inLibrary` semantics ("any file of the version present" remains sufficient).
- No change to the update-checker (version-level comparison).
- No primary-key rebuild of the history database.
- HuggingFace download flow untouched.
## 4. Design Decisions
- **D1 — Explicit file selection bypasses the history gate, version-level gates stay for everyone else.** The history skip exists to dedupe automated flows. A user explicitly picking a file is unambiguous intent; the file-level library gate (G2) still prevents real duplicates. **Guard conditions use normalized truthiness** (see D1a). All confirmed `file_params` senders are user-initiated dialog flows (2.6), and LM-initiated deletes already reset history (2.7), so the bypass only affects "downloaded but not LM-deleted" versions with the setting on — intended.
- **D1a — `file_params` normalization at the boundary (R2).** `download-model-get` and `downloads/queue/add` accept arbitrary JSON; `{}` is `not None` but falsy and would bypass gates while downloading the primary file. Normalize `file_params = file_params or None` in the coordinator/handlers, and treat the bypass as active only when a target file id is resolvable.
- **D2 — File identity matching rule (R6/R7):** hash-compare **only when both sides are non-empty** (lowercase SHA256 equality); name-compare when either side is empty. Never let `"" == ""` match. Name fallback caveats from 2.4 apply (renamed files, pending checkpoint hashes) — acceptable residual risk, worst case is a blocked re-download the user can retry after hashing completes.
- **D3 — Cache indexes: additive multi-index + removal re-pointing (R4).** Add `version_files_index: Dict[int, List[dict]]` maintained alongside `version_index` by the same add/remove/rebuild methods; existing readers of `version_index` untouched. Additionally fix `remove_from_version_index`: when the popped entry has a surviving sibling (per the multi-index), re-point `version_index[version_id]` to the sibling instead of dropping the key; same for the `model_id_index` descriptor. This closes the 2.5 hazard for existing readers (`check_model_version_exists`, `existsLocally`, recipe matching) without restructuring anything.
- **D4 — Per-file history via a child table (R3).** v1's additive-column approach is structurally impossible on a `(model_type, version_id)` PK (`ON CONFLICT DO UPDATE` would keep only the last file). Instead add `downloaded_version_files(model_type, version_id, file_id, file_name, downloaded_at, PRIMARY KEY(model_type, version_id, file_id))` — additive, no PK rebuild, honors the Non-Goal. Existing version-level table and queries unchanged. New per-file queries are opt-in. `_initialize_schema` uses `CREATE TABLE IF NOT EXISTS`, so the new table is created for existing DBs without any ALTER.
- **D5 — UI flow reuse, with an extracted inner download function for multi-file (F7).** Phase 1 unlocks the existing badge → file dialog → location → download pipeline. Phase 2 upgrades the dialog to multi-select; iterating `executeDownloadWithProgress` as-is would produce N full library reloads, N toasts, and competing failure-summary modals — so phase 2 extracts a reload-deferred, failure-aggregating inner variant and runs one reload + one summary at the end.
## 5. Implementation — Phase 1 (fix the issue; independently shippable)
### 5.1 Backend — `py/services/download_manager.py`
1. **Normalize `file_params`** at the boundary (D1a): `download_coordinator.schedule_download` and the two API handlers (`model_handlers.py:16491666`, `18101832`) apply `file_params = file_params or None`.
2. **Extract a shared file resolver** (R1): pull the matching logic at 14981569 into `_resolve_target_file(version_info, file_params) -> Optional[dict]`, used by **both** the new gate and the download-selection path. The selection path's condition (line 1499) switches from `model_version_id` to `resolved_version_id` (already computed at 12301236 from `version_info.id`), so gate and download always agree on the target file — including the `model_id`-only case.
3. **New helper** `_find_local_file_entry(version_id, target_file) -> Optional[dict]`: iterate the three scanners' cached `raw_data` (NOT `version_index` — single-valued); candidates = entries whose `civitai.id` normalizes to `version_id`; match per D2.
4. **Gate restructure in `_execute_original_download`**:
- Early scanner gate (11571184): add `file_params is None` guard; with normalized `file_params`, defer (file identity not resolvable before metadata fetch).
- After `version_info` fetch + `resolved_version_id` (~1229): when `file_params` present, resolve target file via the shared resolver; unresolvable → hard error "No matching file" (fail closed, prevents empty-dict bypass). Resolvable → `_find_local_file_entry`; hit → same hard error shape as today with the file name in the message.
- History gate (12381279): add `file_params is None` (D1). Base-model skip (12811324) unchanged — still applies.
- Late gate (13501376): add `file_params is None` guard (F2) — the post-fetch file-level check above already covers this case.
- Nothing between the early gate and the post-fetch point assumes the version is absent (review task 6: only provider selection + metadata fetch; no DB writes; `_persist_aria2_state` runs only when actually downloading at 1659).
5. **Queue retry fix** (2.8, small): persist `file_params` into the queue table on enqueue and reuse it in `retry_from_history` / `retry_all_failed`.
6. Logging: `[download]` lines for file-level allow/block, consistent with existing style.
**Estimated:** ~150220 LOC + resolver extraction.
### 5.2 Frontend — `static/js/managers/DownloadManager.js`
1. Line 598: drop `&& !existsLocally` from the badge condition (badge shows whenever `modelFiles.length > 1`).
2. `fileParams` construction (16111616): add `name: this.selectedFile.name`.
3. Surface the backend "file already in library" hard error as a toast instead of only the batch-summary modal (R10/F12 nit; reuse existing error message field).
4. No changes to `updateNextButtonState` / `proceedToLocation` in phase 1; no template or CSS changes.
**Known phase-1 UX limitations (acknowledged, fixed in phase 2):** with all files downloaded the badge still renders and re-picking a downloaded file fails late (backend error after the location step); `localPath` may point at a sibling file; batch-preview "In Library" badge stays version-level and gives no hint of remaining files.
**Estimated:** ~1030 LOC (confirmed realistic by review).
### 5.3 Phase 1 tests
Backend — extend `tests/services/test_download_manager_basic.py` (1694 lines; all fixture patterns exist):
- **Fixture gaps to add (F10):** `DummyScanner.get_cached_data()`/`raw_data` stub (~10 lines); `hashes.SHA256` in the metadata-provider payload's `files`.
- Cases: same version + different SHA256 in library + `file_params` → proceeds; same SHA256 → hard error; `file_params=None` + version in library → hard error (unchanged); history-skip on + `file_params` → not skipped; without → skipped (unchanged); empty-dict `file_params` normalized → version-level behavior; `model_id`-only + `file_params` → gate and selection resolve the same file; legacy metadata (empty local sha256) matched by name; target file with empty SHA256 → name fallback, no `""==""` false positive.
- Queue retry: `file_params` survives retry.
- Assert proceed/abort via the existing `_execute_download` mock pattern.
Frontend (`tests/frontend/`): badge renders for multi-file version with `existsLocally=true` (pattern from `downloadManager.history.test.js`).
**Estimated:** ~150250 LOC (confirmed realistic).
## 6. Implementation — Phase 2 (per-file status + multi-select + index hardening)
### 6.1 Backend
1. **`py/services/model_cache.py`** (D3): add `version_files_index`; maintain in `add_to_version_index` / `remove_from_version_index` / `rebuild_version_index`; removal re-points `version_index[version_id]` (and the `model_id_index` descriptor) to a surviving sibling instead of dropping the key.
2. **`py/services/model_scanner.py`**: expose `get_files_for_version(version_id) -> List[dict]`.
3. **`py/routes/handlers/model_handlers.py` `get_civitai_versions`**: annotate each version with `downloadedFiles: [{fileId, fileName, filePath}]` via `version_files_index` + D2 matching against `version.files`.
4. **`py/services/downloaded_version_history_service.py`** (D4): new child table `downloaded_version_files`; `mark_downloaded` also upserts the child row when `file_id` known; `mark_as_deleted` clears the version's child rows only when no sibling remains in the library; new `get_downloaded_file_ids(model_type, version_id) -> set[int]`. `_record_downloaded_version_history` passes `file_info` through.
5. **`delete_model_version`** (`misc_handlers.py:24102487`, R5): resolve **all** local files of the version via `version_files_index`; delete all (current endpoint semantics are version-level) or — if kept per-file — only `mark_as_deleted` when no sibling remains. Decide at implementation time; minimum is documenting current behavior.
6. **`ModelVersionsTab` backend support**: none needed beyond item 3 (`downloadedFiles`); the tab consumes the same versions payload.
### 6.2 Frontend
1. **File dialog multi-select** — change surface (F8): option markup (`DownloadManager.js:712724`), the single-select click handler (`727734`), the `input[type="radio"]:checked` selector in `confirmFileSelection` (`738`); template `templates/components/modals/download_modal.html:4860` (confirm-button label only); CSS `download-modal.css` — checkbox variant of `.file-option-radio input` (595604) and a **new** `.file-option.disabled` style (does not exist). Files whose id ∈ `downloadedFiles` render disabled with an "In Library" tag.
2. **Mixed-type guard (F8):** multi-select is restricted to files sharing the same routing target (`_isDiffusionModel` is computed once from a single `selectedFile` at 798803; e.g. "Model" + "UNet" files route to different roots). Disallow mixed-type multi-select (simplest, predictable); single-file selection unchanged.
3. **Multi-file download loop (D5/F7):** extract from `executeDownloadWithProgress` a reload-deferred, no-toast inner function; iterate per selected file with per-file progress; one `resetAndReload(true)` + one aggregated success/failure summary at the end (reuse `showDownloadBatchSummary`).
4. **`updateNextButtonState` / `proceedToLocation`:** for multi-file versions, Next routes into the file dialog; hard block only when *every* weight file is downloaded.
5. **`ModelVersionsTab.js` (F6):** the Download action (`:576` hidden when `isInLibrary`) — for multi-file versions with remaining files, show it and route into the download modal's file dialog; keep hidden when all files present.
6. **Batch preview (F5):** `batch-preview-local-badge` (`:1320`) gains a "partially downloaded" hint for multi-file versions with remaining files.
7. New i18n keys (`modals.download.fileSelection.inLibrary`, `downloadSelected`, partial-download tooltip, etc.) → run `python scripts/sync_translation_keys.py`.
### 6.3 Phase 2 tests
- `model_cache` (`tests/services/test_model_cache.py` already covers add/remove at 4455): multi-valued index; sibling re-point on removal; rebuild.
- `get_civitai_versions`: `downloadedFiles` correctness (hash match, name fallback, no match, CivArchive no-hash payload).
- History service (`tests/services/test_downloaded_version_history_service.py` uses real SQLite on tmp_path): child-table creation on a legacy DB; per-file record/query; `mark_as_deleted` sibling semantics.
- Frontend: dialog checkbox rendering/disabled state and multi-file confirm — **greenfield behavior coverage** (F10: no existing test exercises `showFileSelectionStep`/`confirmFileSelection`; infra exists, patterns must be built).
## 7. Risks and Mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| History-gate bypass (D1) causes unwanted re-downloads in automated flows | Large checkpoint files re-downloaded | Bypass only with normalized, resolvable `file_params` (D1a); all such senders are user-initiated dialog flows (2.6, verified); tests pin batch/recipe/bulk behavior. |
| Empty-hash matching edge cases (R6) | Duplicate download of the same file, or false block | D2 rule: hash only when both non-empty; name otherwise; never `""==""`. Residual risk documented (2.4). |
| Phase-1 late-failure UX (F12) | User picks a downloaded file, fails only after location step | Toast surfacing (5.2.3); phase 2 disables downloaded files up front. |
| Phase-2 index change corrupts existing behavior | Recipe matching, delete flows | Additive index + re-point only; `version_index` read semantics unchanged; `remove_models`/`update_single_model_cache` already rebuild (F3); tests. |
| `delete_model_version` marks whole version deleted while sibling remains (R5) | History wrongly suppresses re-download of the surviving sibling's version | Phase 2 item 6.1.5; documented until then. |
| History child-table migration failure on user installs | Service init crash | `CREATE TABLE IF NOT EXISTS` in `_initialize_schema`; failure degrades to version-level behavior (per-file queries return empty). |
| Batch-preview badge misleading for partial versions (F5) | Minor UX confusion | Acknowledged in phase 1; fixed in phase 2 item 6.2.6. |
| UI confusion: version shows "In Library" while files remain downloadable | Support burden | Phase 2: per-file disabled state + partial-download tooltip. |
| Hash-identical sibling files (repacked content) | Second file blocked | Acceptable: scanner hash dedup already collapses them. |
## 8. Rollout
1. **Commit 1**`fix(download): allow downloading additional files of an in-library model version (#1058)` → Phase 1 (5.15.3).
2. **Commit 2**`feat(download): per-file download status and multi-file selection (#1058)` → Phase 2 (6.16.3).
Phase 1 alone resolves the issue as reported; phase 2 can ship in a later release if review prefers smaller increments.
## 9. Effort Estimate (revised after review)
| Phase | Backend | Frontend | Tests | Risk |
|---|---|---|---|---|
| 1 | ~150220 LOC (+ queue-retry fix ~30) | ~1030 LOC | ~150250 LOC | Low |
| 2 | ~250350 LOC | ~250350 LOC (multi-file loop refactor + ModelVersionsTab + batch badge) | ~250350 LOC (dialog tests greenfield) | Medium |
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "Nur Early-Access-Updates"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "Bezahlte Updates ausblenden",
"help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an"
},
"licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRA-Rezepte",
"actions": {
"sendCheckpoint": "Send to ComfyUI"
"sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "Importieren",
"title": "Ein Rezept aus Bild oder URL importieren",
"urlLocalPath": "URL / Lokaler Pfad",
"uploadImage": "Bild hochladen",
"urlSectionDescription": "Geben Sie eine Civitai-Bild-URL oder einen lokalen Dateipfad ein, um es als Rezept zu importieren.",
"dropZoneLabel": "Bild hochladen",
"dropZoneHint": "Bild hierher ziehen, aus der Zwischenablage einfügen oder klicken zum Durchsuchen",
"orDivider": "oder Bild per Drag & Drop / Einfügen hinzufügen",
"imageUrlOrPath": "Bild-URL oder Dateipfad:",
"urlPlaceholder": "https://civitai.com/images/... oder C:/pfad/zu/bild.png",
"fetchImage": "Bild abrufen",
"uploadSectionDescription": "Laden Sie ein Bild mit LoRA-Metadaten hoch, um es als Rezept zu importieren.",
"selectImage": "Bild auswählen",
"recipeName": "Rezeptname",
"recipeNamePlaceholder": "Rezeptname eingeben",
"tagsOptional": "Tags (optional)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "Bitte wählen Sie eine Bilddatei aus",
"enterUrlOrPath": "Bitte geben Sie eine URL oder einen Dateipfad ein",
"invalidUrl": "Bitte geben Sie eine gültige URL ein",
"invalidInputFormat": "Bitte geben Sie eine Bild-URL oder einen lokalen Bilddateipfad ein",
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "Heruntergeladen",
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek",
"partiallyDownloaded": "Teilweise heruntergeladen",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": {
"title": "Dateiformat auswählen",
"files": "Dateien",
"select": "Datei auswählen"
"select": "Datei auswählen",
"inLibrary": "In Bibliothek"
},
"errors": {
"invalidUrl": "Ungültiges Civitai URL-Format",
@@ -1532,6 +1541,30 @@
"examples": "Beispiele werden geladen...",
"versions": "Versionen werden geladen..."
},
"showcase": {
"hiddenBySfw": "{count} durch Nur-SFW-Einstellung ausgeblendet",
"showExamples": "Beispiele anzeigen",
"showCount": "Beispiele anzeigen ({count})",
"hideExamples": "Beispiele ausblenden",
"addExamples": "Beispiele hinzufügen",
"previousExample": "Vorheriges Beispiel",
"nextExample": "Nächstes Beispiel",
"noExamples": "Keine Beispielbilder verfügbar",
"addMoreExamples": "Weitere Beispiele hinzufügen",
"dragDrop": "Bilder oder Videos hierher ziehen & ablegen",
"or": "oder",
"selectFiles": "Dateien auswählen",
"supportedFormats": "Unterstützte Formate: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Dateien werden importiert...",
"noSupportedFiles": "Keine unterstützten Dateien ausgewählt. Bitte wählen Sie Bild- oder Videodateien aus.",
"allFiltered": "Alle Beispielbilder wurden aufgrund der NSFW-Inhaltseinstellungen herausgefiltert",
"sfwOnlyEnabled": "Ihre Einstellungen zeigen derzeit nur jugendfreie Inhalte an",
"changeInSettings": "Sie können dies in den Einstellungen ändern",
"nsfwMature": "Nicht jugendfreie Inhalte",
"nsfwR": "Inhalte ab 18 (R)",
"nsfwX": "Inhalte mit X-Einstufung",
"nsfwXxx": "Inhalte mit XXX-Einstufung"
},
"versions": {
"heading": "Modellversionen",
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
@@ -1559,8 +1592,8 @@
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "Bezahlt",
"paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen",
"downloadRemainingTooltip": "Verbleibende Dateien dieser Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "Diese bezahlte Version von Civitai herunterladen",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "Keine kompatiblen Prompt-Ziele im Workflow.\nKlicken Sie mit der rechten Maustaste auf einen Knoten in ComfyUI → Markieren als → Prompt-Ziel festlegen",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen",
"downloadPartialWithAccess": "{completed} von {total} LoRAs heruntergeladen. {accessFailures} fehlgeschlagen aufgrund von Zugriffsbeschränkungen. Überprüfen Sie Ihren API-Schlüssel in den Einstellungen oder den Early Access-Status.",
"pleaseSelectVersion": "Bitte wählen Sie eine Version aus",
"pleaseSelectFile": "Bitte wählen Sie mindestens eine Datei aus",
"versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden",
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest."
"noLoraRootConfigured": "Kein LoRA-Stammverzeichnis konfiguriert. Bitte legen Sie ein Standard-LoRA-Stammverzeichnis in den Einstellungen fest.",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "Keine Modelle ausgewählt",
+47 -9
View File
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRA Recipes",
"actions": {
"sendCheckpoint": "Send to ComfyUI"
"sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI"
},
"workflow": {
"sendWorkflow": "Send Workflow to ComfyUI",
"sent": "Workflow sent to ComfyUI",
"sendFailed": "Failed to send workflow to ComfyUI",
"noWorkflow": "No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "Import",
"title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path",
"uploadImage": "Upload Image",
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.",
"dropZoneLabel": "Upload image",
"dropZoneHint": "Drag & drop an image here, paste from clipboard, or click to browse",
"orDivider": "or drag & drop / paste an image",
"imageUrlOrPath": "Image URL or File Path:",
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
"fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image",
"recipeName": "Recipe Name",
"recipeNamePlaceholder": "Enter recipe name",
"tagsOptional": "Tags (optional)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "Please select an image file",
"enterUrlOrPath": "Please enter a URL or file path",
"invalidUrl": "Please enter a valid URL",
"invalidInputFormat": "Please enter an image URL or a local image file path",
"selectLoraRoot": "Please select a LoRA root directory"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "Downloaded",
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library",
"partiallyDownloaded": "Partially downloaded",
"autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": {
"title": "Select File Format",
"files": "files",
"select": "Select File"
"select": "Select File",
"inLibrary": "In Library"
},
"errors": {
"invalidUrl": "Invalid Civitai URL format",
@@ -1532,6 +1541,30 @@
"examples": "Loading examples...",
"versions": "Loading versions..."
},
"showcase": {
"hiddenBySfw": "{count} hidden by SFW-only setting",
"showExamples": "Show examples",
"showCount": "Show examples ({count})",
"hideExamples": "Hide examples",
"addExamples": "Add examples",
"previousExample": "Previous example",
"nextExample": "Next example",
"noExamples": "No example images available",
"addMoreExamples": "Add more examples",
"dragDrop": "Drag & drop images or videos here",
"or": "or",
"selectFiles": "Select Files",
"supportedFormats": "Supported formats: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importing files...",
"noSupportedFiles": "No supported files selected. Please select image or video files.",
"allFiltered": "All example images are filtered due to NSFW content settings",
"sfwOnlyEnabled": "Your settings are currently set to show only safe-for-work content",
"changeInSettings": "You can change this in Settings",
"nsfwMature": "Mature Content",
"nsfwR": "R-rated Content",
"nsfwX": "X-rated Content",
"nsfwXxx": "XXX-rated Content"
},
"versions": {
"heading": "Model versions",
"copy": "Track and manage every version of this model in one place.",
@@ -1569,6 +1602,7 @@
"actions": {
"download": "Download",
"downloadTooltip": "Download this version",
"downloadRemainingTooltip": "Download remaining files of this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs",
"downloadPartialWithAccess": "Downloaded {completed} of {total} LoRAs. {accessFailures} failed due to access restrictions. Check your API key in settings or early access status.",
"pleaseSelectVersion": "Please select a version",
"pleaseSelectFile": "Please select at least one file",
"versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes",
"noMissingLorasInSelection": "No missing LoRAs found in selected recipes",
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings."
"noLoraRootConfigured": "No LoRA root directory configured. Please set a default LoRA root in settings.",
"workflowSent": "Workflow sent to ComfyUI",
"workflowSendFailed": "Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "No models selected",
@@ -2332,4 +2370,4 @@
"retry": "Retry"
}
}
}
}
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "Solo actualizaciones de acceso temprano"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "Ocultar actualizaciones de pago",
"help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'"
},
"licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados",
@@ -853,20 +853,25 @@
"recipes": {
"title": "Recetas de LoRA",
"actions": {
"sendCheckpoint": "Enviar a ComfyUI"
"sendCheckpoint": "Enviar a ComfyUI",
"sendRecipe": "Enviar a ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "Importar",
"title": "Importar una receta desde imagen o URL",
"urlLocalPath": "URL / Ruta local",
"uploadImage": "Subir imagen",
"urlSectionDescription": "Introduce una URL de imagen de Civitai o ruta de archivo local para importar como receta.",
"dropZoneLabel": "Subir imagen",
"dropZoneHint": "Arrastra y suelta una imagen aquí, pégala desde el portapapeles o haz clic para examinar",
"orDivider": "o arrastra y suelta / pega una imagen",
"imageUrlOrPath": "URL de imagen o ruta de archivo:",
"urlPlaceholder": "https://civitai.com/images/... o C:/ruta/a/imagen.png",
"fetchImage": "Obtener imagen",
"uploadSectionDescription": "Sube una imagen con metadatos de LoRA para importar como receta.",
"selectImage": "Seleccionar imagen",
"recipeName": "Nombre de receta",
"recipeNamePlaceholder": "Introduce nombre de receta",
"tagsOptional": "Etiquetas (opcional)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "Por favor selecciona un archivo de imagen",
"enterUrlOrPath": "Por favor introduce una URL o ruta de archivo",
"invalidUrl": "Introduce una URL válida",
"invalidInputFormat": "Introduce la URL de una imagen o una ruta de archivo local",
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "Descargado",
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca",
"partiallyDownloaded": "Descargado parcialmente",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": {
"title": "Seleccionar formato de archivo",
"files": "archivos",
"select": "Seleccionar archivo"
"select": "Seleccionar archivo",
"inLibrary": "En la biblioteca"
},
"errors": {
"invalidUrl": "Formato de URL de Civitai inválido",
@@ -1532,6 +1541,30 @@
"examples": "Cargando ejemplos...",
"versions": "Cargando versiones..."
},
"showcase": {
"hiddenBySfw": "{count} ocultas por el ajuste de solo contenido SFW",
"showExamples": "Mostrar ejemplos",
"showCount": "Mostrar ejemplos ({count})",
"hideExamples": "Ocultar ejemplos",
"addExamples": "Añadir ejemplos",
"previousExample": "Ejemplo anterior",
"nextExample": "Ejemplo siguiente",
"noExamples": "No hay imágenes de ejemplo disponibles",
"addMoreExamples": "Añadir más ejemplos",
"dragDrop": "Arrastra y suelta imágenes o videos aquí",
"or": "o",
"selectFiles": "Seleccionar archivos",
"supportedFormats": "Formatos compatibles: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importando archivos...",
"noSupportedFiles": "No se seleccionaron archivos compatibles. Selecciona archivos de imagen o video.",
"allFiltered": "Todas las imágenes de ejemplo están filtradas por los ajustes de contenido NSFW",
"sfwOnlyEnabled": "Tus ajustes están configurados actualmente para mostrar solo contenido apto para todo público",
"changeInSettings": "Puedes cambiarlo en Configuración",
"nsfwMature": "Contenido para adultos",
"nsfwR": "Contenido clasificación R",
"nsfwX": "Contenido clasificación X",
"nsfwXxx": "Contenido clasificación XXX"
},
"versions": {
"heading": "Versiones del modelo",
"copy": "Administra todas las versiones de este modelo en un solo lugar.",
@@ -1559,8 +1592,8 @@
"newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "De pago",
"paidTooltip": "Esta versión requiere pago para descargarse",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"downloadRemainingTooltip": "Descargar los archivos restantes de esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "Descargar esta versión de pago desde Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "No hay destinos de prompt compatibles en el workflow.\nHaz clic derecho en un nodo de ComfyUI → Marcar como → Destino de envío de prompt",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs",
"downloadPartialWithAccess": "Descargados {completed} de {total} LoRAs. {accessFailures} fallaron debido a restricciones de acceso. Revisa tu clave API en configuración o estado de acceso temprano.",
"pleaseSelectVersion": "Por favor selecciona una versión",
"pleaseSelectFile": "Por favor selecciona al menos un archivo",
"versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas",
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración."
"noLoraRootConfigured": "No se ha configurado el directorio raíz de LoRA. Por favor, establezca un directorio raíz de LoRA predeterminado en la configuración.",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "No hay modelos seleccionados",
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "Seulement les mises à jour en accès anticipé"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "Masquer les mises à jour payantes",
"help": "Lorsque cette option est activée, les modèles n'ayant que des mises à jour payantes n'affichent pas le badge « Mise à jour disponible »"
},
"licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour",
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRA Recipes",
"actions": {
"sendCheckpoint": "Envoyer vers ComfyUI"
"sendCheckpoint": "Envoyer vers ComfyUI",
"sendRecipe": "Envoyer vers ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "Importer",
"title": "Importer une recipe depuis une image ou une URL",
"urlLocalPath": "URL / Chemin local",
"uploadImage": "Téléverser une image",
"urlSectionDescription": "Saisissez une URL d'image Civitai ou un chemin de fichier local pour l'importer comme recipe.",
"dropZoneLabel": "Téléverser une image",
"dropZoneHint": "Glissez-déposez une image ici, collez-la depuis le presse-papiers ou cliquez pour parcourir",
"orDivider": "ou glissez-déposez / collez une image",
"imageUrlOrPath": "URL d'image ou chemin de fichier :",
"urlPlaceholder": "https://civitai.com/images/... ou C:/chemin/vers/image.png",
"fetchImage": "Récupérer l'image",
"uploadSectionDescription": "Téléversez une image avec des métadonnées LoRA pour l'importer comme recipe.",
"selectImage": "Sélectionner une image",
"recipeName": "Nom de la recipe",
"recipeNamePlaceholder": "Entrez le nom de la recipe",
"tagsOptional": "Tags (optionnel)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "Veuillez sélectionner un fichier image",
"enterUrlOrPath": "Veuillez entrer une URL ou un chemin de fichier",
"invalidUrl": "Veuillez saisir une URL valide",
"invalidInputFormat": "Veuillez saisir l'URL d'une image ou un chemin de fichier local",
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "Téléchargé",
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque",
"partiallyDownloaded": "Téléchargé partiellement",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": {
"title": "Choisir le format de fichier",
"files": "fichiers",
"select": "Choisir le fichier"
"select": "Choisir le fichier",
"inLibrary": "Dans la bibliothèque"
},
"errors": {
"invalidUrl": "Format d'URL Civitai invalide",
@@ -1532,6 +1541,30 @@
"examples": "Chargement des exemples...",
"versions": "Chargement des versions..."
},
"showcase": {
"hiddenBySfw": "{count} masqué(s) par le paramètre « Contenu SFW uniquement »",
"showExamples": "Afficher les exemples",
"showCount": "Afficher les exemples ({count})",
"hideExamples": "Masquer les exemples",
"addExamples": "Ajouter des exemples",
"previousExample": "Exemple précédent",
"nextExample": "Exemple suivant",
"noExamples": "Aucune image d'exemple disponible",
"addMoreExamples": "Ajouter d'autres exemples",
"dragDrop": "Glissez-déposez des images ou des vidéos ici",
"or": "ou",
"selectFiles": "Sélectionner des fichiers",
"supportedFormats": "Formats pris en charge : jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Importation des fichiers...",
"noSupportedFiles": "Aucun fichier pris en charge sélectionné. Veuillez sélectionner des fichiers image ou vidéo.",
"allFiltered": "Toutes les images d'exemple sont filtrées en raison des paramètres de contenu NSFW",
"sfwOnlyEnabled": "Vos paramètres sont actuellement configurés pour n'afficher que du contenu tout public",
"changeInSettings": "Vous pouvez modifier cela dans les paramètres",
"nsfwMature": "Contenu pour adultes",
"nsfwR": "Contenu classé R",
"nsfwX": "Contenu classé X",
"nsfwXxx": "Contenu classé XXX"
},
"versions": {
"heading": "Versions du modèle",
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
@@ -1559,8 +1592,8 @@
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "Payant",
"paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"downloadRemainingTooltip": "Télécharger les fichiers restants de cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "Télécharger cette version payante depuis Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "Aucune cible de prompt compatible dans le workflow.\nFaites un clic droit sur un nœud dans ComfyUI → Marquer comme → Cible d'envoi du prompt",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés",
"downloadPartialWithAccess": "{completed} sur {total} LoRAs téléchargés. {accessFailures} ont échoué en raison de restrictions d'accès. Vérifiez votre clé API dans les paramètres ou le statut d'accès anticipé.",
"pleaseSelectVersion": "Veuillez sélectionner une version",
"pleaseSelectFile": "Veuillez sélectionner au moins un fichier",
"versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines recettes",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées",
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres."
"noLoraRootConfigured": "Aucun répertoire racine LoRA configuré. Veuillez définir un répertoire racine LoRA par défaut dans les paramètres.",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "Aucun modèle sélectionné",
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "רק עדכוני גישה מוקדמת"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "הסתר עדכונים בתשלום",
"help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
},
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
@@ -853,20 +853,25 @@
"recipes": {
"title": "מתכוני LoRA",
"actions": {
"sendCheckpoint": "שלח ל-ComfyUI"
"sendCheckpoint": "שלח ל-ComfyUI",
"sendRecipe": "שלח ל-ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "ייבא",
"title": "ייבא מתכון מתמונה או כתובת URL",
"urlLocalPath": "URL / נתיב מקומי",
"uploadImage": "העלה תמונה",
"urlSectionDescription": "הזן כתובת URL של תמונה מ-Civitai או נתיב קובץ מקומי לייבוא כמתכון.",
"dropZoneLabel": "העלאת תמונה",
"dropZoneHint": "גררו ושחררו תמונה כאן, הדביקו מהלוח או לחצו לעיון",
"orDivider": "או גררו ושחררו / הדביקו תמונה",
"imageUrlOrPath": "URL של תמונה או נתיב קובץ:",
"urlPlaceholder": "https://civitai.com/images/... או C:/path/to/image.png",
"fetchImage": "אחזר תמונה",
"uploadSectionDescription": "העלה תמונה עם מטא-דאטה של LoRA לייבוא כמתכון.",
"selectImage": "בחר תמונה",
"recipeName": "שם המתכון",
"recipeNamePlaceholder": "הזן שם מתכון",
"tagsOptional": "תגיות (אופציונלי)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "אנא בחר קובץ תמונה",
"enterUrlOrPath": "אנא הזן URL או נתיב קובץ",
"invalidUrl": "נא להזין כתובת URL תקינה",
"invalidInputFormat": "נא להזין כתובת URL של תמונה או נתיב קובץ מקומי",
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "הורד",
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה",
"partiallyDownloaded": "הורד חלקית",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": {
"title": "בחר פורמט קובץ",
"files": "קבצים",
"select": "בחר קובץ"
"select": "בחר קובץ",
"inLibrary": "בספרייה"
},
"errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי",
@@ -1532,6 +1541,30 @@
"examples": "טוען דוגמאות...",
"versions": "טוען גרסאות..."
},
"showcase": {
"hiddenBySfw": "{count} הוסתרו עקב הגדרת SFW בלבד",
"showExamples": "הצג דוגמאות",
"showCount": "הצג דוגמאות ({count})",
"hideExamples": "הסתר דוגמאות",
"addExamples": "הוסף דוגמאות",
"previousExample": "דוגמה קודמת",
"nextExample": "דוגמה הבאה",
"noExamples": "אין תמונות דוגמה זמינות",
"addMoreExamples": "הוסף עוד דוגמאות",
"dragDrop": "גרור ושחרר תמונות או סרטונים כאן",
"or": "או",
"selectFiles": "בחר קבצים",
"supportedFormats": "פורמטים נתמכים: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "מייבא קבצים...",
"noSupportedFiles": "לא נבחרו קבצים נתמכים. בחר קבצי תמונה או וידאו.",
"allFiltered": "כל תמונות הדוגמה מסוננות עקב הגדרות תוכן NSFW",
"sfwOnlyEnabled": "ההגדרות שלך מוגדרות כעת להציג רק תוכן SFW",
"changeInSettings": "ניתן לשנות זאת בהגדרות",
"nsfwMature": "תוכן למבוגרים",
"nsfwR": "תוכן בדירוג R",
"nsfwX": "תוכן בדירוג X",
"nsfwXxx": "תוכן בדירוג XXX"
},
"versions": {
"heading": "גרסאות המודל",
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
@@ -1559,8 +1592,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "בתשלום",
"paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadRemainingTooltip": "הורד את הקבצים הנותרים של גרסה זו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "הורד את הגרסה בתשלום הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "אין יעדי הנחיה תואמים ב-workflow.\nלחץ לחיצה ימנית על צומת ב-ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
"pleaseSelectVersion": "אנא בחר גרסה",
"pleaseSelectFile": "אנא בחר לפחות קובץ אחד",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות."
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות.",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "לא נבחרו מודלים",
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "早期アクセスのみの更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "有料更新を非表示",
"help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
},
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRAレシピ",
"actions": {
"sendCheckpoint": "ComfyUIへ送信"
"sendCheckpoint": "ComfyUIへ送信",
"sendRecipe": "ComfyUIへ送信"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "インポート",
"title": "画像またはURLからレシピをインポート",
"urlLocalPath": "URL / ローカルパス",
"uploadImage": "画像をアップード",
"urlSectionDescription": "Civitai画像URLまたはローカルファイルパスを入力してレシピとしてインポートします。",
"dropZoneLabel": "画像をアップロード",
"dropZoneHint": "画像をここにドラッグ&ドロップ、クリップードから貼り付け、またはクリックして参照",
"orDivider": "または画像をドラッグ&ドロップ / 貼り付け",
"imageUrlOrPath": "画像URLまたはファイルパス:",
"urlPlaceholder": "https://civitai.com/images/... または C:/path/to/image.png",
"fetchImage": "画像を取得",
"uploadSectionDescription": "LoRAメタデータを含む画像をアップロードしてレシピとしてインポートします。",
"selectImage": "画像を選択",
"recipeName": "レシピ名",
"recipeNamePlaceholder": "レシピ名を入力",
"tagsOptional": "タグ(任意)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "画像ファイルを選択してください",
"enterUrlOrPath": "URLまたはファイルパスを入力してください",
"invalidUrl": "有効なURLを入力してください",
"invalidInputFormat": "画像のURLまたはローカルの画像ファイルパスを入力してください",
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "ダウンロード済み",
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内",
"partiallyDownloaded": "一部ダウンロード済み",
"autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": {
"title": "ファイル形式を選択",
"files": "ファイル",
"select": "ファイルを選択"
"select": "ファイルを選択",
"inLibrary": "ライブラリ内"
},
"errors": {
"invalidUrl": "無効なCivitai URL形式",
@@ -1532,6 +1541,30 @@
"examples": "例を読み込み中...",
"versions": "バージョンを読み込み中..."
},
"showcase": {
"hiddenBySfw": "SFWのみ設定により{count}件非表示",
"showExamples": "例を表示",
"showCount": "例を表示({count}",
"hideExamples": "例を非表示",
"addExamples": "例を追加",
"previousExample": "前の例",
"nextExample": "次の例",
"noExamples": "利用可能な例画像がありません",
"addMoreExamples": "さらに例を追加",
"dragDrop": "画像または動画をここにドラッグ&ドロップ",
"or": "または",
"selectFiles": "ファイルを選択",
"supportedFormats": "対応形式:jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "ファイルをインポート中...",
"noSupportedFiles": "対応ファイルが選択されていません。画像または動画ファイルを選択してください。",
"allFiltered": "NSFWコンテンツ設定により、すべての例画像がフィルタリングされています",
"sfwOnlyEnabled": "現在の設定ではSFWコンテンツのみが表示されます",
"changeInSettings": "設定から変更できます",
"nsfwMature": "成人向けコンテンツ",
"nsfwR": "R指定コンテンツ",
"nsfwX": "X指定コンテンツ",
"nsfwXxx": "XXX指定コンテンツ"
},
"versions": {
"heading": "モデルバージョン",
"copy": "このモデルのすべてのバージョンを一か所で管理します。",
@@ -1559,8 +1592,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "有料",
"paidTooltip": "このバージョンのダウンロードには支払いが必要です",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadRemainingTooltip": "このバージョンの残りのファイルをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "Civitai からこの有料バージョンをダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "ワークフロー内に互換性のあるプロンプトターゲットがありません。\nComfyUIでノードを右クリック → Mark as → Send Prompt Target",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
"pleaseSelectVersion": "バージョンを選択してください",
"pleaseSelectFile": "ファイルを1つ以上選択してください",
"versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。"
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "モデルが選択されていません",
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "얼리 액세스 업데이트만"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "유료 업데이트 숨기기",
"help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
},
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRA 레시피",
"actions": {
"sendCheckpoint": "ComfyUI로 보내기"
"sendCheckpoint": "ComfyUI로 보내기",
"sendRecipe": "ComfyUI로 보내기"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "가져오기",
"title": "이미지 또는 URL에서 레시피 가져오기",
"urlLocalPath": "URL / 로컬 경로",
"uploadImage": "이미지 업로드",
"urlSectionDescription": "Civitai 이미지 URL 또는 로컬 파일 경로를 입력하여 레시피로 가져옵니다.",
"dropZoneLabel": "이미지 업로드",
"dropZoneHint": "이미지를 여기에 끌어다 놓거나, 클립보드에서 붙여넣거나, 클릭하여 찾아보세요",
"orDivider": "또는 이미지를 끌어다 놓기 / 붙여넣기",
"imageUrlOrPath": "이미지 URL 또는 파일 경로:",
"urlPlaceholder": "https://civitai.com/images/... 또는 C:/path/to/image.png",
"fetchImage": "이미지 가져오기",
"uploadSectionDescription": "LoRA 메타데이터가 포함된 이미지를 업로드하여 레시피로 가져옵니다.",
"selectImage": "이미지 선택",
"recipeName": "레시피 이름",
"recipeNamePlaceholder": "레시피 이름을 입력하세요",
"tagsOptional": "태그 (선택사항)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "이미지 파일을 선택해주세요",
"enterUrlOrPath": "URL 또는 파일 경로를 입력해주세요",
"invalidUrl": "유효한 URL을 입력하세요",
"invalidInputFormat": "이미지 URL 또는 로컬 이미지 파일 경로를 입력하세요",
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "다운로드됨",
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음",
"partiallyDownloaded": "부분적으로 다운로드됨",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": {
"title": "파일 형식 선택",
"files": "개 파일",
"select": "파일 선택"
"select": "파일 선택",
"inLibrary": "라이브러리에 있음"
},
"errors": {
"invalidUrl": "잘못된 Civitai URL 형식",
@@ -1532,6 +1541,30 @@
"examples": "예시 로딩 중...",
"versions": "버전 로딩 중..."
},
"showcase": {
"hiddenBySfw": "SFW 전용 설정으로 {count}개 숨겨짐",
"showExamples": "예시 보기",
"showCount": "예시 보기 ({count})",
"hideExamples": "예시 숨기기",
"addExamples": "예시 추가",
"previousExample": "이전 예시",
"nextExample": "다음 예시",
"noExamples": "사용 가능한 예시 이미지가 없습니다",
"addMoreExamples": "예시 더 추가",
"dragDrop": "이미지 또는 비디오를 여기로 끌어다 놓으세요",
"or": "또는",
"selectFiles": "파일 선택",
"supportedFormats": "지원되는 형식: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "파일을 가져오는 중...",
"noSupportedFiles": "지원되는 파일이 선택되지 않았습니다. 이미지 또는 비디오 파일을 선택하세요.",
"allFiltered": "NSFW 콘텐츠 설정으로 인해 모든 예시 이미지가 필터링되었습니다",
"sfwOnlyEnabled": "현재 설정이 안전한(SFW) 콘텐츠만 표시하도록 설정되어 있습니다",
"changeInSettings": "설정에서 변경할 수 있습니다",
"nsfwMature": "성인 콘텐츠",
"nsfwR": "R등급 콘텐츠",
"nsfwX": "X등급 콘텐츠",
"nsfwXxx": "XXX등급 콘텐츠"
},
"versions": {
"heading": "모델 버전",
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
@@ -1559,8 +1592,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "유료",
"paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadRemainingTooltip": "이 버전의 나머지 파일 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "Civitai에서 이 유료 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "워크플로우에 호환되는 프롬프트 타겟이 없습니다.\nComfyUI에서 노드를 우클릭 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
"pleaseSelectVersion": "버전을 선택해주세요",
"pleaseSelectFile": "파일을 하나 이상 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요."
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요.",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "선택된 모델이 없습니다",
+52 -14
View File
@@ -623,8 +623,8 @@
"help": "Только обновления раннего доступа"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "Скрывать платные обновления",
"help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
},
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
@@ -853,20 +853,25 @@
"recipes": {
"title": "Рецепты LoRA",
"actions": {
"sendCheckpoint": "Отправить в ComfyUI"
"sendCheckpoint": "Отправить в ComfyUI",
"sendRecipe": "Отправить в ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "Импортировать",
"title": "Импортировать рецепт из изображения или URL",
"urlLocalPath": "URL / Локальный путь",
"uploadImage": "Загрузить изображение",
"urlSectionDescription": "Введите URL изображения Civitai или локальный путь к файлу для импорта в качестве рецепта.",
"dropZoneLabel": "Загрузить изображение",
"dropZoneHint": "Перетащите изображение сюда, вставьте из буфера обмена или нажмите для выбора",
"orDivider": "или перетащите / вставьте изображение",
"imageUrlOrPath": "URL изображения или путь к файлу:",
"urlPlaceholder": "https://civitai.com/images/... или C:/path/to/image.png",
"fetchImage": "Получить изображение",
"uploadSectionDescription": "Загрузите изображение с метаданными LoRA для импорта в качестве рецепта.",
"selectImage": "Выбрать изображение",
"recipeName": "Название рецепта",
"recipeNamePlaceholder": "Введите название рецепта",
"tagsOptional": "Теги (необязательно)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "Пожалуйста, выберите файл изображения",
"enterUrlOrPath": "Пожалуйста, введите URL или путь к файлу",
"invalidUrl": "Введите корректный URL",
"invalidInputFormat": "Введите URL изображения или путь к локальному файлу изображения",
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "Загружено",
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке",
"partiallyDownloaded": "Загружено частично",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": {
"title": "Выбрать формат файла",
"files": "файлов",
"select": "Выбрать файл"
"select": "Выбрать файл",
"inLibrary": "В библиотеке"
},
"errors": {
"invalidUrl": "Неверный формат URL Civitai",
@@ -1532,6 +1541,30 @@
"examples": "Загрузка примеров...",
"versions": "Загрузка версий..."
},
"showcase": {
"hiddenBySfw": "{count} скрыто настройкой «только SFW»",
"showExamples": "Показать примеры",
"showCount": "Показать примеры ({count})",
"hideExamples": "Скрыть примеры",
"addExamples": "Добавить примеры",
"previousExample": "Предыдущий пример",
"nextExample": "Следующий пример",
"noExamples": "Примеры изображений недоступны",
"addMoreExamples": "Добавить ещё примеры",
"dragDrop": "Перетащите изображения или видео сюда",
"or": "или",
"selectFiles": "Выбрать файлы",
"supportedFormats": "Поддерживаемые форматы: jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "Импорт файлов...",
"noSupportedFiles": "Не выбрано поддерживаемых файлов. Пожалуйста, выберите файлы изображений или видео.",
"allFiltered": "Все примеры изображений отфильтрованы из-за настроек NSFW-контента",
"sfwOnlyEnabled": "В настройках сейчас включён показ только безопасного для работы (SFW) контента",
"changeInSettings": "Вы можете изменить это в Настройках",
"nsfwMature": "Контент для взрослых",
"nsfwR": "Контент с рейтингом R",
"nsfwX": "Контент с рейтингом X",
"nsfwXxx": "Контент с рейтингом XXX"
},
"versions": {
"heading": "Версии модели",
"copy": "Управляйте всеми версиями этой модели в одном месте.",
@@ -1559,8 +1592,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "Платная",
"paidTooltip": "Скачивание этой версии платное",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadRemainingTooltip": "Скачать оставшиеся файлы этой версии",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "Скачать эту платную версию с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
@@ -1740,7 +1774,7 @@
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noPromptTargets": "В рабочем процессе нет совместимых целей для промпта.\nЩёлкните правой кнопкой мыши по узлу в ComfyUI → Отметить как → Send Prompt Target",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
"pleaseSelectVersion": "Пожалуйста, выберите версию",
"pleaseSelectFile": "Пожалуйста, выберите хотя бы один файл",
"versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках."
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках.",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "Модели не выбраны",
+51 -13
View File
@@ -623,8 +623,8 @@
"help": "抢先体验更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "隐藏付费更新",
"help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
},
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRA 配方",
"actions": {
"sendCheckpoint": "发送到 ComfyUI"
"sendCheckpoint": "发送到 ComfyUI",
"sendRecipe": "发送到 ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "导入",
"title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径",
"uploadImage": "上传图片",
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。",
"dropZoneLabel": "上传图片",
"dropZoneHint": "将图片拖拽到此处、从剪贴板粘贴,或点击浏览",
"orDivider": "或拖拽 / 粘贴图片",
"imageUrlOrPath": "图片 URL 或文件路径:",
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片",
"recipeName": "配方名称",
"recipeNamePlaceholder": "输入配方名称",
"tagsOptional": "标签(可选)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "请选择一个图像文件",
"enterUrlOrPath": "请输入 URL 或文件路径",
"invalidUrl": "请输入有效的 URL",
"invalidInputFormat": "请输入图片 URL 或本地图片文件路径",
"selectLoraRoot": "请选择 LoRA 根目录"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "已下载",
"downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中",
"partiallyDownloaded": "部分已下载",
"autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": {
"title": "选择文件格式",
"files": "个文件",
"select": "选择文件"
"select": "选择文件",
"inLibrary": "已在库中"
},
"errors": {
"invalidUrl": "无效的 Civitai URL 格式",
@@ -1532,6 +1541,30 @@
"examples": "正在加载示例...",
"versions": "正在加载版本..."
},
"showcase": {
"hiddenBySfw": "{count} 张因仅显示 SFW 设置而被隐藏",
"showExamples": "显示示例",
"showCount": "显示示例({count}",
"hideExamples": "隐藏示例",
"addExamples": "添加示例",
"previousExample": "上一个示例",
"nextExample": "下一个示例",
"noExamples": "暂无示例图片",
"addMoreExamples": "添加更多示例",
"dragDrop": "将图片或视频拖放到此处",
"or": "或",
"selectFiles": "选择文件",
"supportedFormats": "支持的格式:jpg, png, gif, webp, avif, jxl, mp4, webm",
"importing": "正在导入文件...",
"noSupportedFiles": "未选择受支持的文件。请选择图片或视频文件。",
"allFiltered": "所有示例图片均因 NSFW 内容设置而被过滤",
"sfwOnlyEnabled": "你当前的设置为仅显示 SFW 内容",
"changeInSettings": "你可以在设置中更改此选项",
"nsfwMature": "成熟内容",
"nsfwR": "R 级内容",
"nsfwX": "X 级内容",
"nsfwXxx": "XXX 级内容"
},
"versions": {
"heading": "模型版本",
"copy": "在一个位置管理该模型的所有版本。",
@@ -1559,8 +1592,8 @@
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "付费",
"paidTooltip": "此版本需要付费后才能下载",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadRemainingTooltip": "下载此版本的剩余文件",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "从 Civitai 下载此付费版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
"pleaseSelectVersion": "请选择版本",
"pleaseSelectFile": "请至少选择一个文件",
"versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。"
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "未选中模型",
+51 -13
View File
@@ -623,8 +623,8 @@
"help": "搶先體驗更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
"label": "隱藏付費更新",
"help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
},
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
@@ -853,20 +853,25 @@
"recipes": {
"title": "LoRA 配方",
"actions": {
"sendCheckpoint": "傳送到 ComfyUI"
"sendCheckpoint": "傳送到 ComfyUI",
"sendRecipe": "傳送到 ComfyUI"
},
"workflow": {
"sendWorkflow": "[TODO: Translate] Send Workflow to ComfyUI",
"sent": "[TODO: Translate] Workflow sent to ComfyUI",
"sendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI",
"noWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"controls": {
"import": {
"action": "匯入",
"title": "從圖片或網址匯入配方",
"urlLocalPath": "網址 / 本機路徑",
"uploadImage": "上傳圖片",
"urlSectionDescription": "輸入 Civitai 圖片網址或本機檔案路徑以匯入配方。",
"dropZoneLabel": "上傳圖片",
"dropZoneHint": "將圖片拖曳至此處、從剪貼簿貼上,或點擊瀏覽",
"orDivider": "或拖曳 / 貼上圖片",
"imageUrlOrPath": "圖片網址或檔案路徑:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
"fetchImage": "取得圖片",
"uploadSectionDescription": "上傳含 LoRA metadata 的圖片以匯入配方。",
"selectImage": "選擇圖片",
"recipeName": "配方名稱",
"recipeNamePlaceholder": "輸入配方名稱",
"tagsOptional": "標籤(選填)",
@@ -911,6 +916,8 @@
"errors": {
"selectImageFile": "請選擇圖片檔案",
"enterUrlOrPath": "請輸入網址或檔案路徑",
"invalidUrl": "請輸入有效的 URL",
"invalidInputFormat": "請輸入圖片 URL 或本機圖片檔案路徑",
"selectLoraRoot": "請選擇 LoRA 根目錄"
}
},
@@ -1243,11 +1250,13 @@
"downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存",
"partiallyDownloaded": "部分已下載",
"autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": {
"title": "選擇檔案格式",
"files": "個檔案",
"select": "選擇檔案"
"select": "選擇檔案",
"inLibrary": "已在庫中"
},
"errors": {
"invalidUrl": "Civitai 網址格式無效",
@@ -1532,6 +1541,30 @@
"examples": "載入範例中...",
"versions": "載入版本中..."
},
"showcase": {
"hiddenBySfw": "因僅顯示 SFW 設定而隱藏 {count} 張",
"showExamples": "顯示範例",
"showCount": "顯示範例({count}",
"hideExamples": "隱藏範例",
"addExamples": "新增範例",
"previousExample": "上一個範例",
"nextExample": "下一個範例",
"noExamples": "沒有可用的範例圖片",
"addMoreExamples": "新增更多範例",
"dragDrop": "拖放圖片或影片到此處",
"or": "或",
"selectFiles": "選擇檔案",
"supportedFormats": "支援的格式:jpg、png、gif、webp、avif、jxl、mp4、webm",
"importing": "正在匯入檔案...",
"noSupportedFiles": "未選擇支援的檔案。請選擇圖片或影片檔案。",
"allFiltered": "所有範例圖片都因 NSFW 內容設定而被過濾",
"sfwOnlyEnabled": "你目前的設定為僅顯示安全(SFW)內容",
"changeInSettings": "你可以在設定中變更此選項",
"nsfwMature": "成熟內容",
"nsfwR": "R 級內容",
"nsfwX": "X 級內容",
"nsfwXxx": "XXX 級內容"
},
"versions": {
"heading": "模型版本",
"copy": "在同一位置追蹤並管理此模型的所有版本。",
@@ -1559,8 +1592,8 @@
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"paid": "付費",
"paidTooltip": "此版本需要付費才能下載",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
@@ -1569,8 +1602,9 @@
"actions": {
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadRemainingTooltip": "下載此版本的剩餘檔案",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadPaidTooltip": "從 Civitai 下載此付費版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
@@ -1917,6 +1951,7 @@
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
"pleaseSelectVersion": "請選擇一個版本",
"pleaseSelectFile": "請至少選擇一個檔案",
"versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
@@ -2002,7 +2037,10 @@
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。"
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。",
"workflowSent": "[TODO: Translate] Workflow sent to ComfyUI",
"workflowSendFailed": "[TODO: Translate] Failed to send workflow to ComfyUI: {error}",
"workflowNoWorkflow": "[TODO: Translate] No embedded workflow found in this recipe"
},
"models": {
"noModelsSelected": "未選擇模型",
+77 -2
View File
@@ -13,6 +13,10 @@ class CheckpointLoaderLM:
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for checkpoint loading.
The ckpt_name combo supports ComfyUI's control_after_generate, letting
users pick a random checkpoint on every run; the base_model input narrows
the random pool through a front-end extension that filters the combo
options.
"""
NAME = "Checkpoint Loader (LoraManager)"
@@ -22,11 +26,29 @@ class CheckpointLoaderLM:
def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
{
"tooltip": (
"The name of the checkpoint (model) to load. Use "
"control_after_generate to pick a random model on "
"every run."
),
"control_after_generate": "fixed",
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": (
"Restrict the random selection pool to this base "
"model. 'Any' uses the full pool."
),
},
),
}
}
@@ -93,15 +115,68 @@ class CheckpointLoaderLM:
logger.error(f"Error getting checkpoint names: {e}")
return []
def load_checkpoint(self, ckpt_name: str) -> Tuple[Any, Any, Any]:
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed checkpoints, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
def load_checkpoint(
self, ckpt_name: str, base_model: str = "Any"
) -> Tuple[Any, Any, Any]:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
base_model: Only used by the front-end to filter the random pool
Returns:
Tuple of (MODEL, CLIP, VAE)
"""
del base_model
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
+3 -2
View File
@@ -39,6 +39,7 @@ class CreateHookLoraLM:
),
},
),
"loras": ("LORAS", {}),
},
"optional": FlexibleOptionalInputType(any_type),
}
@@ -52,7 +53,7 @@ class CreateHookLoraLM:
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
FUNCTION = "create_hook"
def create_hook(self, text: str, **kwargs):
def create_hook(self, text: str, loras, **kwargs):
"""Create a HookGroup from the selected LoRAs, chained with prev_hooks.
Each active LoRA from the widget is loaded and wrapped in a WeightHook
@@ -73,7 +74,7 @@ class CreateHookLoraLM:
all_trigger_words: list[str] = []
active_loras: list[tuple[str, float, float]] = []
for lora in get_loras_list(kwargs):
for lora in get_loras_list({"loras": loras}):
if not lora.get("active", False):
continue
+6 -5
View File
@@ -49,9 +49,9 @@ def _collect_stack_entries(lora_stack):
return entries
def _collect_widget_entries(kwargs):
def _collect_widget_entries(loras):
entries = []
for lora in get_loras_list(kwargs):
for lora in get_loras_list({"loras": loras}):
if not lora.get("active", False):
continue
lora_name = apply_lora_syntax_format(lora["name"])
@@ -139,6 +139,7 @@ class LoraLoaderLM:
"placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}),
"loras": ("LORAS", {}),
},
"optional": FlexibleOptionalInputType(any_type),
}
@@ -152,12 +153,12 @@ class LoraLoaderLM:
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras"
def load_loras(self, model, text, **kwargs):
"""Loads multiple LoRAs based on the kwargs input and lora_stack."""
def load_loras(self, model, text, loras, **kwargs):
"""Loads multiple LoRAs based on the widget input and lora_stack."""
del text
clip = kwargs.get("clip", None)
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", None))
lora_entries.extend(_collect_widget_entries(kwargs))
lora_entries.extend(_collect_widget_entries(loras))
nunchaku_model_kind = detect_nunchaku_model_kind(model)
if nunchaku_model_kind == "flux":
+5 -4
View File
@@ -18,6 +18,7 @@ class LoraStackerLM:
"placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}),
"loras": ("LORAS", {}),
},
"optional": FlexibleOptionalInputType(any_type),
}
@@ -31,8 +32,8 @@ class LoraStackerLM:
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
FUNCTION = "stack_loras"
def stack_loras(self, text, **kwargs):
"""Stacks multiple LoRAs based on the kwargs input without loading them."""
def stack_loras(self, text, loras, **kwargs):
"""Stacks multiple LoRAs based on the widget input without loading them."""
stack = []
active_loras = []
all_trigger_words = []
@@ -47,8 +48,8 @@ class LoraStackerLM:
_, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words)
# Process loras from kwargs with support for both old and new formats
loras_list = get_loras_list(kwargs)
# Process loras from the widget with support for both old and new formats
loras_list = get_loras_list({"loras": loras})
for lora in loras_list:
if not lora.get('active', False):
continue
+8
View File
@@ -778,6 +778,14 @@ class SaveImageLM:
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
# The recipe image is the WebP produced above from the output file;
# reuse the same metadata extraction to record workflow presence.
try:
metadata = ExifUtils._load_structured_metadata(image_path)
recipe_data["has_workflow"] = bool(metadata.get("workflow"))
except Exception:
recipe_data["has_workflow"] = False
json_path = os.path.normpath(
os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
)
+77 -2
View File
@@ -28,6 +28,10 @@ class UNETLoaderLM:
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for UNET loading.
Supports both regular diffusion models and GGUF format models.
The unet_name combo supports ComfyUI's control_after_generate, letting
users pick a random diffusion model on every run; the base_model input
narrows the random pool through a front-end extension that filters the
combo options.
"""
NAME = "Unet Loader (LoraManager)"
@@ -37,16 +41,34 @@ class UNETLoaderLM:
def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
{
"tooltip": (
"The name of the diffusion model to load. Use "
"control_after_generate to pick a random model on "
"every run."
),
"control_after_generate": "fixed",
},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": (
"Restrict the random selection pool to this base "
"model. 'Any' uses the full pool."
),
},
),
}
}
@@ -108,16 +130,69 @@ class UNETLoaderLM:
logger.error(f"Error getting unet names: {e}")
return []
def load_unet(self, unet_name: str, weight_dtype: str) -> Tuple[Any, ...]:
@classmethod
def _get_available_base_models(cls) -> List[str]:
"""Get distinct base_model values present among indexed diffusion models, for the random-selection filter."""
try:
from ..services.service_registry import ServiceRegistry
async def _get_base_models():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
base_models = set()
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
base_model = item.get("base_model")
file_path = item.get("file_path", "")
if base_model and file_path and os.path.exists(file_path):
base_models.add(base_model)
return sorted(base_models)
return ["Any"] + cls._run_async(_get_base_models)
except Exception as e:
logger.error(f"Error getting available base models: {e}")
return ["Any"]
@staticmethod
def _run_async(coro_fn):
"""Run an async fetcher, handling the case where an event loop is already running."""
import asyncio
try:
asyncio.get_running_loop()
import concurrent.futures
def run_in_thread():
new_loop = asyncio.new_event_loop()
asyncio.set_event_loop(new_loop)
try:
return new_loop.run_until_complete(coro_fn())
finally:
new_loop.close()
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(run_in_thread)
return future.result()
except RuntimeError:
return asyncio.run(coro_fn())
def load_unet(
self, unet_name: str, weight_dtype: str, base_model: str = "Any"
) -> Tuple[Any, ...]:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
base_model: Only used by the front-end to filter the random pool
Returns:
Tuple of (MODEL,)
"""
del base_model
import torch
# Get absolute path from cache using ComfyUI-style name
+4 -3
View File
@@ -31,6 +31,7 @@ class WanVideoLoraSelectLM:
"placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}),
"loras": ("LORAS", {}),
},
"optional": FlexibleOptionalInputType(any_type),
}
@@ -44,7 +45,7 @@ class WanVideoLoraSelectLM:
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_loras"
def process_loras(self, text, low_mem_load=False, merge_loras=True, **kwargs):
def process_loras(self, text, loras, low_mem_load=False, merge_loras=True, **kwargs):
loras_list = []
all_trigger_words = []
active_loras = []
@@ -62,8 +63,8 @@ class WanVideoLoraSelectLM:
selected_blocks = blocks.get("selected_blocks", {})
layer_filter = blocks.get("layer_filter", "")
# Process loras from kwargs with support for both old and new formats
loras_from_widget = get_loras_list(kwargs)
# Process loras from the widget with support for both old and new formats
loras_from_widget = get_loras_list({"loras": loras})
for lora in loras_from_widget:
if not lora.get('active', False):
continue
+34
View File
@@ -41,6 +41,40 @@ class RecipeMetadataParser(ABC):
"""
pass
@staticmethod
def populate_lora_from_local(lora_entry: Dict[str, Any], local_lora: Dict[str, Any], base_model_counts=None) -> Dict[str, Any]:
"""Populate a recipe LoRA entry from the local scanner cache."""
local_path = local_lora.get('file_path') or ''
file_name = local_lora.get('file_name') or os.path.splitext(os.path.basename(local_path))[0]
base_model = local_lora.get('base_model') or ''
lora_entry['name'] = local_lora.get('model_name') or file_name or lora_entry.get('name', '')
lora_entry['file_name'] = file_name
lora_entry['hash'] = (local_lora.get('sha256') or lora_entry.get('hash') or '').lower()
lora_entry['localPath'] = local_path or None
lora_entry['size'] = local_lora.get('size', 0) or 0
lora_entry['baseModel'] = base_model
lora_entry['existsLocally'] = True
lora_entry['isDeleted'] = False
preview_url = local_lora.get('preview_url')
if preview_url:
lora_entry['thumbnailUrl'] = config.get_preview_static_url(preview_url)
civitai_info = local_lora.get('civitai') or {}
if isinstance(civitai_info, dict):
if civitai_info.get('id') is not None:
lora_entry['id'] = civitai_info['id']
if civitai_info.get('modelId') is not None:
lora_entry['modelId'] = civitai_info['modelId']
if civitai_info.get('name'):
lora_entry['version'] = civitai_info['name']
if base_model_counts is not None and base_model:
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
return lora_entry
@staticmethod
async def populate_lora_from_civitai(lora_entry: Dict[str, Any], civitai_info_tuple: Tuple[Dict[str, Any] | None, str | None] | Dict[str, Any],
recipe_scanner=None, base_model_counts=None, hash_value=None) -> Optional[Dict[str, Any]]:
+201 -61
View File
@@ -362,68 +362,208 @@ class AutomaticMetadataParser(RecipeMetadataParser):
checkpoint = checkpoint_entry
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"]
if not loras or len(loras) == 0:
# Extract lora weights from extranet tags in prompt (for later use)
lora_weights = {}
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt)
for lora_type, lora_name, lora_weight in lora_matches:
key = f"{lora_type}:{lora_name}"
lora_weights[key] = round(float(lora_weight), 2)
# Use hashes from metadata as the primary source
if metadata.get("hashes"):
for hash_key, lora_hash in metadata.get("hashes", {}).items():
# Only process lora or hypernet types
if not hash_key.startswith(("lora:", "hypernet:")):
def normalize_lora_name(name, basename=False):
normalized = str(name or '').replace('\\', '/')
if normalized.casefold().endswith('.safetensors'):
normalized = normalized[:-12]
if basename:
normalized = normalized.rsplit('/', 1)[-1]
return normalized.casefold()
def get_version_id(lora):
version_id = lora.get('id')
if version_id in (None, '', 0, '0'):
version_id = lora.get('modelVersionId')
if version_id in (None, '', 0, '0'):
return None
return str(version_id)
prompt_loras = {}
for match in re.findall(self.EXTRANETS_REGEX, prompt):
lora_type, lora_name, _ = match
prompt_loras[(lora_type, normalize_lora_name(lora_name))] = match
prompt_by_basename = {}
for lora_type, lora_name, lora_weight in prompt_loras.values():
key = (lora_type, normalize_lora_name(lora_name, True))
prompt_by_basename.setdefault(key, []).append((lora_name, round(float(lora_weight), 2)))
hash_basenames = {
(hash_key.split(':', 1)[0], normalize_lora_name(hash_key.split(':', 1)[1], True))
for hash_key, hash_value in metadata.get("hashes", {}).items()
if hash_value and hash_key.startswith(("lora:", "hypernet:"))
}
recipe_base_model = checkpoint.get("baseModel") if checkpoint else None
if not recipe_base_model and len(base_model_counts) == 1:
recipe_base_model = next(iter(base_model_counts))
resource_lora_count = len(loras)
def make_lora_entry(lora_type, lora_name, weight, lora_hash=''):
return {
'name': lora_name,
'type': lora_type,
'weight': weight,
'hash': lora_hash,
'existsLocally': False,
'localPath': None,
'file_name': lora_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
def merge_or_append_civitai(civitai_entry, preserve_existing_weight=False):
civitai_id = get_version_id(civitai_entry)
civitai_hash = (civitai_entry.get('hash') or '').lower()
for index, existing in enumerate(loras):
existing_id = get_version_id(existing)
existing_hash = (existing.get('hash') or '').lower()
if not (
(civitai_id and existing_id == civitai_id)
or (civitai_hash and existing_hash == civitai_hash)
):
continue
if preserve_existing_weight:
civitai_entry['weight'] = existing.get('weight', civitai_entry['weight'])
existing_base = existing.get('baseModel')
if not civitai_entry.get('baseModel'):
civitai_entry['baseModel'] = existing_base or ''
elif existing_base:
remaining = base_model_counts.get(existing_base, 0) - 1
if remaining > 0:
base_model_counts[existing_base] = remaining
else:
base_model_counts.pop(existing_base, None)
loras[index] = civitai_entry
return
loras.append(civitai_entry)
def merge_or_append_local(local_entry):
local_id = get_version_id(local_entry)
local_hash = (local_entry.get('hash') or '').lower()
for existing in loras:
existing_id = get_version_id(existing)
existing_hash = (existing.get('hash') or '').lower()
if not (
(local_id and existing_id == local_id)
or (local_hash and existing_hash == local_hash)
):
continue
existing['weight'] = local_entry['weight']
existing['hash'] = local_entry['hash']
existing['file_name'] = local_entry['file_name']
existing['existsLocally'] = True
existing['localPath'] = local_entry['localPath']
existing['size'] = local_entry['size']
existing['isDeleted'] = False
if not existing.get('modelId') and local_entry.get('modelId'):
existing['modelId'] = local_entry['modelId']
if not existing.get('baseModel') and local_entry.get('baseModel'):
existing['baseModel'] = local_entry['baseModel']
base_model_counts[local_entry['baseModel']] = base_model_counts.get(local_entry['baseModel'], 0) + 1
thumbnail_url = local_entry.get('thumbnailUrl')
if thumbnail_url and not thumbnail_url.endswith('/images/no-preview.png'):
existing['thumbnailUrl'] = thumbnail_url
return
if local_entry.get('baseModel'):
base_model = local_entry['baseModel']
base_model_counts[base_model] = base_model_counts.get(base_model, 0) + 1
loras.append(local_entry)
resolved_prompt_basenames = set()
queried_local_basenames = set()
for lora_type, lora_name, lora_weight in prompt_loras.values():
weight = round(float(lora_weight), 2)
basename_key = (lora_type, normalize_lora_name(lora_name, True))
matching_resources = [
lora
for lora in loras[:resource_lora_count]
if lora.get('file_name')
and normalize_lora_name(lora['file_name'], True) == basename_key[1]
and (
(lora_type == 'hypernet' and str(lora.get('type', '')).casefold() in ('hypernet', 'hypernetwork'))
or (lora_type == 'lora' and str(lora.get('type', '')).casefold() not in ('hypernet', 'hypernetwork'))
)
]
if len(prompt_by_basename[basename_key]) == 1 and len(matching_resources) == 1:
matching_resources[0]['weight'] = weight
if basename_key not in hash_basenames:
resolved_prompt_basenames.add(basename_key)
continue
if basename_key in hash_basenames:
continue
if not recipe_scanner or lora_type != 'lora':
continue
queried_local_basenames.add(basename_key)
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if not local_lora:
continue
local_entry = self.populate_lora_from_local(
make_lora_entry(lora_type, lora_name, weight),
local_lora,
)
merge_or_append_local(local_entry)
resolved_prompt_basenames.add(basename_key)
for hash_key, lora_hash in metadata.get("hashes", {}).items():
if not hash_key.startswith(("lora:", "hypernet:")):
continue
lora_type, lora_name = hash_key.split(':', 1)
basename_key = (lora_type, normalize_lora_name(lora_name, True))
if basename_key in resolved_prompt_basenames:
continue
prompt_entries = prompt_by_basename.get(basename_key, [])
weight = prompt_entries[0][1] if len(prompt_entries) == 1 else 1.0
lora_entry = make_lora_entry(lora_type, lora_name, weight, lora_hash)
if lora_hash and recipe_scanner and lora_type == 'lora':
local_lora = await recipe_scanner.get_local_lora_by_hash(lora_hash)
if local_lora:
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
merge_or_append_local(local_entry)
continue
hash_resolved = False
if lora_hash and metadata_provider:
try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
lora_hash,
)
if populated_entry is None:
continue
# Skip entries without a hash value — they can't be
# resolved via CivitAI and would only produce a
# useless "Deleted" entry in the recipe.
if not lora_hash:
continue
lora_type, lora_name = hash_key.split(':', 1)
# Get weight from extranet tags if available, else default to 1.0
weight = lora_weights.get(hash_key, 1.0)
# Initialize lora entry
lora_entry = {
'name': lora_name,
'type': lora_type, # 'lora' or 'hypernet'
'weight': weight,
'hash': lora_hash,
'existsLocally': False,
'localPath': None,
'file_name': lora_name,
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Try to get info from Civitai
if metadata_provider:
try:
civitai_info = await metadata_provider.get_model_by_hash(lora_hash)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info,
recipe_scanner,
base_model_counts,
lora_hash
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
loras.append(lora_entry)
lora_entry = populated_entry
hash_resolved = not lora_entry.get('isDeleted')
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
if hash_resolved:
merge_or_append_civitai(lora_entry, preserve_existing_weight=not prompt_entries)
continue
if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if local_lora:
local_entry = self.populate_lora_from_local(lora_entry, local_lora)
merge_or_append_local(local_entry)
continue
if lora_hash and not resource_lora_count:
loras.append(lora_entry)
# Try to get base model from resources or make educated guess
base_model = None
+95 -68
View File
@@ -31,79 +31,15 @@ class ComfyMetadataParser(RecipeMetadataParser):
metadata_provider = await get_default_metadata_provider()
data = json.loads(user_comment)
loras = []
# Find all LoraLoader nodes
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
# Process each LoraLoader node
for node_id, node in lora_nodes.items():
if 'inputs' not in node or 'lora_name' not in node['inputs']:
continue
lora_name = node['inputs'].get('lora_name', '')
# Parse the URN to extract model ID and version ID
# Format: "urn:air:sdxl:lora:civitai:1107767@1253442"
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
if not lora_id_match:
continue
model_id = lora_id_match.group(1)
model_version_id = lora_id_match.group(2)
# Get strength from node inputs
weight = node['inputs'].get('strength_model', 1.0)
# Initialize lora entry with default values
lora_entry = {
'id': model_version_id,
'modelId': model_id,
'name': f"Lora {model_id}", # Default name
'version': '',
'type': 'lora',
'weight': weight,
'existsLocally': False,
'localPath': None,
'file_name': '',
'hash': '',
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
# Get additional info from Civitai if metadata provider is available
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
# Populate lora entry with Civitai info
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info_tuple,
recipe_scanner
)
if populated_entry is None:
continue # Skip invalid LoRA types
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
loras.append(lora_entry)
# Find checkpoint info
checkpoint_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
checkpoint = None
checkpoint_id = None
checkpoint_version_id = None
if checkpoint_nodes:
# Get the first checkpoint node
checkpoint_node = next(iter(checkpoint_nodes.values()))
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
checkpoint_name = checkpoint_node['inputs']['ckpt_name']
# Parse checkpoint URN
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
if checkpoint_match:
checkpoint_id = checkpoint_match.group(1)
@@ -115,16 +51,107 @@ class ComfyMetadataParser(RecipeMetadataParser):
'version': '',
'type': 'checkpoint'
}
# Get additional checkpoint info from Civitai
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id)
civitai_info, _ = civitai_info_tuple if isinstance(civitai_info_tuple, tuple) else (civitai_info_tuple, None)
# Populate checkpoint with Civitai info
checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint: {e}")
recipe_base_model = checkpoint.get('baseModel') if checkpoint else None
loras = []
lora_candidates = []
for node in data.values():
if not isinstance(node, dict):
continue
inputs = node.get('inputs')
if not isinstance(inputs, dict):
continue
if node.get('class_type') == 'LoraLoader':
lora_name = inputs.get('lora_name', '')
if isinstance(lora_name, str) and lora_name:
lora_candidates.append((lora_name, inputs.get('strength_model', 1.0)))
continue
if node.get('class_type') != 'LoraLoaderLM':
continue
loras_data = inputs.get('loras', [])
if isinstance(loras_data, dict):
loras_data = loras_data.get('__value__', [])
if isinstance(loras_data, list) and len(loras_data) == 1 and isinstance(loras_data[0], list):
loras_data = loras_data[0]
if not isinstance(loras_data, list):
continue
for lora in loras_data:
if not isinstance(lora, dict) or not lora.get('active', False) or lora.get('_isDummy', False):
continue
lora_name = lora.get('name', '')
if isinstance(lora_name, str) and lora_name:
lora_candidates.append((lora_name, lora.get('strength', 1.0)))
for lora_name, weight in lora_candidates:
if isinstance(weight, str):
try:
weight = float(weight)
except ValueError:
weight = 1.0
lora_id_match = re.search(r'civitai:(\d+)@(\d+)', lora_name)
if lora_id_match:
model_id = lora_id_match.group(1)
model_version_id = lora_id_match.group(2)
entry_name = f"Lora {model_id}"
else:
model_id = 0
model_version_id = 0
entry_name = re.split(r'[\\/]', lora_name)[-1]
entry_name = re.sub(r'\.[^.]+$', '', entry_name)
lora_entry = {
'id': model_version_id,
'modelId': model_id,
'name': entry_name,
'version': '',
'type': 'lora',
'weight': weight,
'existsLocally': False,
'localPath': None,
'file_name': entry_name,
'hash': '',
'thumbnailUrl': '/loras_static/images/no-preview.png',
'baseModel': '',
'size': 0,
'downloadUrl': '',
'isDeleted': False
}
if lora_id_match:
if metadata_provider:
try:
civitai_info_tuple = await metadata_provider.get_model_version_info(model_version_id)
populated_entry = await self.populate_lora_from_civitai(
lora_entry,
civitai_info_tuple,
recipe_scanner
)
if populated_entry is None:
continue
lora_entry = populated_entry
except Exception as e:
logger.error(f"Error fetching Civitai info for LoRA: {e}")
else:
if not recipe_scanner:
continue
local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if not local_lora:
continue
lora_entry = self.populate_lora_from_local(lora_entry, local_lora)
loras.append(lora_entry)
# Extract generation parameters
gen_params = {}
+14
View File
@@ -32,6 +32,7 @@ from .handlers.recipe_handlers import (
RecipePageView,
RecipeQueryHandler,
RecipeSharingHandler,
RecipeWorkflowHandler,
)
from .recipe_route_registrar import ROUTE_DEFINITIONS
@@ -200,6 +201,18 @@ class BaseRecipeRoutes:
sharing_service=sharing_service,
)
# Lazy import: standalone mode replaces the ``server`` module with a
# mock, so resolve PromptServer at handler-set build time instead of
# module import time. The handler's standalone check guards UX.
from server import PromptServer # pyright: ignore[reportMissingImports]
workflow = RecipeWorkflowHandler(
ensure_dependencies_ready=self.ensure_dependencies_ready,
recipe_scanner_getter=recipe_scanner_getter,
prompt_server=PromptServer,
logger=logger,
)
from ..services.websocket_manager import ws_manager
batch_import_service = BatchImportService(
@@ -224,4 +237,5 @@ class BaseRecipeRoutes:
analysis=analysis,
sharing=sharing,
batch_import=batch_import,
workflow=workflow,
)
+40
View File
@@ -1,4 +1,5 @@
import logging
import os
from typing import Any, Dict, List, Set
from aiohttp import web
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
from ..services.checkpoint_service import CheckpointService
from ..services.service_registry import ServiceRegistry
from ..config import config
from ..utils.utils import _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
@@ -44,7 +46,45 @@ class CheckpointRoutes(BaseModelRoutes):
# Checkpoint roots and Unet roots
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/checkpoints_roots', prefix, self.get_checkpoints_roots)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_roots)
# Name/base_model pool for the Random Checkpoint/Unet Loader nodes
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/loader-pool', prefix, self.get_loader_pool)
async def get_loader_pool(self, request: web.Request) -> web.Response:
"""Return ComfyUI-formatted model names with their base_model.
Backing data for the Random Checkpoint/Unet Loader nodes: the front-end
filters the ckpt_name/unet_name combo options by base_model using this
pool, so control_after_generate randomizes within the narrowed set.
"""
try:
sub_type = request.query.get("sub_type", "checkpoint")
if sub_type not in ("checkpoint", "diffusion_model"):
return web.json_response({"error": "invalid sub_type"}, status=400)
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
model_roots = scanner.get_model_roots()
items: List[Dict[str, str]] = []
for item in cache.raw_data:
if item.get("sub_type") != sub_type:
continue
file_path = item.get("file_path", "")
if not file_path or not os.path.exists(file_path):
continue
formatted_name = _format_model_name_for_comfyui(file_path, model_roots)
if formatted_name:
items.append(
{
"name": formatted_name,
"base_model": item.get("base_model", "") or "",
}
)
items.sort(key=lambda x: x["name"])
return web.json_response({"items": items})
except Exception as e:
logger.error(f"Error getting loader pool: {e}", exc_info=True)
return web.json_response({"error": str(e)}, status=500)
def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Checkpoint"""
return model_type.lower() == 'checkpoint'
+92 -9
View File
@@ -56,6 +56,7 @@ from ...utils.constants import (
)
from .hf_handlers import HfHandler
from .agent_handlers import AgentHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root,
@@ -2061,6 +2062,63 @@ class ModelLibraryHandler:
enriched.append(entry)
return enriched
@staticmethod
async def _get_downloaded_files(
scanner: Any, model_version_id: int
) -> list[dict[str, Any]]:
"""Return per-file downloaded state for a version in the library.
This handler has no CivitAI version payload, so the remote file list
is taken from the local entries' cached ``civitai`` metadata (the
full version payload persisted at download time, see
``BaseModelMetadata.from_civitai_info``) and matched with the same
D2 rule used by ``get_civitai_versions`` (#1058). Local entries that
cannot be matched to a known remote file (e.g. missing metadata or
renamed files) are still reported with ``fileId`` set to None.
Returns ``[{fileId, fileName, filePath}]``.
"""
try:
cache = await scanner.get_cached_data()
except Exception: # pragma: no cover - defensive fallback
logger.debug(
"Failed to read cache for downloaded files of version %s",
model_version_id,
exc_info=True,
)
return []
files_getter = getattr(cache, "get_files_by_version_id", None)
local_entries = files_getter(model_version_id) if files_getter else []
if not local_entries:
return []
version_payload: Mapping[str, Any] = {}
for entry in local_entries:
civitai = entry.get("civitai") if isinstance(entry, Mapping) else None
if isinstance(civitai, Mapping) and isinstance(civitai.get("files"), list):
version_payload = civitai
break
downloaded = ModelCivitaiHandler._match_downloaded_files(
version_payload, local_entries
)
# Surface local files that D2 could not map to a known remote file
matched_paths = {item.get("filePath") for item in downloaded}
for entry in local_entries:
if not isinstance(entry, Mapping):
continue
if entry.get("file_path") in matched_paths:
continue
downloaded.append(
{
"fileId": None,
"fileName": entry.get("file_name"),
"filePath": entry.get("file_path"),
}
)
return downloaded
async def check_model_exists(self, request: web.Request) -> web.Response:
try:
model_id_str = request.query.get("modelId")
@@ -2096,9 +2154,11 @@ class ModelLibraryHandler:
exists = False
model_type = None
matched_scanner = None
if await lora_scanner.check_model_version_exists(model_version_id):
exists = True
model_type = "lora"
matched_scanner = lora_scanner
elif (
checkpoint_scanner
and await checkpoint_scanner.check_model_version_exists(
@@ -2107,6 +2167,7 @@ class ModelLibraryHandler:
):
exists = True
model_type = "checkpoint"
matched_scanner = checkpoint_scanner
elif (
embedding_scanner
and await embedding_scanner.check_model_version_exists(
@@ -2115,6 +2176,7 @@ class ModelLibraryHandler:
):
exists = True
model_type = "embedding"
matched_scanner = embedding_scanner
if exists:
return web.json_response(
@@ -2123,6 +2185,9 @@ class ModelLibraryHandler:
"exists": True,
"modelType": model_type,
"hasBeenDownloaded": False,
"downloadedFiles": await self._get_downloaded_files(
matched_scanner, model_version_id
),
}
)
@@ -2144,6 +2209,7 @@ class ModelLibraryHandler:
"exists": False,
"modelType": history_type,
"hasBeenDownloaded": has_been_downloaded,
"downloadedFiles": [],
}
)
@@ -2428,8 +2494,8 @@ class ModelLibraryHandler:
embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None
file_path = None
found_cache = None
entries: list = []
for model_type, scanner in (
("lora", lora_scanner),
@@ -2440,27 +2506,43 @@ class ModelLibraryHandler:
if cache and model_version_id in cache.version_index:
found_type = model_type
found_cache = cache
entry = cache.version_index[model_version_id]
file_path = entry.get("file_path")
# A version can have several local files (#1058); collect
# them all so the delete below covers every file.
files_getter = getattr(cache, "get_files_by_version_id", None)
if files_getter is not None:
entries = files_getter(model_version_id)
else:
entries = [cache.version_index[model_version_id]]
break
if not file_path:
file_paths = [
entry.get("file_path")
for entry in entries
if isinstance(entry, dict) and entry.get("file_path")
]
if not file_paths:
return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"},
status=404,
)
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
for file_path in file_paths:
target_dir = os.path.dirname(file_path)
base_name = os.path.basename(file_path)
file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache:
removed_paths = set(file_paths)
found_cache.raw_data = [
item
for item in found_cache.raw_data
if item.get("file_path") != file_path
if item.get("file_path") not in removed_paths
]
rebuild = getattr(found_cache, "rebuild_version_index", None)
if rebuild is not None:
rebuild()
await found_cache.resort()
scanner_map = {
@@ -2483,6 +2565,7 @@ class ModelLibraryHandler:
"success": True,
"modelType": found_type,
"modelVersionId": model_version_id,
"deletedFiles": len(file_paths),
}
)
except Exception as exc:
+75 -2
View File
@@ -1659,7 +1659,8 @@ class ModelDownloadHandler:
import json
try:
data["file_params"] = json.loads(file_params_json)
# Normalize falsy payloads (e.g. {}) to None (#1058)
data["file_params"] = json.loads(file_params_json) or None
except json.JSONDecodeError:
self._logger.warning(
"Invalid file_params JSON: %s", file_params_json
@@ -1811,7 +1812,8 @@ class ModelDownloadHandler:
model_id = int(model_id_str) if model_id_str else None
model_version_id = int(model_version_id_str) if model_version_id_str else None
file_params = json.loads(file_params_json) if file_params_json else None
# Normalize falsy payloads (e.g. {}) to None (#1058)
file_params = (json.loads(file_params_json) if file_params_json else None) or None
service = await DownloadQueueService.get_instance()
item = await service.add_to_queue(
@@ -2187,6 +2189,19 @@ class ModelCivitaiHandler:
else:
version.pop("localPath", None)
# Per-file downloaded state so multi-file versions can show
# which individual files are already in the library (#1058)
local_entries: List[Any] = []
if version_id is not None and cache:
files_getter = getattr(cache, "get_files_by_version_id", None)
if files_getter is not None:
local_entries = files_getter(version_id)
elif cache_entry is not None:
local_entries = [cache_entry]
version["downloadedFiles"] = self._match_downloaded_files(
version, local_entries
)
model_file = (
self._find_model_file(version.get("files", []))
if isinstance(version.get("files"), Iterable)
@@ -2201,6 +2216,64 @@ class ModelCivitaiHandler:
)
return web.Response(status=500, text=str(exc))
@staticmethod
def _match_downloaded_files(
version: Mapping[str, Any], local_entries: List[Any]
) -> List[Dict[str, Any]]:
"""Map local library entries back to individual files of a version.
Matching follows rule D2 (#1058): SHA256 is authoritative when the
local entry carries one; otherwise fall back to extension-less file
name equality. Returns ``[{fileId, fileName, filePath}]``.
"""
files = version.get("files")
if not isinstance(files, list) or not local_entries:
return []
by_hash: Dict[str, Mapping[str, Any]] = {}
by_name: Dict[str, Mapping[str, Any]] = {}
for file_info in files:
if not isinstance(file_info, Mapping):
continue
sha = str(
(file_info.get("hashes") or {}).get("SHA256") or ""
).strip().lower()
if sha:
by_hash.setdefault(sha, file_info)
name = str(file_info.get("name") or "").strip()
if name:
by_name.setdefault(os.path.splitext(name)[0], file_info)
downloaded: List[Dict[str, Any]] = []
seen_keys: set = set()
for entry in local_entries:
if not isinstance(entry, Mapping):
continue
matched: Optional[Mapping[str, Any]] = None
local_hash = str(entry.get("sha256") or "").strip().lower()
if local_hash:
matched = by_hash.get(local_hash)
if matched is None:
local_name = str(entry.get("file_name") or "").strip()
if local_name:
matched = by_name.get(local_name)
if matched is None:
continue
file_id = matched.get("id")
dedupe_key = file_id if file_id is not None else matched.get("name")
if dedupe_key in seen_keys:
continue
seen_keys.add(dedupe_key)
downloaded.append(
{
"fileId": file_id,
"fileName": matched.get("name"),
"filePath": entry.get("file_path"),
}
)
return downloaded
async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
try:
model_version_id = request.match_info.get("modelVersionId")
+99 -1
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Tuple
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Protocol, Tuple
from aiohttp import web
@@ -45,6 +45,17 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = Callable[[], Any]
class PromptServerProtocol(Protocol):
"""Subset of PromptServer used by the recipe workflow handler."""
instance: "PromptServerProtocol"
def send_sync(
self, event: str, payload: dict[str, Any] | None = None, sid: str | None = None
) -> None: # pragma: no cover - protocol
...
# Cap concurrent preview-dimension reads across requests. With a cold LRU
# cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop.
@@ -73,6 +84,7 @@ class RecipeHandlerSet:
analysis: "RecipeAnalysisHandler"
sharing: "RecipeSharingHandler"
batch_import: "BatchImportHandler"
workflow: "RecipeWorkflowHandler"
def to_route_mapping(
self,
@@ -128,6 +140,7 @@ class RecipeHandlerSet:
"import_from_url": self.management.import_from_url,
"create_from_example": self.management.create_from_example,
"reimport_recipe": self.management.reimport_recipe,
"send_recipe_workflow": self.workflow.send_recipe_workflow,
}
@@ -2755,6 +2768,91 @@ class RecipeSharingHandler:
return web.json_response({"error": str(exc)}, status=500)
class RecipeWorkflowHandler:
"""Extract an embedded workflow from a recipe image and broadcast it."""
def __init__(
self,
*,
ensure_dependencies_ready: EnsureDependenciesCallable,
recipe_scanner_getter: RecipeScannerGetter,
prompt_server: type[PromptServerProtocol],
logger: Logger,
) -> None:
self._ensure_dependencies_ready = ensure_dependencies_ready
self._recipe_scanner_getter = recipe_scanner_getter
self._prompt_server = prompt_server
self._logger = logger
async def send_recipe_workflow(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
recipe_id = request.match_info["recipe_id"]
recipe = await recipe_scanner.get_recipe_by_id(recipe_id)
if not recipe:
return web.json_response({"error": "Recipe not found"}, status=404)
if os.environ.get("LORA_MANAGER_STANDALONE", "0") == "1":
return web.json_response(
{"error": "Standalone Mode Active"}, status=400
)
image_path = recipe.get("file_path")
if not image_path:
return web.json_response({"error": "no_workflow"}, status=404)
metadata = await asyncio.to_thread(
ExifUtils._load_structured_metadata, image_path
)
workflow_raw = metadata.get("workflow")
if not workflow_raw:
return web.json_response(
{
"error": "no_workflow",
"message": "No embedded workflow found in recipe image",
},
status=404,
)
# _load_structured_metadata always yields workflow as a JSON string;
# the frontend extension expects a parsed object for loadGraphData.
try:
workflow = (
json.loads(workflow_raw)
if isinstance(workflow_raw, str)
else workflow_raw
)
except (TypeError, ValueError):
self._logger.warning(
"Recipe %s embeds a non-JSON workflow payload; skipping send",
recipe_id,
)
return web.json_response(
{
"error": "no_workflow",
"message": "Embedded workflow data is not valid JSON",
},
status=404,
)
self._prompt_server.instance.send_sync(
"lm_load_workflow",
{
"workflow": workflow,
"name": recipe.get("title") or "",
"recipe_id": recipe_id,
},
)
return web.json_response({"success": True, "sent": True})
except Exception as exc:
self._logger.error("Error sending recipe workflow: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
class BatchImportHandler:
"""Handle batch import operations for recipes."""
+3
View File
@@ -90,6 +90,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
),
)
+3 -1
View File
@@ -87,7 +87,9 @@ class DownloadCoordinator:
progress_callback=progress_callback,
download_id=download_id,
source=payload.get("source"),
file_params=payload.get("file_params"),
# Normalize falsy file_params (e.g. {}) to None so download gates
# treat it as "no explicit file selection" (#1058).
file_params=payload.get("file_params") or None,
)
result["download_id"] = download_id
+237 -69
View File
@@ -213,6 +213,162 @@ class DownloadManager:
)
return False
async def _get_scanner_for_model_type(self, model_type: str):
"""Return the scanner responsible for the given model type."""
if model_type == "checkpoint":
return await self._get_checkpoint_scanner()
if model_type == "embedding":
return await ServiceRegistry.get_embedding_scanner()
return await self._get_lora_scanner()
@staticmethod
def _resolve_target_file(
files: Any, file_params: Dict[str, Any] | None
) -> Optional[Dict[str, Any]]:
"""Resolve the target file within a version's file list from file_params.
Shared by the existence gate and the actual file selection so both
always agree on which file a download refers to (#1058). Returns None
when file_params is None or no file matches.
"""
if not file_params or not isinstance(files, list):
return None
target_file_id = file_params.get("id")
target_type = file_params.get("type", "Model")
target_format = file_params.get("format")
target_size = file_params.get("size")
target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False)
logger.debug(
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, "
"isPrimary=%s, total_files=%d",
target_file_id, target_type, target_format, target_size, target_fp,
is_primary, len(files),
)
file_info: Optional[Dict[str, Any]] = None
if target_file_id:
target_id_str = str(target_file_id)
for f in files:
if not isinstance(f, dict):
continue
f_id = f.get("id")
if str(f_id) == target_id_str:
file_info = f
logger.debug(
"[download] MATCH by ID: id=%s name='%s'",
f_id, f.get("name"),
)
break
if not file_info:
logger.debug("[download] No file found with id=%s", target_file_id)
elif is_primary:
file_info = next(
(
f
for f in files
if isinstance(f, dict)
and f.get("primary")
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
),
None,
)
else:
# Lenient metadata match: only compare fields present on both sides
for f in files:
if not isinstance(f, dict):
continue
f_type = f.get("type", "")
if f_type != target_type:
continue
f_meta = f.get("metadata", {})
f_format = f_meta.get("format") or f.get("format")
f_size = f_meta.get("size") or f.get("size")
f_fp = f_meta.get("fp") or f.get("fp")
if target_format and f_format != target_format:
continue
if target_size and f_size and f_size != target_size:
continue
if target_fp and f_fp and f_fp != target_fp:
continue
file_info = f
break
return file_info
async def _find_local_file_entry(
self,
model_type: str,
model_version_id: int,
target_file: Dict[str, Any],
) -> Optional[Dict[str, Any]]:
"""Find a local library entry for a specific file of a model version.
Matches per design rule D2 (#1058): SHA256 is only compared when both
sides carry a non-empty hash; otherwise fall back to (extension-less)
file name equality. Never let two empty hashes compare equal.
"""
try:
normalized_version_id = int(model_version_id)
except (TypeError, ValueError):
return None
try:
scanner = await self._get_scanner_for_model_type(model_type)
cache = await scanner.get_cached_data()
except Exception as exc:
logger.debug(
"Failed to scan local entries for version %s file check: %s",
model_version_id,
exc,
)
return None
raw_data = getattr(cache, "raw_data", None) if cache else None
if not raw_data:
return None
target_hash = str(
(target_file.get("hashes") or {}).get("SHA256") or ""
).strip().lower()
target_name = str(target_file.get("name") or "").strip()
target_base = os.path.splitext(target_name)[0] if target_name else ""
for item in raw_data:
if not isinstance(item, dict):
continue
civitai_data = item.get("civitai")
if not isinstance(civitai_data, dict):
continue
try:
item_version_id = int(civitai_data.get("id"))
except (TypeError, ValueError):
continue
if item_version_id != normalized_version_id:
continue
local_hash = str(item.get("sha256") or "").strip().lower()
if target_hash and local_hash:
if local_hash == target_hash:
return item
# Both sides carry hashes that differ: this is a different
# file of the same version — do not fall back to name match.
continue
if target_base:
local_name = str(item.get("file_name") or "").strip()
if local_name == target_base:
return item
return None
async def download_from_civitai(
self,
model_id: int | None = None,
@@ -242,6 +398,10 @@ class DownloadManager:
Returns:
Dict with download result
"""
# Normalize falsy file_params (e.g. an empty dict from API JSON
# parsing) to None so gate conditions behave consistently (#1058).
file_params = file_params or None
logger.debug(
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
"source=%s, file_params=%s",
@@ -816,6 +976,7 @@ class DownloadManager:
version_info,
record.get("model_version_id"),
record.get("save_path") or record.get("file_path"),
file_info=file_info,
)
await self._sync_downloaded_version(
model_type,
@@ -1152,9 +1313,13 @@ class DownloadManager:
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Wrapper for original download_from_civitai implementation"""
file_params = file_params or None
try:
# Check if model version already exists in library
if model_version_id is not None:
# Check if model version already exists in library.
# With an explicit file selection (file_params) the version-level
# check is deferred until after the metadata fetch, when the target
# file can be resolved and checked individually (#1058).
if model_version_id is not None and file_params is None:
# Check both scanners
lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner()
@@ -1235,8 +1400,26 @@ class DownloadManager:
except (TypeError, ValueError):
resolved_version_id = None
# Resolve the explicitly selected file (if any) up front so the
# existence gates and the actual file selection below always agree
# on the target file (#1058).
target_file: Optional[Dict[str, Any]] = None
if file_params is not None:
target_file = self._resolve_target_file(
version_info.get("files") or [], file_params
)
if target_file is None:
logger.warning(
"[download] file_params provided but no file matched; "
"falling back to version-level checks and primary file "
"selection (model_version_id=%s)",
resolved_version_id,
)
explicit_file = target_file is not None
if (
get_settings_manager().get_skip_previously_downloaded_model_versions()
not explicit_file
and get_settings_manager().get_skip_previously_downloaded_model_versions()
and resolved_version_id is not None
and await self._has_been_downloaded(model_type, resolved_version_id)
):
@@ -1346,9 +1529,38 @@ class DownloadManager:
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder"
)
# Case 2: model_version_id was None, check after getting version_info
if model_version_id is None:
version_id = version_info.get("id")
# Existence check after the metadata fetch (#1058):
# - An explicit file selection only blocks when THIS file is
# already in the library; other files of the same version
# remain downloadable.
# - Without file_params (or when file_params failed to resolve),
# keep version-level protection. The case "model_version_id
# given + no file_params" was already covered by the early
# gate above.
if explicit_file and resolved_version_id is not None:
existing_entry = await self._find_local_file_entry(
model_type, resolved_version_id, target_file
)
if existing_entry is not None:
error_message = (
f"File '{target_file.get('name')}' from model version "
f"{resolved_version_id} already exists in {model_type} library"
)
logger.info("[download] %s", error_message)
return {"success": False, "error": error_message}
logger.info(
"[download] File '%s' of model version %s not in %s library — "
"download allowed (other files of this version may exist locally)",
target_file.get("name"), resolved_version_id, model_type,
)
elif file_params is not None or model_version_id is None:
# Case 2: model_version_id was None, or file_params did not
# resolve to a concrete file — check at version level.
version_id = (
resolved_version_id
if resolved_version_id is not None
else version_info.get("id")
)
if model_type == "lora":
# Check lora scanner
@@ -1495,73 +1707,16 @@ class DownloadManager:
files = version_info.get("files", [])
file_info = None
# If file_params is provided, try to find matching file
if file_params and model_version_id:
target_file_id = file_params.get("id")
target_type = file_params.get("type", "Model")
target_format = file_params.get("format")
target_size = file_params.get("size")
target_fp = file_params.get("fp")
is_primary = file_params.get("isPrimary", False)
logger.debug(
"[download] file_params received: id=%s, type=%s, format=%s, size=%s, fp=%s, isPrimary=%s, "
"model_version_id=%s, total_files=%d",
target_file_id, target_type, target_format, target_size, target_fp, is_primary,
model_version_id, len(files),
)
if target_file_id:
target_id_str = str(target_file_id)
for f in files:
f_id = f.get("id")
if str(f_id) == target_id_str:
file_info = f
logger.debug(
"[download] MATCH by ID: id=%s name='%s'",
f_id, f.get("name"),
)
break
if not file_info:
logger.debug("[download] No file found with id=%s", target_file_id)
elif is_primary:
file_info = next(
(
f
for f in files
if f.get("primary")
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
),
None,
)
else:
# Lenient metadata match: only compare fields present on both sides
for f in files:
f_type = f.get("type", "")
if f_type != target_type:
continue
f_meta = f.get("metadata", {})
f_format = f_meta.get("format") or f.get("format")
f_size = f_meta.get("size") or f.get("size")
f_fp = f_meta.get("fp") or f.get("fp")
if target_format and f_format != target_format:
continue
if target_size and f_size and f_size != target_size:
continue
if target_fp and f_fp and f_fp != target_fp:
continue
file_info = f
break
# If file_params is provided, reuse the file resolved right after
# the metadata fetch so the existence gate and this selection
# always agree on the target file (#1058).
if file_params is not None:
file_info = target_file
if not file_info:
logger.debug(
"[download] No match found via file_params — falling back to primary file lookup",
)
elif not file_params:
else:
logger.debug(
"[download] No file_params provided (null/None) — will use primary file lookup. "
"model_version_id=%s, total_files=%d",
@@ -1706,6 +1861,7 @@ class DownloadManager:
version_info,
model_version_id,
save_path,
file_info=file_info,
)
await self._sync_downloaded_version(
model_type,
@@ -1748,6 +1904,7 @@ class DownloadManager:
version_info: Dict[str, Any],
fallback_version_id=None,
file_path: str | None = None,
file_info: Dict[str, Any] | None = None,
) -> None:
try:
history_service = await ServiceRegistry.get_downloaded_version_history_service()
@@ -1773,6 +1930,15 @@ class DownloadManager:
if version_id is None:
version_id = fallback_version_id
# Per-file identity for multi-file versions (#1058)
file_id = None
file_name = None
if isinstance(file_info, dict):
file_id = file_info.get("id")
raw_file_name = file_info.get("name")
if isinstance(raw_file_name, str) and raw_file_name.strip():
file_name = raw_file_name.strip()
try:
await history_service.mark_downloaded(
model_type,
@@ -1780,6 +1946,8 @@ class DownloadManager:
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
source="download",
file_path=file_path,
file_id=file_id,
file_name=file_name,
)
except (TypeError, ValueError):
logger.debug(
+74 -25
View File
@@ -12,6 +12,15 @@ from ..utils.cache_paths import get_cache_base_dir
logger = logging.getLogger(__name__)
# SQL fragment extracting the CivitAI file id from the JSON ``file_params``
# column (#1058). ``json_valid`` guards against NULL and legacy/unparseable
# values, yielding NULL for rows without a file identity; NULL keys group
# together so such rows keep the old version-level dedup behavior.
_FILE_ID_SQL = (
"CASE WHEN json_valid(file_params) "
"THEN json_extract(file_params, '$.id') END"
)
def _resolve_database_path() -> str:
base_dir = get_cache_base_dir(create=True)
@@ -64,6 +73,7 @@ class DownloadQueueService:
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
file_params TEXT,
status TEXT NOT NULL,
error TEXT,
file_path TEXT,
@@ -120,6 +130,18 @@ class DownloadQueueService:
with self._connect() as conn:
conn.executescript(self._SCHEMA_TABLES)
# Databases created by older versions lack
# download_history.file_params; add it so retry-from-history can
# restore the originally selected file (#1058).
history_columns = {
row["name"]
for row in conn.execute("PRAGMA table_info(download_history)")
}
if "file_params" not in history_columns:
conn.execute(
"ALTER TABLE download_history ADD COLUMN file_params TEXT"
)
# Creating the unique index on download_history.download_id can
# fail if pre-existing rows have duplicate values (e.g. from a
# previous version that lacked the index). Deduplicate first so
@@ -418,6 +440,12 @@ class DownloadQueueService:
return None
now = completed_at if completed_at is not None else time.time()
# Guard against legacy databases whose download_queue table
# predates the file_params column.
queue_columns = set(row.keys())
file_params_json = (
row["file_params"] if "file_params" in queue_columns else None
)
conn.execute(
"DELETE FROM download_queue WHERE download_id = ?",
(download_id,),
@@ -426,9 +454,9 @@ class DownloadQueueService:
"""
INSERT OR IGNORE INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
version_name, thumbnail_url, file_params, status, error,
file_path, bytes_downloaded, total_bytes, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
row["download_id"],
@@ -437,6 +465,7 @@ class DownloadQueueService:
row["model_name"],
row["version_name"],
row["thumbnail_url"],
file_params_json,
status,
error,
file_path,
@@ -503,6 +532,7 @@ class DownloadQueueService:
bytes_downloaded: int = 0,
total_bytes: Optional[int] = None,
is_already_exists: int = 0,
file_params: Optional[dict[str, Any]] = None,
) -> int:
"""Insert a record into the download history.
@@ -510,6 +540,7 @@ class DownloadQueueService:
inserted row.
"""
now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock:
conn = self._get_conn()
@@ -517,9 +548,10 @@ class DownloadQueueService:
"""
INSERT INTO download_history (
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path,
bytes_downloaded, total_bytes, completed_at, is_already_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
version_name, thumbnail_url, file_params, status, error,
file_path, bytes_downloaded, total_bytes, completed_at,
is_already_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
download_id,
@@ -528,6 +560,7 @@ class DownloadQueueService:
model_name,
version_name,
thumbnail_url,
file_params_json,
status,
error,
file_path,
@@ -702,7 +735,7 @@ class DownloadQueueService:
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""",
(
new_id,
@@ -712,6 +745,7 @@ class DownloadQueueService:
row["version_name"],
row["thumbnail_url"],
"retry",
row["file_params"],
now,
),
)
@@ -755,7 +789,7 @@ class DownloadQueueService:
download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params,
status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""",
(
new_id,
@@ -765,6 +799,7 @@ class DownloadQueueService:
row["version_name"],
row["thumbnail_url"],
"retry",
row["file_params"],
now,
),
)
@@ -840,33 +875,44 @@ class DownloadQueueService:
async with self._lock:
conn = self._get_conn()
# 1. History: for each (model_id, model_version_id, status) triplet
# keep only the row with the highest id (most recently inserted).
conn.execute("""
# 1. History: for each (model_id, model_version_id, file_id,
# status) group keep only the row with the highest id (most
# recently inserted). file_id comes from file_params (#1058)
# so distinct files of the same version never collapse.
conn.execute(f"""
DELETE FROM download_history
WHERE id NOT IN (
SELECT MAX(id)
FROM download_history
GROUP BY model_id, model_version_id, status
GROUP BY model_id, model_version_id, status,
{_FILE_ID_SQL}
)
""")
result["removed_history"] = conn.execute(
"SELECT changes()"
).fetchone()[0]
# 2. Cross-status dedup: for each (model_id, model_version_id),
# keep only the entry with the highest-priority terminal status.
# 2. Cross-status dedup: for each (model_id, model_version_id,
# file_id), keep only the entry with the highest-priority
# terminal status.
# Priority: completed (3) > failed (2) > canceled (1).
# This prevents the same model version from having both a
# 'failed' and a 'canceled' entry (or a 'completed' alongside
# either) after the bug-created duplicates are removed.
conn.execute("""
# This prevents the same file of a model version from having
# both a 'failed' and a 'canceled' entry (or a 'completed'
# alongside either) after the bug-created duplicates are
# removed. ``IS`` matches NULL file ids against each other so
# rows without file identity keep the old behavior.
conn.execute(f"""
DELETE FROM download_history
WHERE id NOT IN (
SELECT dh.id
FROM download_history dh
FROM (
SELECT id, model_id, model_version_id, status,
{_FILE_ID_SQL} AS file_id
FROM download_history
) dh
INNER JOIN (
SELECT model_id, model_version_id,
{_FILE_ID_SQL} AS file_id,
MAX(CASE status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
@@ -874,17 +920,18 @@ class DownloadQueueService:
ELSE 0
END) AS best_prio
FROM download_history
GROUP BY model_id, model_version_id
GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
) best
ON dh.model_id = best.model_id
AND dh.model_version_id = best.model_version_id
AND dh.file_id IS best.file_id
AND CASE dh.status
WHEN 'completed' THEN 3
WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1
ELSE 0
END = best.best_prio
GROUP BY dh.model_id, dh.model_version_id
GROUP BY dh.model_id, dh.model_version_id, dh.file_id
HAVING dh.id = MAX(dh.id)
)
""")
@@ -892,15 +939,17 @@ class DownloadQueueService:
"SELECT changes()"
).fetchone()[0]
# 3. Queue: for each (model_id, model_version_id) keep only the
# row with the latest added_at (most recently enqueued).
conn.execute("""
# 3. Queue: for each (model_id, model_version_id, file_id) keep
# only the row with the latest added_at (most recently
# enqueued). file_id comes from file_params (#1058) so
# distinct files of the same version never collapse.
conn.execute(f"""
DELETE FROM download_queue
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM download_queue
WHERE status IN ('queued', 'downloading', 'paused', 'waiting')
GROUP BY model_id, model_version_id
GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
)
AND status IN ('queued', 'downloading', 'paused', 'waiting')
""")
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
);
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
ON downloaded_model_versions(model_type, model_id);
CREATE TABLE IF NOT EXISTS downloaded_version_files (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
file_id INTEGER NOT NULL,
file_name TEXT,
downloaded_at REAL NOT NULL,
PRIMARY KEY (model_type, version_id, file_id)
);
"""
def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
source: str = "manual",
file_path: str | None = None,
library_name: str | None = None,
file_id: int | None = None,
file_name: str | None = None,
) -> None:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
normalized_model_id = _normalize_int(model_id)
normalized_file_id = _normalize_int(file_id)
if normalized_type is None or normalized_version_id is None:
return
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
active_library_name,
),
)
if normalized_file_id is not None:
# Per-file history for multi-file versions (#1058)
conn.execute(
"""
INSERT INTO downloaded_version_files (
model_type, version_id, file_id, file_name, downloaded_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(model_type, version_id, file_id) DO UPDATE SET
file_name = COALESCE(excluded.file_name, downloaded_version_files.file_name),
downloaded_at = excluded.downloaded_at
""",
(
normalized_type,
normalized_version_id,
normalized_file_id,
file_name,
timestamp,
),
)
conn.commit()
async def mark_downloaded_bulk(
@@ -255,8 +285,63 @@ class DownloadedVersionHistoryService:
self._get_active_library_name(),
),
)
# Whole-version deletion also clears the per-file records (#1058)
conn.execute(
"""
DELETE FROM downloaded_version_files
WHERE model_type = ? AND version_id = ?
""",
(normalized_type, normalized_version_id),
)
conn.commit()
async def mark_file_deleted(
self, model_type: str, version_id: int, file_id: int
) -> None:
"""Drop a single file record of a version, keeping siblings (#1058)."""
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
normalized_file_id = _normalize_int(file_id)
if (
normalized_type is None
or normalized_version_id is None
or normalized_file_id is None
):
return
async with self._lock:
conn = self._get_conn()
conn.execute(
"""
DELETE FROM downloaded_version_files
WHERE model_type = ? AND version_id = ? AND file_id = ?
""",
(normalized_type, normalized_version_id, normalized_file_id),
)
conn.commit()
async def get_downloaded_file_ids(
self, model_type: str, version_id: int
) -> list[int]:
"""Return the CivitAI file ids recorded as downloaded for a version."""
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
if normalized_type is None or normalized_version_id is None:
return []
async with self._lock:
conn = self._get_conn()
rows = conn.execute(
"""
SELECT file_id
FROM downloaded_version_files
WHERE model_type = ? AND version_id = ?
ORDER BY file_id ASC
""",
(normalized_type, normalized_version_id),
).fetchall()
return [int(row["file_id"]) for row in rows]
async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id)
+65 -1
View File
@@ -35,6 +35,10 @@ class ModelCache:
folders: List[str]
version_index: Dict[int, 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
# CivitAI model version, so versions with several downloaded files stay
# consistent (#1058).
version_files_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
name_display_mode: str = "model_name"
_lock: Any = field(init=False, repr=False, default=None)
# Cache for last sort: (sort_key, order, seed) -> sorted list
@@ -116,6 +120,7 @@ class ModelCache:
self.version_index = {}
self.model_id_index = {}
self.version_files_index = {}
for item in self.raw_data:
self.add_to_version_index(item)
@@ -132,6 +137,17 @@ class ModelCache:
self.version_index[version_id] = item
# Register in the multi-valued index, deduplicated by file_path (#1058)
files = self.version_files_index.setdefault(version_id, [])
for entry in files:
if entry is item or (
isinstance(entry, dict)
and entry.get('file_path') == item.get('file_path')
):
break
else:
files.append(item)
model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None:
return
@@ -159,12 +175,37 @@ class ModelCache:
if version_id is None:
return
# Drop only this file's entry from the multi-valued index (#1058)
files = self.version_files_index.get(version_id)
if files:
remaining = [
entry
for entry in files
if not (
entry is item
or (
isinstance(entry, dict)
and entry.get('file_path') == item.get('file_path')
)
)
]
if remaining:
self.version_files_index[version_id] = remaining
else:
self.version_files_index.pop(version_id, None)
# A surviving sibling file keeps the version present in the indexes
sibling = (self.version_files_index.get(version_id) or [None])[0]
existing = self.version_index.get(version_id)
if existing is item or (
isinstance(existing, dict)
and existing.get('file_path') == item.get('file_path')
):
self.version_index.pop(version_id, None)
if sibling is not None:
self.version_index[version_id] = sibling
else:
self.version_index.pop(version_id, None)
model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None:
@@ -174,6 +215,20 @@ class ModelCache:
if not versions:
return
if sibling is not None:
# Update the descriptor to reflect the surviving sibling file
descriptor = self._build_version_descriptor(
sibling,
sibling.get('civitai') if isinstance(sibling, dict) else {},
version_id,
)
for index, existing_desc in enumerate(versions):
if existing_desc.get('versionId') == version_id:
if descriptor is not None:
versions[index] = descriptor
break
return
filtered = [v for v in versions if v.get('versionId') != version_id]
if filtered:
self.model_id_index[model_id] = filtered
@@ -206,6 +261,15 @@ class ModelCache:
versions = self.model_id_index.get(normalized_id, [])
return [dict(version) for version in versions]
def get_files_by_version_id(self, version_id: Any) -> List[Dict[str, Any]]:
"""Return every local file entry for a CivitAI model version (#1058)."""
normalized_id = self._normalize_version_id(version_id)
if normalized_id is None:
return []
return list(self.version_files_index.get(normalized_id, []))
async def resort(self):
"""Resort cached data according to last sort mode if set"""
async with self._lock:
+147 -2
View File
@@ -25,6 +25,28 @@ from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
logger = logging.getLogger(__name__)
# Canonical set of weight-file extensions stripped when normalizing model
# names for matching (ModelScanner.find_matching_models and the recipe rematch
# filename key share this set). It is the union of the LoRA scanner set
# ({".safetensors"}) and the Checkpoint scanner set (ComfyUI's
# supported_pt_extensions plus ".gguf") so type-blind lookups (lora +
# checkpoint merged) cover every format either scanner indexes. ".safebin"
# is deliberately absent — no scanner indexes it, so a recipe entry
# "model.safebin" must not be bound to a local "model.safetensors".
WEIGHT_FILE_EXTENSIONS = frozenset(
{
".safetensors",
".ckpt",
".pt",
".pt2",
".bin",
".pth",
".pkl",
".sft",
".gguf",
}
)
def _is_excluded_dir(name: str) -> bool:
"""Return True when a directory entry must be skipped during model walks.
@@ -2140,8 +2162,98 @@ class ModelScanner:
return sorted_models
return sorted_models[:limit]
async def get_model_info_by_name(self, name):
"""Get model information by name"""
@staticmethod
def find_matching_models(
raw_data: List[Dict[str, Any]],
name: str,
*,
base_model: Optional[str] = None,
extensions: Optional[Set[str]] = None,
) -> List[Dict[str, Any]]:
"""Return all cached models matching ``name`` (case-insensitive).
A name containing a path separator must equal the model's
folder-relative path; a bare name matches on basename. When
``base_model`` is given, confident mismatches are rejected while
unknowns on either side stay eligible (lenient guard).
``extensions`` should be the scanner's own ``file_extensions`` so
suffix stripping only covers formats the scanner actually indexes;
when omitted, the shared :data:`WEIGHT_FILE_EXTENSIONS` set is used.
"""
# Longest first so overlapping suffixes strip correctly.
exts = sorted(extensions or WEIGHT_FILE_EXTENSIONS, key=len, reverse=True)
normalized_name = str(name).replace("\\", "/").casefold()
for ext in exts:
if normalized_name.endswith(ext):
normalized_name = normalized_name[: -len(ext)]
break
has_path = "/" in normalized_name
basename = normalized_name.rsplit("/", 1)[-1]
matches = []
for model in raw_data:
file_name = str(model.get("file_name") or "").replace("\\", "/")
folder = str(model.get("folder") or "").replace("\\", "/").strip("/")
model_path = f"{folder}/{file_name}" if folder else file_name
for ext in exts:
if model_path.casefold().endswith(ext):
model_path = model_path[: -len(ext)]
break
if (has_path and model_path.casefold() == normalized_name) or (
not has_path and model_path.rsplit("/", 1)[-1].casefold() == basename
):
matches.append(model)
expected_base = str(base_model or "").strip().casefold()
if expected_base and expected_base != "unknown":
matches = [
model
for model in matches
if str(model.get("base_model") or "").strip().casefold()
in ("", "unknown", expected_base)
]
return matches
async def find_models_by_name(
self, name: str, *, base_model: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Return every cached model matching ``name`` (see ``find_matching_models``)."""
try:
cache = await self.get_cached_data()
return self.find_matching_models(
cache.raw_data,
name,
base_model=base_model,
extensions=self.file_extensions,
)
except Exception as e:
logger.error(f"Error finding models by name: {e}", exc_info=True)
return []
async def get_model_info_by_name(
self,
name: str,
*,
require_unique: bool = False,
base_model: Optional[str] = None,
):
"""Get model information by name.
Default mode keeps the legacy first-match/fallback semantics. With
``require_unique`` an ambiguous name is a miss, and ``base_model``
rejects confident base-model mismatches (unknowns stay eligible).
"""
if require_unique or base_model:
try:
matches = await self.find_models_by_name(name, base_model=base_model)
if require_unique and len(matches) != 1:
return None
return matches[0] if matches else None
except Exception as e:
logger.error(f"Error getting model info by name: {e}", exc_info=True)
return None
try:
cache = await self.get_cached_data()
@@ -2446,6 +2558,39 @@ class ModelScanner:
logger.error(f"Error checking model version existence: {e}")
return False
async def get_files_for_version(self, model_version_id: int) -> List[Dict[str, Any]]:
"""Get all local file entries for a specific model version (#1058).
A Civitai model version can have several weight files downloaded;
unlike the single-valued version_index this returns every entry.
Args:
model_version_id: Civitai model version ID
Returns:
List[Dict]: Cache entries (may be empty)
"""
try:
normalized_id = int(model_version_id)
except (TypeError, ValueError):
return []
try:
cache = await self.get_cached_data()
if not cache:
return []
getter = getattr(cache, "get_files_by_version_id", None)
if getter is not None:
return getter(normalized_id)
# Fallback for cache implementations without the multi-file index
entry = cache.version_index.get(normalized_id)
return [entry] if entry is not None else []
except Exception as e:
logger.error(f"Error getting files for model version: {e}")
return []
async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
"""Get all versions of a model by its ID
+12 -1
View File
@@ -58,6 +58,7 @@ class PersistentRecipeCache:
"checkpoint_json",
"gen_params_json",
"tags_json",
"has_workflow",
)
_instances: Dict[str, "PersistentRecipeCache"] = {}
_instance_lock = threading.Lock()
@@ -407,7 +408,8 @@ class PersistentRecipeCache:
loras_json TEXT,
checkpoint_json TEXT,
gen_params_json TEXT,
tags_json TEXT
tags_json TEXT,
has_workflow INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
@@ -426,6 +428,13 @@ class PersistentRecipeCache:
)
except Exception:
pass # column already exists
# Migration: add has_workflow column to existing databases
try:
conn.execute(
"ALTER TABLE recipes ADD COLUMN has_workflow INTEGER DEFAULT 0"
)
except Exception:
pass # column already exists
conn.commit()
self._schema_initialized = True
except Exception as exc:
@@ -488,6 +497,7 @@ class PersistentRecipeCache:
checkpoint_json,
gen_params_json,
tags_json,
1 if recipe.get("has_workflow") else 0,
)
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
@@ -533,6 +543,7 @@ class PersistentRecipeCache:
"favorite": bool(row["favorite"]),
"repair_version": row["repair_version"] or 0,
"preview_nsfw_level": row["preview_nsfw_level"] or 0,
"has_workflow": bool(row["has_workflow"]),
"loras": loras,
"gen_params": gen_params,
}
+87 -13
View File
@@ -13,8 +13,10 @@ import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
from ..utils.exif_utils import ExifUtils
from ..utils.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats
from .model_scanner import WEIGHT_FILE_EXTENSIONS
from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from natsort import natsorted
@@ -36,11 +38,6 @@ logger = logging.getLogger(__name__)
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
# Known weight-file extensions stripped by _normalize_filename_key. Names are
# stored extensionless on both sides, so splitext would misread dotted stems
# ("my.mix" -> "my") and silently collide distinct models.
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
class RecipeScanner:
"""Service for scanning and managing recipe images"""
@@ -179,13 +176,15 @@ class RecipeScanner:
Only known weight-file extensions are stripped names are stored
extensionless on both sides, so splitext would misread dotted stems
("my.mix" -> "my") and collide distinct models.
("my.mix" -> "my") and collide distinct models. The extension set is
shared with ModelScanner.find_matching_models, and is iterated longest
first to keep the strip ordering identical to that function.
"""
if not name:
return ""
basename = os.path.basename(name.replace("\\", "/"))
lower = basename.lower()
for ext in _WEIGHT_FILE_EXTS:
for ext in sorted(WEIGHT_FILE_EXTENSIONS, key=len, reverse=True):
if lower.endswith(ext):
basename = basename[: -len(ext)]
break
@@ -1731,6 +1730,23 @@ class RecipeScanner:
return recipes, json_paths
@staticmethod
def _detect_has_workflow(image_path: Optional[str]) -> bool:
"""Detect whether the recipe image embeds a ComfyUI workflow.
Reuses ``ExifUtils._load_structured_metadata`` so the metadata parsing
stays in one place. Any failure (missing/corrupt image, unsupported
format, unexpected exception) maps to ``False`` and never propagates
recipe loading must remain resilient.
"""
if not image_path or not os.path.exists(image_path):
return False
try:
metadata = ExifUtils._load_structured_metadata(image_path)
return bool(metadata.get("workflow"))
except Exception:
return False
def _load_recipe_file_sync(self, recipe_path: str) -> Optional[Dict[str, Any]]:
"""Load a single recipe file synchronously.
@@ -1787,6 +1803,19 @@ class RecipeScanner:
except Exception as e:
logger.warning(f"Failed to persist repair for {recipe_path}: {e}")
# Detect embedded ComfyUI workflow and persist when it changed
if "has_workflow" not in recipe_data:
has_workflow = self._detect_has_workflow(recipe_data.get("file_path"))
if has_workflow != recipe_data.get("has_workflow"):
recipe_data["has_workflow"] = has_workflow
try:
with open(recipe_path, "w", encoding="utf-8") as f:
json.dump(recipe_data, f, indent=4, ensure_ascii=False)
except Exception as e:
logger.warning(
f"Failed to persist has_workflow for {recipe_path}: {e}"
)
# Track folder placement relative to recipes directory
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
recipe_path
@@ -2472,6 +2501,13 @@ class RecipeScanner:
if path_updated:
self._write_recipe_file(recipe_path, recipe_data)
# Detect embedded ComfyUI workflow and persist when it changed
if "has_workflow" not in recipe_data:
has_workflow = self._detect_has_workflow(recipe_data.get("file_path"))
if has_workflow != recipe_data.get("has_workflow"):
recipe_data["has_workflow"] = has_workflow
self._write_recipe_file(recipe_path, recipe_data)
# Track folder placement relative to recipes directory
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
recipe_path
@@ -2926,13 +2962,45 @@ class RecipeScanner:
return normalized
async def get_local_lora(self, name: str) -> Optional[Dict[str, Any]]:
"""Lookup a local LoRA model by name."""
async def get_local_lora(
self, name: str, base_model: Optional[str] = None
) -> Optional[Dict[str, Any]]:
"""Lookup an unambiguous local LoRA by name and optional base model."""
if not self._lora_scanner or not name:
return None
return await self._lora_scanner.get_model_info_by_name(name)
return await self._lora_scanner.get_model_info_by_name(
name, require_unique=True, base_model=base_model
)
async def find_local_loras_by_name(
self, name: str, base_model: Optional[str] = None
) -> List[Dict[str, Any]]:
"""Return every local LoRA matching ``name`` (used to explain lookup misses)."""
if not self._lora_scanner or not name:
return []
return await self._lora_scanner.find_models_by_name(name, base_model=base_model)
async def get_local_lora_by_hash(self, hash_value: str) -> Optional[Dict[str, Any]]:
"""Lookup a local LoRA through the scanner's hash index."""
if not self._lora_scanner or not hash_value:
return None
file_path = self._lora_scanner.get_path_by_hash(hash_value)
if not file_path:
return None
target_path = os.path.normcase(os.path.abspath(file_path))
cached_data = await self._lora_scanner.get_cached_data()
for model in cached_data.raw_data:
model_path = model.get("file_path")
if model_path and os.path.normcase(os.path.abspath(model_path)) == target_path:
return model
return None
async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
"""Lookup a local checkpoint model by name."""
@@ -3272,6 +3340,13 @@ class RecipeScanner:
# Format the recipe with all needed information
formatted_recipe = {**merged_recipe}
# Fallback for recipes saved before has_workflow existed: detect once
# on demand so the modal button works without a rescan.
if "has_workflow" not in formatted_recipe:
formatted_recipe["has_workflow"] = self._detect_has_workflow(
formatted_recipe.get("file_path")
)
# Format file path to URL
if "file_path" in formatted_recipe:
formatted_recipe["file_url"] = self._format_file_url(
@@ -3590,9 +3665,6 @@ class RecipeScanner:
syntax_parts: List[str] = []
for lora in loras:
if lora.get("isDeleted", False):
continue
file_name = None
folder = ""
hash_value = (lora.get("hash") or "").lower()
@@ -3627,6 +3699,8 @@ class RecipeScanner:
break
if not file_name:
if lora.get("isDeleted", False):
continue
file_name = lora.get("file_name", "unknown-lora")
folder = lora.get("folder", "")
+32 -1
View File
@@ -117,6 +117,7 @@ class RecipePersistenceService:
"loras": loras_data,
"gen_params": gen_params,
"fingerprint": fingerprint,
"has_workflow": self._detect_has_workflow(normalized_image_path),
}
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
@@ -426,8 +427,21 @@ class RecipePersistenceService:
if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found")
target_lora = await recipe_scanner.get_local_lora(target_name)
with open(recipe_path, "r", encoding="utf-8") as file_obj:
recipe_base_model = json.load(file_obj).get("base_model", "")
target_lora = await recipe_scanner.get_local_lora(target_name, recipe_base_model)
if not target_lora:
matches = await recipe_scanner.find_local_loras_by_name(target_name)
if len(matches) > 1:
raise RecipeValidationError(
f"Multiple local LoRAs match '{target_name}'; "
"include the folder path to disambiguate"
)
if len(matches) == 1:
raise RecipeValidationError(
f"Local LoRA '{target_name}' has a different base model than the recipe"
)
raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
@@ -602,6 +616,9 @@ class RecipePersistenceService:
if key not in ["checkpoint", "loras"]
},
"loras_stack": lora_stack,
# Widget saves re-encode an in-memory tensor to PNG/WebP with no
# embedded metadata chunks, so a workflow can never be present.
"has_workflow": False,
}
if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry
@@ -626,6 +643,20 @@ class RecipePersistenceService:
# Helper methods ---------------------------------------------------
def _detect_has_workflow(self, image_path: str) -> bool:
"""Detect whether the saved recipe image embeds a ComfyUI workflow.
Extraction failures (missing file, corrupt image, unsupported format)
map to ``False`` and never propagate, mirroring the scanner's behavior.
"""
if not image_path or not os.path.exists(image_path):
return False
try:
metadata = self._exif_utils._load_structured_metadata(image_path)
return bool(metadata.get("workflow"))
except Exception:
return False
async def _build_widget_checkpoint_entry(
self,
recipe_scanner,
+1 -1
View File
@@ -4,7 +4,7 @@
position: fixed;
top: 0;
z-index: var(--z-header);
height: 48px;
height: var(--header-height, 48px);
/* Reduced height */
width: 100%;
box-shadow: var(--shadow-md);
+68 -25
View File
@@ -77,41 +77,84 @@
margin-bottom: var(--space-3);
}
/* File Input Styles */
.file-input-wrapper {
position: relative;
margin-bottom: var(--space-1);
.import-description {
margin-top: 0;
}
.file-input-wrapper input[type="file"] {
position: absolute;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.file-input-button {
/* Unified Drop Zone */
.import-drop-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border-radius: var(--border-radius-xs);
font-weight: 500;
gap: var(--space-1);
padding: var(--space-4) var(--space-3);
border: 2px dashed var(--border-color);
border-radius: var(--border-radius-sm);
background: var(--bg-color);
color: var(--text-color);
text-align: center;
cursor: pointer;
transition: background-color 0.2s;
transition: border-color 0.2s, background-color 0.2s;
}
.file-input-button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
.import-drop-zone:hover,
.import-drop-zone:focus-visible {
border-color: var(--lora-accent);
outline: none;
}
.file-input-wrapper:hover .file-input-button {
background: oklch(from var(--lora-accent) l c h / 0.9);
.import-drop-zone.drag-over {
border-color: var(--lora-accent);
background: oklch(var(--lora-accent) / 0.08);
}
.drop-zone-icon {
font-size: 1.8em;
color: var(--lora-accent);
}
.drop-zone-primary {
margin: 0;
opacity: 0.8;
}
.drop-zone-filename {
margin: 0;
font-weight: 500;
word-break: break-all;
}
/* Divider between drop zone and URL input */
.import-divider {
display: flex;
align-items: center;
gap: var(--space-2);
margin: var(--space-3) 0;
color: var(--text-color);
opacity: 0.6;
font-size: 0.9em;
}
.import-divider::before,
.import-divider::after {
content: '';
flex: 1;
border-top: 1px solid var(--border-color);
}
/* Loading state for the fetch button */
#fetchImageBtn.loading {
opacity: 0.8;
cursor: wait;
}
/* Inputs sit flush against the scrollable step's content edge; an outset
outline (global offset: 2px) gets clipped by overflow-x. Draw the focus
outline inset instead so the full ring stays visible. */
#importModal input:focus-visible,
#importModal select:focus-visible {
outline-offset: -2px;
}
/* Recipe Details Layout */
+274 -47
View File
@@ -4,19 +4,268 @@
margin-top: var(--space-4);
}
.carousel {
transition: max-height 0.3s ease-in-out;
/* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
/* Collapsed indicator bar — slim, no remote media is rendered until expanded */
.gallery-indicator-bar {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
}
.gallery-preview-thumb {
width: 40px;
height: 40px;
border-radius: var(--border-radius-xs);
overflow: hidden;
flex-shrink: 0;
background: var(--bg-color);
}
.gallery-preview-thumb img,
.gallery-preview-thumb video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-indicator-bar .gallery-show-btn {
flex: 1;
justify-content: flex-start;
}
.gallery-indicator-bar .gallery-import-btn {
margin-left: auto;
}
/* Expanded gallery toolbar */
.gallery-toolbar {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-2);
}
/* Position badge floats over the main media, bottom-right */
.gallery-position-badge {
position: absolute;
right: var(--space-2);
bottom: var(--space-2);
z-index: 6;
padding: 2px 10px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 0.8em;
font-variant-numeric: tabular-nums;
pointer-events: none;
}
/* While the gallery is expanded the thumbnail strip sits in the modal's
bottom-right corner, where the back-to-top button would overlap it */
.modal-content.showcase-expanded .back-to-top {
display: none;
}
.gallery-toolbar .gallery-import-btn {
margin-left: auto;
}
.gallery-show-btn,
.gallery-import-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
color: var(--text-color);
font-size: 0.9em;
cursor: pointer;
transition: var(--transition-base);
}
.gallery-show-btn:hover,
.gallery-import-btn:hover {
border-color: var(--lora-accent);
color: var(--lora-accent);
}
.nsfw-filter-notification {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.7;
display: inline-flex;
align-items: center;
gap: 6px;
}
/* Main viewer the container hugs the active media's aspect ratio
(--media-aspect = width/height, set per item) so no dead space remains.
overflow: hidden also clips the hoisted metadata panel while it is
translated below the bottom edge, so it never extends the modal's
scrollable height (which caused a scroll jump when it appeared) */
.gallery-main {
position: relative;
overflow: hidden;
border-radius: var(--border-radius-sm);
}
.main-media-container {
position: relative;
margin: 0 auto;
width: min(100%, calc(min(75vh, 800px) * var(--media-aspect, 1.3333)));
aspect-ratio: var(--media-aspect, 1.3333);
max-height: min(75vh, 800px);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
overflow: hidden;
}
.carousel.collapsed {
max-height: 0;
.main-media-container .media-wrapper {
width: 100%;
height: 100%;
margin-bottom: 0;
}
.carousel-container {
.main-media-container .media-wrapper img,
.main-media-container .media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
cursor: zoom-in;
}
/* Nav buttons float over the media, visible on hover */
.gallery-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 6;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-color);
border: 1px solid var(--border-color);
color: var(--text-color);
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
opacity: 0;
transition: opacity 0.2s ease, border-color 0.2s ease, color 0.2s ease;
pointer-events: none;
}
.gallery-nav.prev {
left: var(--space-2);
}
.gallery-nav.next {
right: var(--space-2);
}
.gallery-main:hover .gallery-nav,
.gallery-nav:focus-visible {
opacity: 0.9;
pointer-events: auto;
}
.gallery-nav:hover {
opacity: 1;
border-color: var(--lora-accent);
color: var(--lora-accent);
}
/* Thumbnail strip */
.gallery-strip {
display: flex;
flex-direction: column;
gap: var(--space-2);
gap: var(--space-1);
margin-top: var(--space-2);
overflow-x: auto;
padding-bottom: var(--space-1);
}
.gallery-thumb {
position: relative;
width: 72px;
height: 72px;
flex-shrink: 0;
border: 2px solid var(--border-color);
border-radius: var(--border-radius-xs);
overflow: hidden;
background: var(--lora-surface);
cursor: pointer;
padding: 0;
transition: border-color 0.15s ease;
}
.gallery-thumb:hover {
border-color: var(--text-color);
}
.gallery-thumb.active {
border-color: var(--lora-accent);
}
.gallery-thumb .thumb-media {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-thumb .thumb-media.blurred {
filter: blur(8px);
}
.gallery-thumb .thumb-video-badge,
.gallery-thumb .thumb-nsfw-badge {
position: absolute;
bottom: 3px;
right: 3px;
font-size: 10px;
color: #fff;
background: rgba(0, 0, 0, 0.6);
border-radius: var(--border-radius-xs);
padding: 1px 4px;
pointer-events: none;
}
.gallery-thumb .thumb-nsfw-badge {
top: 3px;
bottom: auto;
}
.gallery-strip::-webkit-scrollbar {
height: 6px;
}
.gallery-strip::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
/* Inline import zone toggled from the toolbar */
.gallery-import-zone {
margin-top: var(--space-2);
}
.gallery-import-zone.hidden {
display: none;
}
.gallery-import-zone .example-import-area {
margin-top: 0;
}
.media-wrapper {
@@ -31,16 +280,6 @@
margin-bottom: 0;
}
.media-wrapper img,
.media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.no-examples {
text-align: center;
padding: var(--space-3);
@@ -48,11 +287,6 @@
opacity: 0.7;
}
/* Adjust the media wrapper for tab system */
#showcase-tab .carousel-container {
margin-top: var(--space-2);
}
/* Add styles for blurred showcase content */
.nsfw-media-wrapper {
position: relative;
@@ -217,6 +451,24 @@
pointer-events: auto;
}
/* Hoisted panel: pinned to the bottom of .gallery-main at full column width */
.gallery-main > .image-metadata-panel {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 7;
max-height: 60%;
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
}
.gallery-main > .image-metadata-panel.visible {
transform: translateY(0);
opacity: 0.98;
pointer-events: auto;
}
/* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel {
background: var(--card-bg);
@@ -388,31 +640,6 @@
opacity: 0.8;
}
/* Scroll Indicator */
.scroll-indicator {
cursor: pointer;
padding: var(--space-2);
background: var(--lora-surface);
border: 1px solid var(--lora-border);
border-radius: var(--border-radius-sm);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: var(--space-2);
transition: background-color 0.2s, transform 0.2s;
}
.scroll-indicator:hover {
background: oklch(var(--lora-accent-l) var(--lora-accent-c) var(--lora-accent-h) / 0.1);
transform: translateY(-1px);
}
.scroll-indicator span {
font-size: 0.9em;
color: var(--text-color);
}
.lazy {
opacity: 0;
transition: opacity 0.3s;
+3 -1
View File
@@ -6,7 +6,9 @@
left: 0;
width: 100%;
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */
background: rgba(0, 0, 0, 0.2);
background: var(--modal-backdrop-bg, rgba(0, 0, 0, 0.5));
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
z-index: var(--z-modal);
overflow: auto; /* Change from hidden to auto to allow scrolling */
}
@@ -13,7 +13,10 @@
left: 0;
width: 100%;
height: 100%;
/* Darker than --modal-backdrop-bg to stress destructive actions, but keeps the shared blur */
background: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
-webkit-backdrop-filter: blur(var(--modal-backdrop-blur, 6px));
z-index: var(--z-overlay);
}
@@ -603,6 +603,51 @@
cursor: pointer;
}
.file-option-radio input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--lora-accent);
cursor: pointer;
}
/* Files already in the library are greyed out and not clickable */
.file-option.disabled {
opacity: 0.55;
cursor: not-allowed;
}
.file-option.disabled:hover {
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.file-option.disabled input[type="checkbox"] {
cursor: not-allowed;
}
/* Options of the other routing group are temporarily disabled once a
selection is made (mixed-type multi-select is not allowed) */
.file-option.group-disabled {
opacity: 0.6;
cursor: not-allowed;
}
.file-option.group-disabled:hover {
border-color: var(--border-color);
box-shadow: none;
transform: none;
}
.file-option.group-disabled input[type="checkbox"] {
cursor: not-allowed;
}
.file-tag.in-library {
background: oklch(var(--lora-accent) / 0.15);
color: var(--lora-accent);
}
.file-option-info {
flex: 1;
min-width: 0;
+65 -47
View File
@@ -107,12 +107,19 @@
#recipeModal .modal-content {
display: flex;
flex-direction: column;
/* Content-sized shell: grows with content up to the viewport limit, inner panes scroll past it */
box-sizing: border-box; /* Include padding/border so the shell never exceeds the viewport */
width: min(1600px, 94vw);
max-width: min(1600px, 94vw);
height: auto;
max-height: calc(100vh - var(--header-height, 48px) - 2rem);
overflow: hidden;
}
#recipeModal .modal-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
display: grid;
grid-template-columns: 320px minmax(0, 1fr) 420px;
gap: var(--space-3);
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
@@ -174,19 +181,22 @@
}
}
/* Top Section: Preview and Gen Params */
.recipe-top-section {
display: grid;
grid-template-columns: 280px 1fr;
/* Left Column: Preview */
.recipe-media-column {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex-shrink: 0;
margin-bottom: var(--space-2);
min-height: 0;
overflow-y: auto;
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
}
/* Recipe Preview */
.recipe-preview-container {
width: 100%;
height: 360px;
box-sizing: border-box; /* Keep the 1px border inside the column width */
height: auto;
max-height: 42vh;
border-radius: var(--border-radius-sm);
overflow: hidden;
background: var(--lora-surface);
@@ -196,18 +206,19 @@
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
}
.recipe-preview-container img,
.recipe-preview-container video {
max-width: 100%;
max-height: 100%;
max-height: 42vh;
object-fit: contain;
}
.recipe-preview-media {
max-width: 100%;
max-height: 100%;
max-height: 42vh;
object-fit: contain;
}
@@ -340,9 +351,10 @@
/* Generation Parameters */
.recipe-gen-params {
height: 360px;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.gen-params-header-row {
@@ -399,8 +411,6 @@
display: flex;
flex-direction: column;
gap: var(--space-2);
overflow-y: auto;
flex: 1;
}
.param-group {
@@ -453,8 +463,6 @@
color: var(--text-color);
font-size: 0.9em;
line-height: 1.5;
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-word;
}
@@ -526,14 +534,12 @@
opacity: 0.8;
}
/* Bottom Section: Resources */
/* Right Column: Resources */
.recipe-bottom-section {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}
.recipe-section-header {
@@ -1010,18 +1016,43 @@
}
/* Responsive adjustments */
@media (max-width: 768px) {
.recipe-top-section {
grid-template-columns: 1fr;
@media (max-width: 1500px) {
#recipeModal .modal-body {
grid-template-columns: 300px minmax(0, 1fr) 380px;
}
.recipe-preview-container {
height: 200px;
}
@media (max-width: 1000px) {
#recipeModal .modal-body {
display: flex;
flex-direction: column;
gap: var(--space-2);
overflow-y: auto;
}
.recipe-media-column {
overflow-y: visible;
flex-shrink: 0;
}
.recipe-preview-container,
.recipe-preview-container img,
.recipe-preview-container video,
.recipe-preview-media {
max-height: 40vh;
}
.recipe-gen-params {
height: auto;
max-height: 300px;
overflow-y: visible;
flex-shrink: 0;
}
.recipe-bottom-section {
flex: none;
}
.recipe-loras-list {
max-height: 45vh;
}
}
@@ -1045,19 +1076,11 @@
margin-bottom: 6px;
}
.recipe-top-section {
grid-template-columns: 1fr;
gap: var(--space-1);
margin-bottom: var(--space-1);
}
.recipe-preview-container {
display: none;
}
.recipe-gen-params {
height: auto;
max-height: 210px;
.recipe-preview-container,
.recipe-preview-container img,
.recipe-preview-container video,
.recipe-preview-media {
max-height: 32vh;
}
.recipe-gen-params h3 {
@@ -1070,7 +1093,6 @@
}
.param-content {
max-height: 90px;
padding: 10px;
}
@@ -1083,10 +1105,6 @@
gap: 6px;
}
.recipe-bottom-section {
padding-top: var(--space-1);
}
.recipe-section-header {
margin-bottom: var(--space-1);
}
+11 -11
View File
@@ -5,11 +5,11 @@
border: none;
padding: 8px 16px;
font-size: 0.9em;
transform: translateX(-50%) translateY(20px);
transform: translateY(20px);
}
.toast.toast-copy.show {
transform: translateX(-50%) translateY(0);
transform: translateY(0);
}
/* Toast Notifications */
@@ -19,14 +19,15 @@
right: 20px;
left: auto;
transform: translateX(120%);
min-width: 300px;
box-sizing: border-box;
min-width: 200px;
max-width: 400px;
background: var(--lora-surface);
color: var(--text-color);
padding: 12px 16px;
border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-toast);
z-index: calc(var(--z-overlay) + 10);
z-index: var(--z-toast);
opacity: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@@ -130,7 +131,6 @@
.toast {
width: calc(100% - 40px);
max-width: none;
right: 20px;
}
}
@@ -166,16 +166,17 @@
opacity: 1;
}
/* Toast Container for stacked notifications */
/* Toast Container for stacked notifications (top-right, flush below the header) */
.toast-container {
position: fixed;
top: 0;
top: var(--header-height, 48px); /* Start right below the fixed header */
right: 0;
z-index: calc(var(--z-overlay) + 10);
z-index: var(--z-toast);
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
padding: 20px;
padding: 8px 20px 0; /* Small breathing room below the header */
pointer-events: none; /* Allow clicking through the container */
width: 400px;
max-width: 100%;
@@ -215,8 +216,7 @@
/* Responsive adjustments */
@media (max-width: 480px) {
.toast-container {
width: 100%;
padding: 10px;
padding: 0 10px;
}
.toast {
+3
View File
@@ -27,6 +27,9 @@
--shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.25);
--shadow-inset-top: 0 -2px 8px rgba(0, 0, 0, 0.1);
--modal-backdrop-bg: rgba(0, 0, 0, 0.5);
--modal-backdrop-blur: 6px;
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
+22
View File
@@ -49,6 +49,28 @@ export async function fetchRecipeDetails(recipeId) {
return response.json();
}
export async function sendRecipeWorkflow(recipeId) {
if (!recipeId) {
throw new Error('Unable to determine recipe ID');
}
const encodedRecipeId = encodeURIComponent(recipeId);
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${encodedRecipeId}/send-workflow`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const result = await response.json();
if (!response.ok) {
return { success: false, error: result.error || response.statusText };
}
return result;
}
/**
* Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch
+57 -54
View File
@@ -4,7 +4,7 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { fetchRecipeDetails, updateRecipeMetadata } from '../api/recipeApi.js';
import { fetchRecipeDetails, updateRecipeMetadata, sendRecipeWorkflow } from '../api/recipeApi.js';
import { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js';
@@ -300,7 +300,7 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe);
this.syncSourceUrlAction();
this.syncHeaderActions();
// Show the modal
modalManager.showModal('recipeModal');
@@ -385,6 +385,10 @@ class RecipeModal {
nextRecipe.gen_params = preservedGenParams;
}
if (fullRecipe.has_workflow !== undefined) {
nextRecipe.has_workflow = fullRecipe.has_workflow;
}
if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint;
} else {
@@ -441,7 +445,7 @@ class RecipeModal {
} else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
}
this.syncSourceUrlAction();
this.syncHeaderActions();
}
getPreviewMediaUrl(recipe = {}) {
@@ -509,28 +513,64 @@ class RecipeModal {
}
}
syncSourceUrlAction() {
syncHeaderActions() {
const actionsContainer = document.getElementById('recipeHeaderActions');
if (!actionsContainer) {
return;
}
actionsContainer.innerHTML = '';
actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
if (this.currentRecipe?.has_workflow === true) {
const workflowBtn = document.createElement('button');
workflowBtn.className = 'recipe-source-url-btn';
workflowBtn.id = 'sendWorkflowBtn';
workflowBtn.title = 'Send Workflow to ComfyUI';
workflowBtn.innerHTML = '<i class="fas fa-project-diagram"></i> Send Workflow to ComfyUI';
workflowBtn.addEventListener('click', () => {
this.sendWorkflowToComfyUI();
});
actionsContainer.appendChild(workflowBtn);
}
const sourcePath = this.currentRecipe?.source_path || '';
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://');
if (!isValidUrl) {
if (isValidUrl) {
const btn = document.createElement('button');
btn.className = 'recipe-source-url-btn';
btn.title = sourcePath;
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
btn.addEventListener('click', () => {
window.open(sourcePath, '_blank');
});
actionsContainer.appendChild(btn);
}
}
async sendWorkflowToComfyUI() {
if (!this.recipeId) {
return;
}
const btn = document.createElement('button');
btn.className = 'recipe-source-url-btn';
btn.title = sourcePath;
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL';
btn.addEventListener('click', () => {
window.open(sourcePath, '_blank');
});
actionsContainer.appendChild(btn);
try {
const result = await sendRecipeWorkflow(this.recipeId);
if (result?.success) {
showToast('toast.recipes.workflowSent', {}, 'success', 'Workflow sent to ComfyUI');
return;
}
const error = result?.error || '';
if (error === 'Standalone Mode Active') {
showToast('toast.general.cannotInteractStandalone', {}, 'warning', 'Cannot interact with ComfyUI in standalone mode');
} else if (error === 'no_workflow') {
showToast('toast.recipes.workflowNoWorkflow', {}, 'warning', 'No embedded workflow found in this recipe');
} else {
showToast('toast.recipes.workflowSendFailed', { error }, 'error', `Failed to send workflow to ComfyUI: ${error}`);
}
} catch (error) {
console.error('Failed to send workflow to ComfyUI:', error);
showToast('toast.recipes.workflowSendFailed', { error: error.message }, 'error', `Failed to send workflow to ComfyUI: ${error.message}`);
}
}
syncTagsDisplay(tags) {
@@ -719,7 +759,7 @@ class RecipeModal {
}
}
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} LoRAs ${statusHTML}`;
lorasCountElement.innerHTML = `<i class="fas fa-layer-group"></i> ${totalCount} ${totalCount === 1 ? 'LoRA' : 'LoRAs'} ${statusHTML}`;
setTimeout(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
@@ -1153,7 +1193,7 @@ class RecipeModal {
// Update source URL in the UI
this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
this.syncSourceUrlAction();
this.syncHeaderActions();
// Update the current recipe object
this.currentRecipe.source_path = newSourceUrl;
@@ -1180,11 +1220,10 @@ class RecipeModal {
});
}
// Setup copy buttons for prompts and recipe syntax
// Setup copy buttons for prompts and send recipe button
setupCopyButtons() {
const copyPromptBtn = document.getElementById('copyPromptBtn');
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
const sendRecipeBtn = document.getElementById('sendRecipeBtn');
if (copyPromptBtn) {
@@ -1207,13 +1246,6 @@ class RecipeModal {
});
}
if (copyRecipeSyntaxBtn) {
copyRecipeSyntaxBtn.addEventListener('click', () => {
// Use backend API to get recipe syntax
this.fetchAndCopyRecipeSyntax();
});
}
if (sendRecipeBtn) {
sendRecipeBtn.addEventListener('click', () => {
// Send recipe to ComfyUI workflow
@@ -1299,35 +1331,6 @@ class RecipeModal {
});
}
// Fetch recipe syntax from backend and copy to clipboard
async fetchAndCopyRecipeSyntax() {
if (!this.recipeId) {
showToast('toast.recipes.noRecipeId', {}, 'error');
return;
}
try {
// Fetch recipe syntax from backend
const response = await fetch(`/api/lm/recipe/${this.recipeId}/syntax`);
if (!response.ok) {
throw new Error(`Failed to get recipe syntax: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.syntax) {
// Use the centralized copyToClipboard utility function
await copyToClipboard(data.syntax, 'Recipe syntax copied to clipboard');
} else {
throw new Error(data.error || 'No syntax returned from server');
}
} catch (error) {
console.error('Error fetching recipe syntax:', error);
showToast('toast.recipes.copyFailed', { message: error.message }, 'error');
}
}
// Helper method to copy text to clipboard
copyToClipboard(text, successMessage) {
copyToClipboard(text, successMessage);
+4
View File
@@ -9,6 +9,7 @@ import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
export class SidebarManager {
constructor() {
@@ -252,6 +253,9 @@ export class SidebarManager {
if (dataTransfer) {
dataTransfer.effectAllowed = 'move';
dataTransfer.setData('text/plain', filePaths.join(','));
// Tag the drag as an internal card drag so preview-drop handlers on
// other cards ignore it (no highlight, no preview replacement).
dataTransfer.setData(MODEL_CARD_DRAG_MIME_TYPE, filePaths.join(','));
try {
dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
} catch (error) {
@@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) {
const keyHandler = (e) => {
if (e.key === 'Escape') {
// Stop propagation so bubble-phase handlers (e.g. ModalManager's
// Escape handler) do not also close the modal underneath.
e.stopPropagation();
e.preventDefault();
closeMediaViewer();
return;
}
+34 -25
View File
@@ -1,10 +1,9 @@
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } from '../../utils/constants.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES, MODEL_CARD_DRAG_MIME_TYPE } from '../../utils/constants.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
@@ -304,10 +303,20 @@ function handleCardClick(card, modelType) {
}
}
// Preview URL is not in the dataset; read it from the card's rendered media
function getCardPreviewUrl(card) {
const cardMedia = card.querySelector('.card-preview img, .card-preview video');
if (!cardMedia) return '';
return cardMedia.tagName === 'VIDEO'
? (cardMedia.dataset.src || '')
: (cardMedia.src || '');
}
async function showModelModalFromCard(card, modelType) {
// Create model metadata object
const modelMeta = {
sha256: card.dataset.sha256,
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
@@ -397,6 +406,7 @@ function showExampleAccessModal(card, modelType) {
// Get the model data from card dataset (works for both lora and checkpoint)
const modelMeta = {
sha256: card.dataset.sha256,
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath,
model_name: card.dataset.name,
file_name: card.dataset.file_name,
@@ -421,30 +431,18 @@ function showExampleAccessModal(card, modelType) {
// Show the model modal
await showModelModal(modelMeta, modelType);
// Scroll to import area after modal is visible
// Reveal the import entry once the modal content has rendered
setTimeout(() => {
const importArea = document.querySelector('.example-import-area');
// Gallery mode: the import button is always visible — expand the zone
const importBtn = document.querySelector('#modelModal .gallery-import-btn');
if (importBtn) {
importBtn.click();
return;
}
// Empty state: the import area is the whole tab content — scroll to it
const importArea = document.querySelector('#modelModal .example-import-area');
if (importArea) {
const showcaseTab = document.getElementById('showcase-tab');
if (showcaseTab) {
// First make sure showcase tab is visible
const tabBtn = document.querySelector('.tab-btn[data-tab="showcase"]');
if (tabBtn && !tabBtn.classList.contains('active')) {
tabBtn.click();
}
// Then toggle showcase if collapsed
const carousel = showcaseTab.querySelector('.carousel');
if (carousel && carousel.classList.contains('collapsed')) {
const scrollIndicator = showcaseTab.querySelector('.scroll-indicator');
if (scrollIndicator) {
toggleShowcase(scrollIndicator);
}
}
// Finally scroll to the import area
importArea.scrollIntoView({ behavior: 'smooth' });
}
importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}, 500);
};
@@ -457,6 +455,9 @@ function showExampleAccessModal(card, modelType) {
export function createModelCard(model, modelType) {
const card = document.createElement('div');
card.className = 'model-card'; // Reuse the same class for styling
// Always draggable (move-to-folder in the sidebar). Accidental micro-drags
// from click jitter are rendered harmless by the preview-drop handlers
// below, which ignore internal card drags via MODEL_CARD_DRAG_MIME_TYPE.
card.draggable = true;
card.dataset.sha256 = model.sha256;
card.dataset.filepath = model.file_path;
@@ -649,7 +650,7 @@ export function createModelCard(model, modelType) {
<div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` :
`<img src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
`<img draggable="false" src="${versionedPreviewUrl}" alt="${model.model_name}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
<div class="card-header">
${shouldBlur ?
@@ -743,6 +744,11 @@ export function createModelCard(model, modelType) {
// Dropping an image/video onto the card replaces the model preview via the
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
// Internal card drags (move-to-folder) are tagged with a custom MIME type by
// SidebarManager and must be ignored here entirely: no highlight, no upload.
const isInternalCardDrag = (event) =>
Boolean(event.dataTransfer?.types?.includes(MODEL_CARD_DRAG_MIME_TYPE));
const preventDragDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
@@ -750,17 +756,20 @@ export function createModelCard(model, modelType) {
['dragenter', 'dragover'].forEach((eventName) => {
card.addEventListener(eventName, (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event);
card.classList.add('drag-over');
});
});
card.addEventListener('dragleave', (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event);
card.classList.remove('drag-over');
});
card.addEventListener('drop', (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event);
card.classList.remove('drag-over');
+9 -11
View File
@@ -2,8 +2,6 @@ import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, se
import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js';
import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop,
loadExampleImages
} from './showcase/ShowcaseView.js';
@@ -727,8 +725,6 @@ export async function showModelModal(model, modelType) {
updateCardUpdateAvailability(hasUpdate);
}
let showcaseCleanup;
const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId);
@@ -736,10 +732,6 @@ export async function showModelModal(model, modelType) {
modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler;
}
if (showcaseCleanup) {
showcaseCleanup();
showcaseCleanup = null;
}
cleanupNavigationShortcuts();
};
@@ -759,6 +751,14 @@ export async function showModelModal(model, modelType) {
if (modelType === 'embeddings' && modelWithFullData.folder) {
activeModalElement.dataset.folder = modelWithFullData.folder;
}
// Show the back-to-top button once the modal content is scrolled
const modalContent = activeModalElement.querySelector('.modal-content');
const backToTopBtn = activeModalElement.querySelector('.back-to-top');
if (modalContent && backToTopBtn) {
modalContent.addEventListener('scroll', () => {
backToTopBtn.classList.toggle('visible', modalContent.scrollTop > 300);
});
}
}
updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({
@@ -771,7 +771,6 @@ export async function showModelModal(model, modelType) {
onUpdateStatusChange: handleUpdateStatusChange,
});
setupEditableFields(modelWithFullData.file_path, modelType);
showcaseCleanup = setupShowcaseScroll(modalId);
setupTabSwitching({
onTabChange: async (tab) => {
if (tab === 'versions') {
@@ -814,7 +813,7 @@ export async function showModelModal(model, modelType) {
const customImages = modelWithFullData.civitai?.customImages || [];
// Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, modelWithFullData.sha256);
loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
}
function renderLoraSpecificContent(lora, escapedWords) {
@@ -1316,7 +1315,6 @@ async function handleSendToWorkflow(target, modelType) {
// Export the model modal API
const modelModal = {
show: showModelModal,
toggleShowcase,
scrollToTop
};
+55 -39
View File
@@ -573,46 +573,53 @@ function renderRow(version, options) {
);
const actions = [];
if (!version.isInLibrary) {
const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
let downloadTitle;
if (!canDownload) {
downloadTitle = translate(
'modals.model.versions.actions.downloadNotAllowedTooltip',
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isPaidPermanent(version)) {
downloadTitle = translate(
'modals.model.versions.actions.downloadPaidTooltip',
{},
'Download this paid version from Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
);
} else {
downloadTitle = translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
);
const canDownload = isDownloadAllowed(version);
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
let downloadTitle;
if (!canDownload) {
downloadTitle = translate(
'modals.model.versions.actions.downloadNotAllowedTooltip',
{},
'This version is only available for on-site generation on Civitai'
);
} else if (version.isInLibrary) {
// In-library versions may still have undownloaded weight files; the
// download modal's file dialog decides what remains (#1058).
downloadTitle = translate(
'modals.model.versions.actions.downloadRemainingTooltip',
{},
'Download remaining files of this version'
);
} else if (isPaidPermanent(version)) {
downloadTitle = translate(
'modals.model.versions.actions.downloadPaidTooltip',
{},
'Download this paid version from Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
{},
'Download this early access version from Civitai'
);
} else {
downloadTitle = translate(
'modals.model.versions.actions.downloadTooltip',
{},
'Download this version'
);
}
actions.push(buildActionButton(
downloadLabel,
canDownload ? 'version-action-primary' : 'version-action-disabled',
canDownload ? 'download' : '',
{
title: downloadTitle,
iconMarkup: downloadIcon,
disabled: !canDownload,
}
actions.push(buildActionButton(
downloadLabel,
canDownload ? 'version-action-primary' : 'version-action-disabled',
canDownload ? 'download' : '',
{
title: downloadTitle,
iconMarkup: downloadIcon,
disabled: !canDownload,
}
));
} else if (version.filePath) {
));
if (version.isInLibrary && version.filePath) {
actions.push(buildActionButton(
deleteLabel,
'version-action-danger',
@@ -1422,6 +1429,15 @@ export function initVersionsTab({
button.disabled = true;
try {
// In-library versions may still have undownloaded weight files
// (#1058). The tab payload has no per-file state, so open the
// download modal's file dialog, which refetches the full version
// payload and shows what remains.
if (version.isInLibrary) {
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
return;
}
const pathInfo = await resolveDownloadPathFromCurrentVersion();
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
@@ -4,9 +4,9 @@
*/
/**
* Generate video wrapper HTML
* Generate video wrapper HTML. The wrapper fills its container (the gallery's
* main viewer) and the media is letterboxed inside via object-fit: contain.
* @param {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML
@@ -15,11 +15,11 @@
* @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content
*/
export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
export function generateVideoWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
@@ -48,9 +48,9 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
}
/**
* Generate image wrapper HTML
* Generate image wrapper HTML. The wrapper fills its container (the gallery's
* main viewer) and the media is letterboxed inside via object-fit: contain.
* @param {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML
@@ -59,11 +59,11 @@ export function generateVideoWrapper(media, heightPercent, shouldBlur, nsfwText,
* @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content
*/
export function generateImageWrapper(media, heightPercent, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
export function generateImageWrapper(media, shouldBlur, nsfwText, metadataPanel, localUrl, remoteUrl, mediaControlsHtml = '') {
const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return `
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" style="padding-bottom: ${heightPercent}%" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
<div class="media-wrapper ${shouldBlur ? 'nsfw-media-wrapper' : ''}" data-short-id="${media.id || ''}" data-nsfw-level="${nsfwLevel}">
${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i>
+154 -168
View File
@@ -213,190 +213,170 @@ export function getRenderedMediaRect(mediaElement, containerWidth, containerHeig
}
/**
* Initialize metadata panel interaction handlers
* Initialize metadata panel interaction handlers: hover over the media reveals
* the panel and media controls (same as the legacy carousel). Panel-internal
* buttons and wheel isolation are bound here as well.
* @param {HTMLElement} container - Container element with media wrappers
*/
export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return;
let isOverMetadataPanel = false;
// Add event listeners to the wrapper for mouse tracking
wrapper.addEventListener('mousemove', (e) => {
// Get mouse position relative to wrapper
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Get the actual displayed dimensions of the media element
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
// Check if mouse is over the actual media content
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
// Show metadata panel and controls when over media content or metadata panel itself
if (isOverMedia || isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
if (metadataPanel) metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
// Add mouse enter/leave events for the metadata panel itself
if (metadataPanel) {
if (mediaElement) {
let isOverMetadataPanel = false;
// Hovering the actual media content reveals the metadata panel and controls
wrapper.addEventListener('mousemove', (e) => {
const rect = wrapper.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (isOverMedia || isOverMetadataPanel) {
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
} else {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
wrapper.addEventListener('mouseleave', () => {
if (!isOverMetadataPanel) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
});
metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true;
metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible');
});
metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false;
// Only hide if mouse is not over the media
const rect = wrapper.getBoundingClientRect();
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const isOverMedia = (
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
mouseY <= mediaRect.bottom
);
if (!isOverMedia) {
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
}
metadataPanel.classList.remove('visible');
if (mediaControls) mediaControls.classList.remove('visible');
});
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('toast.triggerWords.copyFailed', {}, 'error');
}
});
});
// Handle send prompt buttons
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
sendBtns.forEach(sendBtn => {
const promptIndex = sendBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
sendBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
let promptText = promptElement.textContent || '';
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
// Respect strip <lora> setting from global state
if (state.global.settings?.strip_lora_on_copy) {
promptText = stripLoraTags(promptText);
}
sendPromptToWorkflow(promptText);
});
});
// Handle send params buttons
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
if (paramsBtn) {
paramsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Collect gen params from the param-tag elements
const tagsContainer = wrapper.querySelector('.params-tags');
if (!tagsContainer) return;
const paramTags = tagsContainer.querySelectorAll('.param-tag');
const genParams = {};
// Map display labels to genParams keys
const labelToKey = {
'Seed': 'seed',
'Steps': 'steps',
'Sampler': 'sampler',
'CFG': 'cfg_scale',
};
paramTags.forEach(tag => {
const nameEl = tag.querySelector('.param-name');
const valueEl = tag.querySelector('.param-value');
if (!nameEl || !valueEl) return;
const label = nameEl.textContent.replace(':', '').trim();
const key = labelToKey[label];
if (key) {
genParams[key] = valueEl.textContent.trim();
}
});
if (Object.keys(genParams).length === 0) {
showToast('No sendable parameters found', {}, 'warning');
return;
}
await sendGenParamsToWorkflow(genParams);
});
}
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
}
// Prevent events from bubbling
metadataPanel.addEventListener('click', (e) => {
e.stopPropagation();
});
// Handle copy prompt buttons
const copyBtns = metadataPanel.querySelectorAll('.copy-prompt-btn');
copyBtns.forEach(copyBtn => {
const promptIndex = copyBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
try {
await copyToClipboard(promptElement.textContent, 'Prompt copied to clipboard');
} catch (err) {
console.error('Copy failed:', err);
showToast('toast.triggerWords.copyFailed', {}, 'error');
}
});
});
// Handle send prompt buttons
const sendBtns = metadataPanel.querySelectorAll('.send-prompt-btn');
sendBtns.forEach(sendBtn => {
const promptIndex = sendBtn.dataset.promptIndex;
const promptElement = wrapper.querySelector(`#prompt-${promptIndex}`);
sendBtn.addEventListener('click', async (e) => {
e.stopPropagation();
if (!promptElement) return;
let promptText = promptElement.textContent || '';
if (!promptText.trim()) {
showToast('toast.recipes.noPromptToSend', {}, 'warning');
return;
}
// Respect strip <lora> setting from global state
if (state.global.settings?.strip_lora_on_copy) {
promptText = stripLoraTags(promptText);
}
sendPromptToWorkflow(promptText);
});
});
// Handle send params buttons
const paramsBtn = metadataPanel.querySelector('.send-params-btn');
if (paramsBtn) {
paramsBtn.addEventListener('click', async (e) => {
e.stopPropagation();
// Collect gen params from the param-tag elements
const tagsContainer = wrapper.querySelector('.params-tags');
if (!tagsContainer) return;
const paramTags = tagsContainer.querySelectorAll('.param-tag');
const genParams = {};
// Map display labels to genParams keys
const labelToKey = {
'Seed': 'seed',
'Steps': 'steps',
'Sampler': 'sampler',
'CFG': 'cfg_scale',
};
paramTags.forEach(tag => {
const nameEl = tag.querySelector('.param-name');
const valueEl = tag.querySelector('.param-value');
if (!nameEl || !valueEl) return;
const label = nameEl.textContent.replace(':', '').trim();
const key = labelToKey[label];
if (key) {
genParams[key] = valueEl.textContent.trim();
}
});
if (Object.keys(genParams).length === 0) {
showToast('No sendable parameters found', {}, 'warning');
return;
}
await sendGenParamsToWorkflow(genParams);
});
}
// Prevent panel scroll from causing modal scroll
metadataPanel.addEventListener('wheel', (e) => {
const isAtTop = metadataPanel.scrollTop === 0;
const isAtBottom = metadataPanel.scrollHeight - metadataPanel.scrollTop === metadataPanel.clientHeight;
// Only prevent default if scrolling would cause the panel to scroll
if ((e.deltaY < 0 && !isAtTop) || (e.deltaY > 0 && !isAtBottom)) {
e.stopPropagation();
}
}, { passive: true });
});
}
@@ -525,6 +505,12 @@ export function initMediaControlHandlers(container) {
const result = await response.json();
if (result.success) {
// Let the gallery refresh itself (removes thumbnail + selects a neighbor)
mediaWrapper.dispatchEvent(new CustomEvent('example-media-deleted', {
bubbles: true,
detail: { shortId }
}));
// Success: remove the media wrapper from the DOM
mediaWrapper.style.opacity = '0';
mediaWrapper.style.height = '0';
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
// Initialize NSFW level buttons
initSetNsfwHandlers(container);
// Media control visibility is now handled in initMetadataPanelHandlers
// Media control visibility is handled with pure CSS (.media-wrapper:hover .media-controls)
// Any click handlers or other functionality can still be added here
}
File diff suppressed because it is too large Load Diff
+332 -41
View File
@@ -25,6 +25,12 @@ export class DownloadManager {
this.apiClient = null;
this.useDefaultPath = false;
// Multi-file selection state: selectedFile stays the first selected
// file for backward compatibility with single-file flows (#1058).
this.selectedFile = null;
this.selectedFiles = [];
this._lastDownloadError = null;
// Batch mode state
this.batchModels = [];
this.isBatchMode = false;
@@ -160,6 +166,8 @@ export class DownloadManager {
this.modelVersionId = null;
this.source = null;
this.selectedFile = null;
this.selectedFiles = [];
this._lastDownloadError = null;
this._isDiffusionModel = false;
this.selectedFolder = '';
@@ -546,6 +554,64 @@ export class DownloadManager {
await this.fetchVersionsForCurrentModel();
}
/**
* Open the download modal directly on the file-selection step for a
* specific model version (#1058). Used by entry points (e.g.
* ModelVersionsTab) whose version payloads lack per-file downloaded
* state, so the full versions payload is fetched here first.
*/
async openFileSelectionForVersion(modelType, modelId, versionId, { source = null } = {}) {
try {
this.apiClient = getModelApiClient(modelType);
} catch (error) {
this.apiClient = getModelApiClient();
}
this.showDownloadModal();
this.modelId = modelId ? modelId.toString() : null;
this.modelVersionId = versionId ? versionId.toString() : null;
this.source = source;
if (!this.modelId) {
return;
}
try {
this.loadingManager.showSimpleLoading(translate('modals.download.fetchingVersions'));
await this.retrieveVersionsForModel(this.modelId, this.source);
} catch (error) {
showToast('toast.downloads.loadError', { message: error.message }, 'error');
return;
} finally {
this.loadingManager.hide();
}
const version = this.versions.find(v => v.id.toString() === this.modelVersionId);
if (!version) {
console.warn('[download] openFileSelectionForVersion: version %s not found for model %s',
this.modelVersionId, this.modelId);
this.showVersionStep();
return;
}
const hasRemainingFiles = this._getWeightFiles(version).length > 1
&& this._getRemainingFiles(version).length > 0;
if (hasRemainingFiles) {
this.showFileSelectionStep(version.id);
return;
}
// Nothing left to download for this version (single file or all
// files already in the library) — fall back to the version step.
if (version.existsLocally) {
showToast('toast.loras.versionExists', {}, 'info');
}
this.currentVersion = version;
this.showVersionStep();
}
showVersionStep() {
document.getElementById('urlStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block';
@@ -595,7 +661,10 @@ export class DownloadManager {
</div>`;
}
const fileBadge = modelFiles.length > 1 && !existsLocally
// Always offer the file-selection entry for multi-file versions,
// even when the version is already (partially) in the library, so
// remaining files can still be downloaded (#1058).
const fileBadge = modelFiles.length > 1
? `<span class="file-select-badge" data-version-id="${version.id}">
<i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
</span>`
@@ -667,9 +736,14 @@ export class DownloadManager {
const nextButton = document.getElementById('nextFromVersion');
if (!nextButton) return;
const existsLocally = this.currentVersion?.existsLocally;
const version = this.currentVersion;
const existsLocally = version?.existsLocally;
// A partially downloaded multi-file version still has downloadable
// files, so Next routes into the file dialog instead of blocking (#1058).
const hasRemainingFiles = this._getWeightFiles(version).length > 1
&& this._getRemainingFiles(version).length > 0;
if (existsLocally) {
if (existsLocally && !hasRemainingFiles) {
nextButton.disabled = true;
nextButton.classList.add('disabled');
nextButton.textContent = translate('modals.download.alreadyInLibrary');
@@ -680,14 +754,41 @@ export class DownloadManager {
}
}
_getWeightFiles(version) {
return (version?.files || []).filter(f => isModelWeightFile(f.type));
}
_getRemainingFiles(version) {
const downloadedIds = new Set(
(version?.downloadedFiles || []).map(f => String(f.fileId))
);
return this._getWeightFiles(version).filter(f => !downloadedIds.has(String(f.id)));
}
// Files of type UNet / Diffusion Model are routed to the diffusion_model
// root while regular files go to the model-type root, so a single
// multi-file selection session must stay within one routing group.
_getFileRoutingGroup(file) {
return (file.type === 'UNet' || file.type === 'Diffusion Model') ? 'diffusion' : 'model';
}
showFileSelectionStep(versionId) {
const version = this.versions.find(v => v.id.toString() === versionId.toString());
if (!version) return;
this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
// Start each file-selection session with a clean selection
this.selectedFiles = [];
this.selectedFile = null;
const modelFiles = this._getWeightFiles(version);
const downloadedIds = new Set(
(version.downloadedFiles || []).map(f => String(f.fileId))
);
document.getElementById('versionStep').style.display = 'none';
// Hide every other step — this dialog can be entered directly from
// entry points like ModelVersionsTab, where the URL step would
// otherwise remain visible (#1058).
document.querySelectorAll('.download-step').forEach(step => step.style.display = 'none');
document.getElementById('fileSelectionStep').style.display = 'block';
const nameEl = document.getElementById('fileSelectionVersionName');
@@ -699,9 +800,12 @@ export class DownloadManager {
container.innerHTML = modelFiles.map(file => {
const meta = file.metadata || {};
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--';
const isSelected = this.selectedFile?.id === file.id;
const isDownloaded = downloadedIds.has(String(file.id));
const tags = [];
if (isDownloaded) {
tags.push(`<span class="file-tag in-library">${translate('modals.download.fileSelection.inLibrary', {}, 'In Library')}</span>`);
}
if (meta.size) tags.push(`<span class="file-tag size">${meta.size}</span>`);
if (meta.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</span>`);
@@ -709,9 +813,9 @@ export class DownloadManager {
const fileName = file.name || '';
return `
<div class="file-option ${isSelected ? 'selected' : ''}" data-file-id="${file.id}">
<div class="file-option ${isDownloaded ? 'disabled' : ''}" data-file-id="${file.id}">
<div class="file-option-radio">
<input type="radio" name="fileSelection" value="${file.id}" ${isSelected ? 'checked' : ''}>
<input type="checkbox" name="fileSelection" value="${file.id}" ${isDownloaded ? 'disabled' : ''}>
</div>
<div class="file-option-info">
<div class="file-option-tags">
@@ -725,33 +829,80 @@ export class DownloadManager {
}).join('');
container.querySelectorAll('.file-option').forEach(el => {
el.addEventListener('click', () => {
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
const radio = el.querySelector('input[type="radio"]');
if (radio) radio.checked = true;
el.addEventListener('click', (event) => {
// Already-downloaded files stay disabled regardless
if (el.classList.contains('disabled')) {
event.preventDefault();
return;
}
const checkbox = el.querySelector('input[type="checkbox"]');
if (!checkbox || checkbox.disabled) {
event.preventDefault();
return;
}
// Clicking the checkbox directly toggles natively; clicking
// anywhere else on the option toggles it programmatically.
if (event.target !== checkbox) {
checkbox.checked = !checkbox.checked;
}
this._syncFileSelectionState();
});
});
}
confirmFileSelection() {
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked');
if (!selectedRadio) {
console.warn('[download] confirmFileSelection: no radio button checked');
return;
}
// Sync this.selectedFiles with the DOM checkboxes and enforce the
// mixed-type routing guard by disabling the other routing group.
_syncFileSelectionState() {
const container = document.getElementById('fileSelectionList');
if (!container || !this.currentVersion) return;
const checkedValues = new Set(
Array.from(container.querySelectorAll('input[type="checkbox"]:checked'))
.map(cb => cb.value)
);
const modelFiles = this._getWeightFiles(this.currentVersion);
this.selectedFiles = modelFiles.filter(f => checkedValues.has(f.id.toString()));
this.selectedFile = this.selectedFiles[0] || null;
const activeGroup = this.selectedFiles.length > 0
? this._getFileRoutingGroup(this.selectedFiles[0])
: null;
container.querySelectorAll('.file-option').forEach(el => {
const checkbox = el.querySelector('input[type="checkbox"]');
if (!checkbox || el.classList.contains('disabled')) return;
const file = modelFiles.find(f => f.id.toString() === el.dataset.fileId);
const groupBlocked = activeGroup !== null
&& file
&& this._getFileRoutingGroup(file) !== activeGroup
&& !checkbox.checked;
el.classList.toggle('selected', checkbox.checked);
el.classList.toggle('group-disabled', groupBlocked);
checkbox.disabled = groupBlocked;
});
}
confirmFileSelection() {
const version = this.currentVersion;
if (!version) {
console.warn('[download] confirmFileSelection: no currentVersion set');
return;
}
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
// Sync from the DOM first so programmatically checked boxes count too
this._syncFileSelectionState();
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata);
if (this.selectedFiles.length === 0) {
console.warn('[download] confirmFileSelection: no file selected');
showToast('toast.loras.pleaseSelectFile', {}, 'error');
return;
}
console.log('[download] confirmFileSelection: %d file(s) selected — %o',
this.selectedFiles.length,
this.selectedFiles.map(f => ({ id: f.id, name: f.name, type: f.type })));
document.getElementById('fileSelectionStep').style.display = 'none';
document.getElementById('downloadLocationStep').style.display = 'block';
@@ -782,6 +933,13 @@ export class DownloadManager {
return;
}
if (this.currentVersion.existsLocally) {
// Multi-file versions with remaining undownloaded files route
// into the file dialog instead of being blocked outright (#1058).
if (this._getWeightFiles(this.currentVersion).length > 1
&& this._getRemainingFiles(this.currentVersion).length > 0) {
this.showFileSelectionStep(this.currentVersion.id);
return;
}
showToast('toast.loras.versionExists', {}, 'info');
return;
}
@@ -916,6 +1074,9 @@ export class DownloadManager {
source = null,
fileParams = null,
closeModal = false,
deferReload = false,
suppressSuccessToast = false,
suppressFailureSummary = false,
}) {
const config = this.apiClient?.apiConfig?.config;
@@ -924,7 +1085,8 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false, deferReload, suppressSuccessToast, suppressFailureSummary };
this._lastDownloadError = null;
let ws = null;
let updateProgress = () => { };
let cancelled = false;
@@ -1007,7 +1169,9 @@ export class DownloadManager {
if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName);
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
if (!suppressSuccessToast) {
showToast('toast.loras.downloadSkippedByBaseModel', { baseModel: response.base_model || 'Unknown' }, 'warning');
}
if (closeModal) {
modalManager.closeModal('downloadModal');
}
@@ -1016,6 +1180,22 @@ export class DownloadManager {
if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
const errorMessage = response?.error || 'Unknown error';
// When the caller aggregates failures itself (multi-file
// loop), just record the error and return (#1058).
if (suppressFailureSummary) {
this._lastDownloadError = errorMessage;
return false;
}
// A file-level "already in library" rejection is an expected
// outcome when browsing files of a partially downloaded
// version — surface it as a lightweight toast instead of the
// failure summary modal so the user can simply go back and
// pick another file (#1058).
if (typeof errorMessage === 'string' && errorMessage.includes('already exists in')) {
showToast(errorMessage, {}, 'info');
return false;
}
showDownloadBatchSummary({
total: 1,
completed: 0,
@@ -1026,7 +1206,7 @@ export class DownloadManager {
source,
url: this._buildSingleItemUrl({ modelId, versionId, source }),
},
error: response?.error || 'Unknown error',
error: errorMessage,
name: displayName,
}],
onRetry: () => this.executeDownloadWithProgress(retryParams),
@@ -1034,7 +1214,9 @@ export class DownloadManager {
return false;
}
showToast('toast.loras.downloadCompleted', {}, 'success');
if (!suppressSuccessToast) {
showToast('toast.loras.downloadCompleted', {}, 'success');
}
if (closeModal) {
modalManager.closeModal('downloadModal');
@@ -1045,29 +1227,35 @@ export class DownloadManager {
ws = null;
}
const pageState = this.apiClient.getPageState();
if (!deferReload) {
const pageState = this.apiClient.getPageState();
if (!useDefaultPaths && targetFolder) {
pageState.activeFolder = targetFolder;
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
if (!useDefaultPaths && targetFolder) {
pageState.activeFolder = targetFolder;
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive);
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
document.querySelectorAll('.folder-tags .tag').forEach(tag => {
const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive);
if (isActive && !tag.parentNode.classList.contains('collapsed')) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
});
}
await resetAndReload(true);
}
await resetAndReload(true);
return true;
} catch (error) {
if (cancelled) {
console.log('Download cancelled by user:', downloadId);
} else {
console.error('Failed to download model version:', error);
if (suppressFailureSummary) {
this._lastDownloadError = error?.message || 'Unknown error';
return false;
}
showDownloadBatchSummary({
total: 1,
completed: 0,
@@ -1097,6 +1285,89 @@ export class DownloadManager {
}
}
/**
* Download multiple selected files of the same version sequentially,
* reusing the location-step choices for every file. Per-file toasts,
* reloads and failure modals are suppressed; a single aggregated result
* is shown at the end (design decision D5, #1058).
*/
async _downloadSelectedFilesSequentially({ modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot = false, files = null }) {
const filesToDownload = files || this.selectedFiles;
const totalFiles = filesToDownload.length;
const failedItems = [];
let completedDownloads = 0;
for (const file of filesToDownload) {
const fileParams = {
id: file.id,
name: file.name || null,
type: file.type || 'Model',
format: file.metadata?.format || null,
size: file.metadata?.size || null,
fp: file.metadata?.fp || null,
};
console.log('[download] multi-file loop: downloading file id=%s, name="%s" (%d/%d)',
fileParams.id, fileParams.name, completedDownloads + failedItems.length + 1, totalFiles);
const success = await this.executeDownloadWithProgress({
modelId: this.modelId,
versionId: this.currentVersion.id,
versionName: file.name || `${this.currentVersion.name} #${file.id}`,
modelRoot,
targetFolder,
useDefaultPaths,
useSaveDirAsRoot,
source: this.source,
fileParams,
closeModal: false,
deferReload: true,
suppressSuccessToast: true,
suppressFailureSummary: true,
});
if (success) {
completedDownloads++;
} else {
failedItems.push({
item: {
modelId: this.modelId,
versionId: this.currentVersion.id,
source: this.source,
file,
url: this._buildSingleItemUrl({
modelId: this.modelId,
versionId: this.currentVersion.id,
source: this.source,
}),
},
error: this._lastDownloadError || 'Unknown error',
name: file.name || `#${file.id}`,
});
}
}
if (failedItems.length === 0) {
showToast('toast.loras.allDownloadSuccessful', { count: completedDownloads }, 'success');
} else {
showDownloadBatchSummary({
total: totalFiles,
completed: completedDownloads,
failedItems,
onRetry: () => this._downloadSelectedFilesSequentially({
modelRoot,
targetFolder,
useDefaultPaths,
useSaveDirAsRoot,
files: failedItems.map(f => f.item.file),
}),
});
}
await resetAndReload(true);
return failedItems.length === 0;
}
async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar();
@@ -1307,6 +1578,14 @@ export class DownloadManager {
? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally;
// Multi-file versions that are only partially downloaded get a
// distinct hint instead of the plain in-library badge (#1058).
const isPartiallyDownloaded = existsLocally
&& this._getWeightFiles(ver).length > 1
&& this._getRemainingFiles(ver).length > 0;
const localBadgeLabel = isPartiallyDownloaded
? translate('modals.download.partiallyDownloaded', {}, 'Partially downloaded')
: translate('modals.download.inLibrary');
return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail">
@@ -1317,7 +1596,7 @@ export class DownloadManager {
<div class="batch-preview-meta">
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
<span>${fileSize} MB</span>
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${translate('modals.download.inLibrary')}</span>` : ''}
${existsLocally ? `<span class="batch-preview-local-badge"><i class="fas fa-check"></i> ${localBadgeLabel}</span>` : ''}
</div>
</div>
${item.versions.length > 1 ? `
@@ -1608,8 +1887,20 @@ export class DownloadManager {
});
}
// Multi-file selection: download all selected files sequentially,
// reusing the chosen location for every file (#1058).
if (this.selectedFiles.length > 1) {
modalManager.closeModal('downloadModal');
return this._downloadSelectedFilesSequentially({
modelRoot,
targetFolder,
useDefaultPaths,
});
}
const fileParams = this.selectedFile ? {
id: this.selectedFile.id,
name: this.selectedFile.name || null,
type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || null,
size: this.selectedFile.metadata?.size || null,
+82 -42
View File
@@ -25,7 +25,7 @@ export class ImportManager {
this.selectedFolder = '';
this.downloadableLoRAs = [];
this.recipeId = null;
this.importMode = 'url'; // Default mode: 'url' or 'upload'
this.importMode = null; // Set by input handlers: 'url' or 'upload'
this.useDefaultPath = false;
this.apiClient = null;
@@ -70,10 +70,8 @@ export class ImportManager {
this.stepManager.removeInjectedStyles();
});
// Verify visibility and focus on URL input
// Verify visibility and focus on the URL input (primary mode)
setTimeout(() => {
// Ensure URL option is selected and focus on the input
this.toggleImportMode('url');
const urlInput = document.getElementById('imageUrlInput');
if (urlInput) {
urlInput.focus();
@@ -87,6 +85,62 @@ export class ImportManager {
if (useDefaultPathToggle) {
useDefaultPathToggle.addEventListener('change', this.handleToggleDefaultPath);
}
const modal = document.getElementById('importModal');
const dropZone = document.getElementById('importDropZone');
const fileInput = document.getElementById('recipeImageUpload');
const urlInput = document.getElementById('imageUrlInput');
// Submit URL with Enter
if (urlInput) {
urlInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
this.handleUrlInput();
}
});
}
if (dropZone && fileInput) {
// Click or keyboard activation opens the file picker
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
fileInput.click();
}
});
// Drag & drop
dropZone.addEventListener('dragover', (event) => {
event.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (event) => {
event.preventDefault();
dropZone.classList.remove('drag-over');
const file = event.dataTransfer?.files?.[0];
if (file) {
this.imageProcessor.handleDroppedFile(file);
}
});
}
// Paste an image from clipboard while the modal is open
if (modal) {
modal.addEventListener('paste', (event) => {
if (this.stepManager.currentStep !== 'uploadStep') return;
const file = Array.from(event.clipboardData?.files || [])
.find(f => f.type.startsWith('image/'));
if (file) {
event.preventDefault();
this.imageProcessor.handleDroppedFile(file);
}
});
}
}
resetSteps() {
@@ -128,9 +182,11 @@ export class ImportManager {
this.downloadableLoRAs = [];
this.selectedFolder = '';
// Reset import mode
this.importMode = 'url';
this.toggleImportMode('url');
// Import mode is set by the input handlers ('url' or 'upload')
this.importMode = null;
// Reset drop zone filename feedback
this.updateSelectedFileName(null);
// Clear folder tree selection
if (this.folderTreeManager) {
@@ -166,43 +222,24 @@ export class ImportManager {
}
}
toggleImportMode(mode) {
this.importMode = mode;
/**
* Show the selected file name in the drop zone, or restore the default
* hint text when called with null.
*/
updateSelectedFileName(fileName) {
const nameEl = document.getElementById('selectedFileName');
const hintEl = document.getElementById('dropZonePrimaryText');
if (!nameEl || !hintEl) return;
// Update toggle buttons
const uploadBtn = document.querySelector('.toggle-btn[data-mode="upload"]');
const urlBtn = document.querySelector('.toggle-btn[data-mode="url"]');
if (uploadBtn && urlBtn) {
if (mode === 'upload') {
uploadBtn.classList.add('active');
urlBtn.classList.remove('active');
} else {
uploadBtn.classList.remove('active');
urlBtn.classList.add('active');
}
if (fileName) {
nameEl.textContent = fileName;
nameEl.style.display = 'block';
hintEl.style.display = 'none';
} else {
nameEl.textContent = '';
nameEl.style.display = 'none';
hintEl.style.display = '';
}
// Show/hide appropriate sections
const uploadSection = document.getElementById('uploadSection');
const urlSection = document.getElementById('urlSection');
if (uploadSection && urlSection) {
if (mode === 'upload') {
uploadSection.style.display = 'block';
urlSection.style.display = 'none';
} else {
uploadSection.style.display = 'none';
urlSection.style.display = 'block';
}
}
// Clear error messages
const uploadError = document.getElementById('uploadError');
const importUrlError = document.getElementById('importUrlError');
if (uploadError) uploadError.textContent = '';
if (importUrlError) importUrlError.textContent = '';
}
handleImageUpload(event) {
@@ -345,6 +382,9 @@ export class ImportManager {
const urlInput = document.getElementById('imageUrlInput');
if (urlInput) urlInput.value = '';
// Reset drop zone filename feedback
this.updateSelectedFileName(null);
// Clear error messages
const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = '';
+3
View File
@@ -904,6 +904,9 @@ export class SettingsManager {
// Helper to update model Combobox presets from catalog / Ollama API
const llmModelInput = document.getElementById('llmModel');
this._llmModelCombobox = null;
if (llmModelInput) {
llmModelInput.value = state.global.settings.llm_model || '';
}
if (llmModelInput && typeof Combobox !== 'undefined') {
const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai';
const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []);
+50 -9
View File
@@ -8,20 +8,32 @@ export class ImageProcessor {
handleFileUpload(event) {
const file = event.target.files[0];
if (file) {
this.handleDroppedFile(file);
}
}
/**
* Shared entry for files coming from the file picker, drag & drop,
* or clipboard paste.
*/
handleDroppedFile(file) {
const errorElement = document.getElementById('uploadError');
if (!file) return;
// Validate file type
if (!file.type.match('image.*')) {
errorElement.textContent = translate('recipes.controls.import.errors.selectImageFile', {}, 'Please select an image file');
return;
}
// Reset error
errorElement.textContent = '';
this.importManager.recipeImage = file;
this.importManager.importMode = 'upload';
// Show the selected file name in the drop zone
this.importManager.updateSelectedFileName(file.name);
// Auto-proceed to next step if file is selected
this.importManager.uploadAndAnalyzeImage();
}
@@ -30,19 +42,37 @@ export class ImageProcessor {
const urlInput = document.getElementById('imageUrlInput');
const errorElement = document.getElementById('importUrlError');
const input = urlInput.value.trim();
// Validate input
if (!input) {
errorElement.textContent = translate('recipes.controls.import.errors.enterUrlOrPath', {}, 'Please enter a URL or file path');
return;
}
// Front-end format validation before hitting the backend
if (input.startsWith('http://') || input.startsWith('https://')) {
try {
new URL(input);
} catch {
errorElement.textContent = translate('recipes.controls.import.errors.invalidUrl', {}, 'Please enter a valid URL');
return;
}
} else if (!/\.(png|jpe?g|webp|gif|bmp|avif|jxl|mp4|webm)$/i.test(input)) {
errorElement.textContent = translate('recipes.controls.import.errors.invalidInputFormat', {}, 'Please enter an image URL or a local image file path');
return;
}
// Reset error
errorElement.textContent = '';
this.importManager.importMode = 'url';
// Put the fetch button into a loading state to prevent duplicate submits
const fetchBtn = document.getElementById('fetchImageBtn');
this._setFetchButtonLoading(fetchBtn, true);
// Show loading indicator
this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.processingInput', {}, 'Processing input...'));
try {
// Check if it's a URL or a local file path
if (input.startsWith('http://') || input.startsWith('https://')) {
@@ -55,10 +85,21 @@ export class ImageProcessor {
} catch (error) {
errorElement.textContent = error.message || 'Failed to process input';
} finally {
this._setFetchButtonLoading(fetchBtn, false);
this.importManager.loadingManager.hide();
}
}
_setFetchButtonLoading(button, isLoading) {
if (!button) return;
button.disabled = isLoading;
button.classList.toggle('loading', isLoading);
const icon = button.querySelector('i');
if (icon) {
icon.className = isLoading ? 'fas fa-spinner fa-spin' : 'fas fa-download';
}
}
async analyzeImageFromUrl(url) {
try {
// Call the API with URL data
@@ -1,6 +1,7 @@
export class ImportStepManager {
constructor() {
this.injectedStyles = null;
this.currentStep = null;
}
removeInjectedStyles() {
@@ -18,6 +19,7 @@ export class ImportStepManager {
showStep(stepId) {
// Remove any injected styles to prevent conflicts
this.removeInjectedStyles();
this.currentStep = stepId;
// Hide all steps first
document.querySelectorAll('.import-step').forEach(step => {
+4
View File
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
UNKNOWN: "Other"
};
// Custom dataTransfer MIME type tagging internal model-card drags (move-to-folder).
// Preview-drop handlers use it to ignore drags that did not come from the OS file system.
export const MODEL_CARD_DRAG_MIME_TYPE = 'application/x-lora-manager-model-card';
// Model sub-type display names (new canonical field: sub_type)
export const MODEL_SUBTYPE_DISPLAY_NAMES = {
// LoRA sub-types
+18 -28
View File
@@ -5,47 +5,37 @@
<h2>{{ t('recipes.controls.import.action') }}</h2>
</div>
<!-- Step 1: Upload Image or Input URL -->
<!-- Step 1: Provide Image (URL first, or drop zone below) -->
<div class="import-step" id="uploadStep">
<div class="import-mode-toggle">
<button class="toggle-btn active" data-mode="url" onclick="importManager.toggleImportMode('url')">
<i class="fas fa-link"></i> {{ t('recipes.controls.import.urlLocalPath') }}
</button>
<button class="toggle-btn" data-mode="upload" onclick="importManager.toggleImportMode('upload')">
<i class="fas fa-upload"></i> {{ t('recipes.controls.import.uploadImage') }}
</button>
</div>
<!-- Input URL/Path Section -->
<p class="import-description">{{ t('recipes.controls.import.title') }}</p>
<!-- Input URL/Path Section (primary mode) -->
<div class="import-section" id="urlSection">
<p>{{ t('recipes.controls.import.urlSectionDescription') }}</p>
<div class="input-group">
<label for="imageUrlInput">{{ t('recipes.controls.import.imageUrlOrPath') }}</label>
<div class="input-with-button">
<input type="text" id="imageUrlInput" placeholder="{{ t('recipes.controls.import.urlPlaceholder') }}">
<button class="primary-btn" onclick="importManager.handleUrlInput()">
<button class="primary-btn" id="fetchImageBtn" onclick="importManager.handleUrlInput()">
<i class="fas fa-download"></i> {{ t('recipes.controls.import.fetchImage') }}
</button>
</div>
<div class="error-message" id="importUrlError"></div>
</div>
</div>
<!-- Upload Image Section -->
<div class="import-section" id="uploadSection">
<p>{{ t('recipes.controls.import.uploadSectionDescription') }}</p>
<div class="input-group">
<label for="recipeImageUpload">{{ t('recipes.controls.import.selectImage') }}</label>
<div class="file-input-wrapper">
<input type="file" id="recipeImageUpload" accept="image/*" onchange="importManager.handleImageUpload(event)">
<div class="file-input-button">
<i class="fas fa-upload"></i> {{ t('recipes.controls.import.selectImage') }}
</div>
</div>
<div class="error-message" id="uploadError"></div>
</div>
<div class="import-divider"><span>{{ t('recipes.controls.import.orDivider') }}</span></div>
<!-- Unified drop zone: click to browse, drag & drop, or paste an image -->
<div class="import-drop-zone" id="importDropZone" tabindex="0" role="button"
aria-label="{{ t('recipes.controls.import.dropZoneLabel') }}">
<input type="file" id="recipeImageUpload" accept="image/*" hidden
onchange="importManager.handleImageUpload(event)">
<i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
<p class="drop-zone-primary" id="dropZonePrimaryText">{{ t('recipes.controls.import.dropZoneHint') }}</p>
<p class="drop-zone-filename" id="selectedFileName" style="display: none;"></p>
</div>
<div class="error-message" id="uploadError"></div>
<div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('importModal')">{{ t('common.actions.cancel') }}</button>
</div>
@@ -0,0 +1,69 @@
{# Shared building blocks for the settings modal sections. #}
{# Usage: {% import 'components/modals/settings/_macros.html' as sm with context %} #}
{# `with context` is required so macros can call the `t()` translation function. #}
{% macro setting_toggle(id, key, label, help='') %}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="{{ id }}">
{{ t(label) }}
{% if help %}<i class="fas fa-info-circle info-icon" data-tooltip="{{ t(help) }}"></i>{% endif %}
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="{{ id }}" onchange="settingsManager.saveToggleSetting('{{ id }}', '{{ key }}')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
{% endmacro %}
{% macro setting_select(id, key, label, options, help='') %}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="{{ id }}">
{{ t(label) }}
{% if help %}<i class="fas fa-info-circle info-icon" data-tooltip="{{ t(help) }}"></i>{% endif %}
</label>
</div>
<div class="setting-control select-control">
<select id="{{ id }}" onchange="settingsManager.saveSelectSetting('{{ id }}', '{{ key }}')">
{% for value, option_label in options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
{% endmacro %}
{% macro setting_input(id, key, label, placeholder, help='') %}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="{{ id }}">
{{ t(label) }}
{% if help %}<i class="fas fa-info-circle info-icon" data-tooltip="{{ t(help) }}"></i>{% endif %}
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text" id="{{ id }}"
placeholder="{{ t(placeholder) }}"
onblur="settingsManager.saveInputSetting('{{ id }}', '{{ key }}')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro subsection_header(title) %}
<div class="settings-subsection-header">
<h4>{{ t(title) }}</h4>
</div>
{% endmacro %}
@@ -0,0 +1,326 @@
{% import 'components/modals/settings/_macros.html' as sm with context %}
<!-- Section 1: General -->
<div id="section-general" class="settings-section active" data-section="general">
<!-- Language -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="languageSelect">
{{ t('common.language.select') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('common.language.select_help') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="languageSelect" onchange="settingsManager.saveLanguageSetting()">
<option value="en">{{ t('common.language.english') }}</option>
<option value="zh-CN">{{ t('common.language.chinese_simplified') }}</option>
<option value="zh-TW">{{ t('common.language.chinese_traditional') }}</option>
<option value="ru">{{ t('common.language.russian') }}</option>
<option value="de">{{ t('common.language.german') }}</option>
<option value="ja">{{ t('common.language.japanese') }}</option>
<option value="ko">{{ t('common.language.korean') }}</option>
<option value="fr">{{ t('common.language.french') }}</option>
<option value="es">{{ t('common.language.spanish') }}</option>
<option value="he">{{ t('common.language.Hebrew') }}</option>
</select>
</div>
</div>
</div>
<!-- Storage Location -->
{{ sm.setting_toggle('usePortableSettings', 'use_portable_settings', 'settings.storage.locationLabel', 'settings.storage.locationHelp') }}
<!-- API Configuration -->
<div class="setting-item api-key-item">
<div class="setting-row">
<div class="setting-info">
<label>{{ t('settings.civitaiApiKey') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.civitaiApiKeyHelp') }}"></i>
</div>
<div class="setting-control">
<!-- Status display (shown when not editing) -->
<div id="civitaiApiKeyStatus" class="api-key-status">
<span id="civitaiApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
<i class="fas fa-times-circle text-error"></i>
{{ t('settings.civitaiApiKeyNotConfigured') }}
</span>
<button type="button" class="secondary-btn" id="civitaiApiKeyActionBtn" onclick="settingsManager.editApiKey()">
{{ t('settings.civitaiApiKeySet') }}
</button>
</div>
<!-- Inline edit view (shown when editing) -->
<div id="civitaiApiKeyEdit" class="api-key-edit is-hidden">
<div class="api-key-input">
<input type="text"
id="civitaiApiKey"
class="api-key-masked"
placeholder="{{ t('settings.civitaiApiKeyPlaceholder') }}"
autocomplete="off"
data-mask="css" />
<button type="button" class="toggle-visibility">
<i class="fas fa-eye"></i>
</button>
</div>
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey()">{{ t('common.actions.save') }}</button>
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey()">{{ t('common.actions.cancel') }}</button>
</div>
</div>
</div>
</div>
{{ sm.setting_select('civitaiHost', 'civitai_host', 'settings.civitaiHost.label', [
('civitai.com', 'settings.civitaiHost.options.com'),
('civitai.red', 'settings.civitaiHost.options.red'),
], 'settings.civitaiHost.help') }}
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.downloads') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadBackend">{{ t('settings.downloadBackend.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadBackend.help') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Aria2-Download-Backend-(Experimental)" target="_blank" rel="noopener" aria-label="{{ t('settings.aria2HelpLink') }}" title="{{ t('settings.aria2HelpLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="setting-control select-control">
<select id="downloadBackend" onchange="settingsManager.saveSelectSetting('downloadBackend', 'download_backend')">
<option value="python">{{ t('settings.downloadBackend.options.python') }}</option>
<option value="aria2">{{ t('settings.downloadBackend.options.aria2') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="aria2PathSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="aria2cPath">{{ t('settings.aria2cPath.label') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aria2cPath.help') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input type="text"
id="aria2cPath"
placeholder="{{ t('settings.aria2cPath.placeholder') }}"
onblur="settingsManager.saveInputSetting('aria2cPath', 'aria2c_path')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- AI Provider Configuration (BYOK) -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.aiProvider.title') }}
{{ sm.setting_select('llmProvider', 'llm_provider', 'settings.aiProvider.provider', [
('openai', 'settings.aiProvider.providerOptions.openai'),
('ollama', 'settings.aiProvider.providerOptions.ollama'),
('deepseek', 'settings.aiProvider.providerOptions.deepseek'),
('groq', 'settings.aiProvider.providerOptions.groq'),
('openrouter', 'settings.aiProvider.providerOptions.openrouter'),
('google', 'settings.aiProvider.providerOptions.google'),
('opencode-go', 'settings.aiProvider.providerOptions.opencode-go'),
('custom', 'settings.aiProvider.providerOptions.custom'),
], 'settings.aiProvider.providerHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="llmApiBase">{{ t('settings.aiProvider.apiBase') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.apiBaseHelp') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper lm-combobox-container">
<input type="text" id="llmApiBase"
class="lm-combobox-input"
placeholder="{{ t('settings.aiProvider.apiBasePlaceholder') }}"
autocomplete="off"
onblur="settingsManager.saveInputSetting('llmApiBase', 'llm_api_base')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
<div class="setting-item api-key-item">
<div class="setting-row">
<div class="setting-info">
<label>{{ t('settings.aiProvider.apiKey') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.apiKeyHelp') }}"></i>
</div>
<div class="setting-control">
<div id="llmApiKeyStatus" class="api-key-status">
<span id="llmApiKeyStatusText" class="api-key-status-text api-key-status--unconfigured">
<i class="fas fa-times-circle text-error"></i>
{{ t('settings.aiProvider.apiKeyNotSet') }}
</span>
<button type="button" class="secondary-btn" id="llmApiKeyActionBtn" onclick="settingsManager.editApiKey('llm_api_key', 'llmApiKey')">
{{ t('settings.aiProvider.apiKeySet') }}
</button>
</div>
<div id="llmApiKeyEdit" class="api-key-edit is-hidden">
<div class="api-key-input">
<input type="text"
id="llmApiKey"
class="api-key-masked"
placeholder="{{ t('settings.aiProvider.apiKeyPlaceholder') }}"
autocomplete="off"
data-mask="css" />
<button type="button" class="toggle-visibility">
<i class="fas fa-eye"></i>
</button>
</div>
<button type="button" class="primary-btn" onclick="settingsManager.saveApiKey('llm_api_key', 'llmApiKey')">{{ t('common.actions.save') }}</button>
<button type="button" class="secondary-btn" onclick="settingsManager.cancelEditApiKey(true, 'llmApiKey')">{{ t('common.actions.cancel') }}</button>
</div>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="llmModel">{{ t('settings.aiProvider.model') }}</label>
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.aiProvider.modelHelp') }}"></i>
</div>
<div class="setting-control">
<div class="text-input-wrapper lm-combobox-container">
<input type="text" id="llmModel"
class="lm-combobox-input"
placeholder="{{ t('settings.aiProvider.modelPlaceholder') }}"
autocomplete="off"
onblur="settingsManager.saveInputSetting('llmModel', 'llm_model')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
</div>
</div>
</div>
</div>
</div>
<!-- Backup -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.backup') }}
<div class="settings-help-text subtle">
{{ t('settings.backup.scopeHelp') }}
</div>
{{ sm.setting_toggle('backupAutoEnabled', 'backup_auto_enabled', 'settings.backup.autoEnabled', 'settings.backup.autoEnabledHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="backupRetentionCount">
{{ t('settings.backup.retention') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.retentionHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="text-input-wrapper">
<input
type="number"
id="backupRetentionCount"
min="1"
step="1"
onblur="settingsManager.saveInputSetting('backupRetentionCount', 'backup_retention_count')"
onkeydown="if(event.key === 'Enter') { this.blur(); }"
/>
</div>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.backup.management') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.backup.managementHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="secondary-btn" onclick="settingsManager.exportBackup()">
{{ t('settings.backup.exportButton') }}
</button>
<button type="button" class="secondary-btn" onclick="settingsManager.triggerBackupImport()" style="margin-left: 10px;">
{{ t('settings.backup.importButton') }}
</button>
<input
type="file"
id="backupImportInput"
accept=".zip,application/zip"
style="display: none;"
onchange="settingsManager.handleBackupImportFile(this)"
/>
</div>
</div>
</div>
<div class="setting-item">
<details class="backup-location-details">
<summary>{{ t('settings.backup.locationSummary') }}</summary>
<div class="backup-location-panel">
<code id="backupLocationPath" class="backup-location-path"></code>
<button type="button" class="secondary-btn" id="backupOpenLocationBtn">
{{ t('settings.backup.openFolderButton') }}
</button>
</div>
</details>
</div>
<div class="setting-item">
<div class="backup-status" id="backupStatus">
<!-- Status will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Proxy Settings -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.proxySettings') }}
{{ sm.setting_toggle('proxyEnabled', 'proxy_enabled', 'settings.proxySettings.enableProxy', 'settings.proxySettings.enableProxyHelp') }}
<div id="proxySettingsGroup" class="proxy-settings-group" style="display: none;">
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="proxyType">
{{ t('settings.proxySettings.proxyType') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.proxySettings.proxyTypeHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="proxyType" onchange="settingsManager.saveSelectSetting('proxyType', 'proxy_type')">
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
<option value="socks4">SOCKS4</option>
<option value="socks5">SOCKS5</option>
</select>
</div>
</div>
</div>
{{ sm.setting_input('proxyHost', 'proxy_host', 'settings.proxySettings.proxyHost', 'settings.proxySettings.proxyHostPlaceholder', 'settings.proxySettings.proxyHostHelp') }}
{{ sm.setting_input('proxyPort', 'proxy_port', 'settings.proxySettings.proxyPort', 'settings.proxySettings.proxyPortPlaceholder', 'settings.proxySettings.proxyPortHelp') }}
{{ sm.setting_input('proxyUsername', 'proxy_username', 'settings.proxySettings.proxyUsername', 'settings.proxySettings.proxyUsernamePlaceholder', 'settings.proxySettings.proxyUsernameHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="proxyPassword">
{{ t('settings.proxySettings.proxyPassword') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.proxySettings.proxyPasswordHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<div class="api-key-input">
<input type="password" id="proxyPassword"
placeholder="{{ t('settings.proxySettings.proxyPasswordPlaceholder') }}"
autocomplete="new-password"
onblur="settingsManager.saveInputSetting('proxyPassword', 'proxy_password')"
onkeydown="if(event.key === 'Enter') { this.blur(); }" />
<button class="toggle-visibility">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,127 @@
{% import 'components/modals/settings/_macros.html' as sm with context %}
<!-- Section 2: Interface -->
<div id="section-interface" class="settings-section" data-section="interface">
<!-- Content Filtering -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.contentFiltering') }}
{{ sm.setting_toggle('blurMatureContent', 'blur_mature_content', 'settings.contentFiltering.blurNsfwContent', 'settings.contentFiltering.blurNsfwContentHelp') }}
{{ sm.setting_toggle('showOnlySFW', 'show_only_sfw', 'settings.contentFiltering.showOnlySfw', 'settings.contentFiltering.showOnlySfwHelp') }}
{{ sm.setting_select('matureBlurLevel', 'mature_blur_level', 'settings.contentFiltering.matureBlurThreshold', [
('PG13', 'settings.contentFiltering.matureBlurThresholdOptions.pg13'),
('R', 'settings.contentFiltering.matureBlurThresholdOptions.r'),
('X', 'settings.contentFiltering.matureBlurThresholdOptions.x'),
('XXX', 'settings.contentFiltering.matureBlurThresholdOptions.xxx'),
], 'settings.contentFiltering.matureBlurThresholdHelp') }}
</div>
<!-- Video Settings -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.videoSettings') }}
{{ sm.setting_toggle('autoplayOnHover', 'autoplay_on_hover', 'settings.videoSettings.autoplayOnHover', 'settings.videoSettings.autoplayOnHoverHelp') }}
</div>
<!-- Layout Settings -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.layoutSettings') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="displayDensity">
{{ t('settings.layoutSettings.displayDensity') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.displayDensityHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="displayDensity" onchange="settingsManager.saveSelectSetting('displayDensity', 'display_density')">
<option value="default">{{ t('settings.layoutSettings.displayDensityOptions.default') }}</option>
<option value="medium">{{ t('settings.layoutSettings.displayDensityOptions.medium') }}</option>
<option value="compact">{{ t('settings.layoutSettings.displayDensityOptions.compact') }}</option>
</select>
</div>
</div>
<div class="input-help"><ul class="list-description">
<li><strong>{{ t('settings.layoutSettings.displayDensityOptions.default') }}:</strong> {{ t('settings.layoutSettings.displayDensityDetails.default') }}</li>
<li><strong>{{ t('settings.layoutSettings.displayDensityOptions.medium') }}:</strong> {{ t('settings.layoutSettings.displayDensityDetails.medium') }}</li>
<li><strong>{{ t('settings.layoutSettings.displayDensityOptions.compact') }}:</strong> {{ t('settings.layoutSettings.displayDensityDetails.compact') }}</li>
</ul>
<span class="warning-text">{{ t('settings.layoutSettings.displayDensityWarning') }}</span>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label id="recipesLayoutLabel">
{{ t('settings.layoutSettings.recipesLayout') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
</label>
</div>
<div class="setting-control layout-options-control">
<div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
<button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
<span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</span>
</button>
<button type="button" class="layout-option" data-recipes-layout="masonry" onclick="settingsManager.saveRecipesLayout('masonry')" role="radio" aria-checked="false">
<span class="layout-option-preview layout-preview-masonry" aria-hidden="true"><span></span><span></span><span></span></span>
<span class="layout-option-label">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</span>
</button>
</div>
</div>
</div>
</div>
{{ sm.setting_select('modelNameDisplay', 'model_name_display', 'settings.layoutSettings.modelNameDisplay', [
('model_name', 'settings.layoutSettings.modelNameDisplayOptions.modelName'),
('file_name', 'settings.layoutSettings.modelNameDisplayOptions.fileName'),
], 'settings.layoutSettings.modelNameDisplayHelp') }}
<!-- Group by model toggle -->
{{ sm.setting_toggle('groupByModel', 'group_by_model', 'settings.layoutSettings.groupByModel', 'settings.layoutSettings.groupByModelHelp') }}
{{ sm.setting_select('cardInfoDisplay', 'card_info_display', 'settings.layoutSettings.cardInfoDisplay', [
('always', 'settings.layoutSettings.cardInfoDisplayOptions.always'),
('hover', 'settings.layoutSettings.cardInfoDisplayOptions.hover'),
], 'settings.layoutSettings.cardInfoDisplayHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="cardBlurAmount">
{{ t('settings.layoutSettings.cardBlurAmount') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.cardBlurAmountHelp') }}"></i>
</label>
</div>
<div class="setting-control range-control">
<input type="range" id="cardBlurAmount" min="0" max="20" value="8" step="1"
oninput="var pct = (this.value / 20) * 100; this.style.setProperty('--range-fill', pct + '%'); document.getElementById('cardBlurAmountValue').textContent = this.value + 'px'"
onchange="settingsManager.saveRangeSetting('cardBlurAmount', 'cardBlurAmountValue', 'card_blur_amount')">
<span id="cardBlurAmountValue" class="range-value">8px</span>
</div>
</div>
</div>
{{ sm.setting_toggle('showVersionOnCard', 'show_version_on_card', 'settings.layoutSettings.showVersionOnCard', 'settings.layoutSettings.showVersionOnCardHelp') }}
{{ sm.setting_select('modelCardFooterAction', 'model_card_footer_action', 'settings.layoutSettings.modelCardFooterAction', [
('example_images', 'settings.layoutSettings.modelCardFooterActionOptions.exampleImages'),
('replace_preview', 'settings.layoutSettings.modelCardFooterActionOptions.replacePreview'),
], 'settings.layoutSettings.modelCardFooterActionHelp') }}
</div>
<!-- License Icons -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.licenseIcons') }}
{{ sm.setting_toggle('useNewLicenseIcons', 'use_new_license_icons', 'settings.licenseIcons.useNewStyle', 'settings.licenseIcons.useNewStyleHelp') }}
</div>
<!-- Miscellaneous -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.misc') }}
{{ sm.setting_select('loraSyntaxFormat', 'lora_syntax_format', 'settings.misc.loraSyntaxFormat', [
('full', 'settings.misc.loraSyntaxFormatOptions.full'),
('legacy', 'settings.misc.loraSyntaxFormatOptions.legacy'),
], 'settings.misc.loraSyntaxFormatHelp') }}
{{ sm.setting_toggle('includeTriggerWords', 'include_trigger_words', 'settings.misc.includeTriggerWords', 'settings.misc.includeTriggerWordsHelp') }}
</div>
</div>
@@ -0,0 +1,482 @@
{% import 'components/modals/settings/_macros.html' as sm with context %}
{% set template_preset_options = [
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
] %}
<!-- Section 3: Library -->
<div id="section-library" class="settings-section" data-section="library">
<!-- Folder Settings -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.folderSettings') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="librarySelect">
{{ t('settings.folderSettings.activeLibrary') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.folderSettings.activeLibraryHelp') }}"></i>
</label>
</div>
<div class="setting-control select-control">
<select id="librarySelect" onchange="settingsManager.handleLibraryChange()">
<option value="">{{ t('settings.folderSettings.loadingLibraries') }}</option>
</select>
</div>
</div>
</div>
{{ sm.setting_select('defaultLoraRoot', 'default_lora_root', 'settings.folderSettings.defaultLoraRoot', [], 'settings.folderSettings.defaultLoraRootHelp') }}
{{ sm.setting_select('defaultCheckpointRoot', 'default_checkpoint_root', 'settings.folderSettings.defaultCheckpointRoot', [], 'settings.folderSettings.defaultCheckpointRootHelp') }}
{{ sm.setting_select('defaultUnetRoot', 'default_unet_root', 'settings.folderSettings.defaultUnetRoot', [], 'settings.folderSettings.defaultUnetRootHelp') }}
{{ sm.setting_select('defaultEmbeddingRoot', 'default_embedding_root', 'settings.folderSettings.defaultEmbeddingRoot', [], 'settings.folderSettings.defaultEmbeddingRootHelp') }}
</div>
<!-- Recipe Settings -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.recipeSettings') }}
{{ sm.setting_input('recipesPath', 'recipes_path', 'settings.folderSettings.recipesPath', 'settings.folderSettings.recipesPathPlaceholder', 'settings.folderSettings.recipesPathHelp') }}
</div>
<!-- Extra Folder Paths -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.extraFolderPaths.title') }}
<i class="fas fa-sync-alt restart-required-icon" title="{{ t('settings.extraFolderPaths.restartRequired') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
{{ t('settings.extraFolderPaths.description') }}
</div>
</div>
<!-- LoRA Paths -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>{{ t('settings.extraFolderPaths.modelTypes.lora') }}</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addExtraFolderPathRow('loras')">
<i class="fas fa-plus"></i>
<span>{{ t('common.actions.add') }}</span>
</button>
</div>
</div>
<div class="extra-folder-paths-container" id="extraFolderPaths-loras">
</div>
</div>
<!-- Checkpoint Paths -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>{{ t('settings.extraFolderPaths.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addExtraFolderPathRow('checkpoints')">
<i class="fas fa-plus"></i>
<span>{{ t('common.actions.add') }}</span>
</button>
</div>
</div>
<div class="extra-folder-paths-container" id="extraFolderPaths-checkpoints">
</div>
</div>
<!-- Diffusion Model (Unet) Paths -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>{{ t('settings.extraFolderPaths.modelTypes.unet') }}</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addExtraFolderPathRow('unet')">
<i class="fas fa-plus"></i>
<span>{{ t('common.actions.add') }}</span>
</button>
</div>
</div>
<div class="extra-folder-paths-container" id="extraFolderPaths-unet">
</div>
</div>
<!-- Embedding Paths -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>{{ t('settings.extraFolderPaths.modelTypes.embedding') }}</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addExtraFolderPathRow('embeddings')">
<i class="fas fa-plus"></i>
<span>{{ t('common.actions.add') }}</span>
</button>
</div>
</div>
<div class="extra-folder-paths-container" id="extraFolderPaths-embeddings">
</div>
</div>
</div>
<!-- Download Path Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.downloadPathTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control select-control">
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control select-control">
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
</button>
</div>
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
</div>
</div>
</div>
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags -->
<div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row">
<div class="setting-info priority-tags-header">
<label>
{{ t('settings.priorityTags.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
</label>
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="priority-tags-tabs">
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
<div class="priority-tags-tablist">
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
</div>
<div class="priority-tags-panels">
<div class="priority-tags-panel" id="priority-tags-panel-lora">
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Version Scope -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.versionScope') }}
{{ sm.setting_select('versionGrouping', 'version_grouping', 'settings.versionGrouping.label', [
('same_base', 'settings.versionGrouping.options.sameBase'),
('any', 'settings.versionGrouping.options.any'),
], 'settings.versionGrouping.help') }}
{{ sm.setting_toggle('hideEarlyAccessUpdates', 'hide_early_access_updates', 'settings.hideEarlyAccessUpdates.label', 'settings.hideEarlyAccessUpdates.help') }}
{{ sm.setting_toggle('hidePaidUpdates', 'hide_paid_updates', 'settings.hidePaidUpdates.label', 'settings.hidePaidUpdates.help') }}
</div>
<!-- Example Images -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.exampleImages') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesPath">{{ t('settings.exampleImages.downloadLocation') }} <i class="fas fa-sync-alt restart-required-icon" title="{{ t('settings.exampleImages.restartRequired') }}"></i></label>
</div>
<div class="setting-control path-control">
<input type="text" id="exampleImagesPath" placeholder="{{ t('settings.exampleImages.downloadLocationPlaceholder') }}" />
<button id="exampleImagesDownloadBtn" class="primary-btn">
<i class="fas fa-download"></i> <span id="exampleDownloadBtnText">{{ t('settings.exampleImages.download') }}</span>
</button>
</div>
</div>
</div>
{{ sm.setting_toggle('autoDownloadExampleImages', 'auto_download_example_images', 'settings.exampleImages.autoDownload', 'settings.exampleImages.autoDownloadHelp') }}
{{ sm.setting_toggle('optimizeExampleImages', 'optimize_example_images', 'settings.exampleImages.optimizeImages', 'settings.exampleImages.optimizeImagesHelp') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesOpenMode">
{{ t('settings.exampleImages.openMode') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.openModeHelp') }}"></i>
<a class="settings-action-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Remote-Open-for-Example-Images" target="_blank" rel="noopener" title="{{ t('settings.exampleImages.openModeWikiLink') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</label>
</div>
<div class="setting-control select-control">
<select id="exampleImagesOpenMode" onchange="settingsManager.handleExampleImagesOpenModeChange()">
<option value="system">{{ t('settings.exampleImages.openModeOptions.system') }}</option>
<option value="clipboard">{{ t('settings.exampleImages.openModeOptions.clipboard') }}</option>
<option value="uri_template">{{ t('settings.exampleImages.openModeOptions.uriTemplate') }}</option>
</select>
</div>
</div>
</div>
<div class="setting-item" id="exampleImagesLocalRootSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesLocalRoot">
{{ t('settings.exampleImages.localRoot') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.localRootHelp') }}"></i>
</label>
</div>
<div class="setting-control path-control">
<input
type="text"
id="exampleImagesLocalRoot"
placeholder="{{ t('settings.exampleImages.localRootPlaceholder') }}"
onchange="settingsManager.saveInputSetting('exampleImagesLocalRoot', 'example_images_local_root')" />
</div>
</div>
</div>
<div class="setting-item" id="exampleImagesUriTemplateSetting" style="display: none;">
<div class="setting-row">
<div class="setting-info">
<label for="exampleImagesOpenUriTemplate">
{{ t('settings.exampleImages.uriTemplate') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.exampleImages.uriTemplateHelp') }} {{ t('settings.exampleImages.uriTemplatePlaceholders') }}"></i>
</label>
</div>
<div class="setting-control path-control">
<input
type="text"
id="exampleImagesOpenUriTemplate"
placeholder="{{ t('settings.exampleImages.uriTemplatePlaceholder') }}"
onchange="settingsManager.saveInputSetting('exampleImagesOpenUriTemplate', 'example_images_open_uri_template')" />
</div>
</div>
</div>
</div>
<!-- Auto-organize -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.autoOrganize') }}
<!-- Auto-organize Exclusions -->
<div class="setting-item auto-organize-exclusions-item">
<div class="setting-row">
<div class="setting-info">
<label for="autoOrganizeExclusions">
{{ t('settings.autoOrganizeExclusions.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
</label>
</div>
</div>
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
</div>
</div>
<!-- Metadata -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.metadata') }}
<!-- Metadata Refresh Skip Paths -->
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="metadataRefreshSkipPaths">
{{ t('settings.metadataRefreshSkipPaths.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataRefreshSkipPaths.help') }}"></i>
</label>
</div>
</div>
<textarea id="metadataRefreshSkipPaths" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.metadataRefreshSkipPaths.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="metadataRefreshSkipPathsError"></div>
</div>
<!-- CivArchive API provider toggle -->
{{ sm.setting_toggle('enableCivarchiveApi', 'enable_civarchive_api', 'settings.metadataArchive.enableCivarchiveApi', 'settings.metadataArchive.enableCivarchiveApiHelp') }}
<!-- Metadata Archive DB -->
{{ sm.setting_toggle('enableMetadataArchive', 'enable_metadata_archive_db', 'settings.metadataArchive.enableArchiveDb', 'settings.metadataArchive.enableArchiveDbHelp') }}
<div class="setting-item">
<div class="metadata-archive-status" id="metadataArchiveStatus">
<!-- Status will be populated by JavaScript -->
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.metadataArchive.management') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.metadataArchive.managementHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" id="downloadMetadataArchiveBtn" class="primary-btn" onclick="settingsManager.downloadMetadataArchive()">
{{ t('settings.metadataArchive.downloadButton') }}
</button>
<button type="button" id="removeMetadataArchiveBtn" class="danger-btn" onclick="settingsManager.removeMetadataArchive()" style="margin-left: 10px;">
{{ t('settings.metadataArchive.removeButton') }}
</button>
</div>
</div>
</div>
<!-- Metadata provider fallback order -->
{{ sm.setting_select('metadataProviderOrder', 'metadata_provider_order', 'settings.metadataArchive.providerOrder', [
('civitai_archive_sqlite', 'settings.metadataArchive.providerOrderCivitaiArchiveSqlite'),
('civitai_sqlite_archive', 'settings.metadataArchive.providerOrderCivitaiSqliteArchive'),
], 'settings.metadataArchive.providerOrderHelp') }}
</div>
</div>
File diff suppressed because it is too large Load Diff
+14 -15
View File
@@ -4,21 +4,27 @@
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<!-- Header Actions: populated dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
<!-- Header Actions: Send button is static; source URL button is appended dynamically in RecipeModal.js -->
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>{{ t('recipes.actions.sendRecipe') }}</span>
</button>
</div>
<!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<!-- Top Section: Preview and Generation Parameters -->
<div class="recipe-top-section">
<!-- Left Column: Preview -->
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
<!-- Source URL elements are now added dynamically in RecipeModal.js -->
</div>
<div class="info-section recipe-gen-params">
</div>
<!-- Center Column: Generation Parameters -->
<div class="info-section recipe-gen-params">
<div class="gen-params-header-row">
<h3>Generation Parameters</h3>
<label class="inline-toggle-container lora-strip-toggle" title="When enabled, &lt;lora:...&gt; tags are removed from prompt text when copying">
@@ -103,9 +109,8 @@
</div>
</div>
</div>
</div>
<!-- Bottom Section: Resources -->
<!-- Right Column: Resources -->
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -114,12 +119,6 @@
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
<button class="action-btn send-recipe-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i>
</button>
</div>
</div>
<div class="recipe-resources-list">
@@ -0,0 +1,116 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(),
show: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
}));
const virtualScrollerMock = vi.hoisted(() => ({
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
}));
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return {
showToast: showToastMock,
};
});
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
}));
vi.mock('../../../static/js/state/index.js', () => {
return {
state: {
loadingManager: loadingManagerMock,
virtualScroller: virtualScrollerMock,
},
getCurrentPageState: getCurrentPageStateMock,
};
});
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: vi.fn(),
restoreScrollPosition: vi.fn(),
recreateVirtualScroll: vi.fn(),
}));
import { sendRecipeWorkflow } from '../../../static/js/api/recipeApi.js';
describe('sendRecipeWorkflow', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
getCurrentPageStateMock.mockReturnValue({});
});
afterEach(() => {
delete global.fetch;
});
it('posts to the send-workflow endpoint and returns the parsed result', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
const result = await sendRecipeWorkflow('recipe-1');
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe-1/send-workflow',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
expect(result).toEqual({ success: true });
});
it('returns the backend error when the response is not ok', async () => {
global.fetch.mockResolvedValue({
ok: false,
statusText: 'Internal Server Error',
json: async () => ({ success: false, error: 'Standalone Mode Active' }),
});
const result = await sendRecipeWorkflow('recipe-1');
expect(result).toEqual({ success: false, error: 'Standalone Mode Active' });
});
it('falls back to statusText when the error payload has no error field', async () => {
global.fetch.mockResolvedValue({
ok: false,
statusText: 'Bad Gateway',
json: async () => ({}),
});
const result = await sendRecipeWorkflow('recipe-1');
expect(result).toEqual({ success: false, error: 'Bad Gateway' });
});
it('throws when the recipe ID cannot be determined', async () => {
await expect(sendRecipeWorkflow('')).rejects.toThrow('Unable to determine recipe ID');
await expect(sendRecipeWorkflow(null)).rejects.toThrow('Unable to determine recipe ID');
expect(global.fetch).not.toHaveBeenCalled();
});
it('encodes the recipe ID in the request URL', async () => {
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await sendRecipeWorkflow('recipe#1?name=foo%bar');
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe%231%3Fname%3Dfoo%25bar/send-workflow',
expect.objectContaining({ method: 'POST' })
);
});
});
@@ -0,0 +1,61 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
const MEDIA_VIEWER_MODULE = new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname;
function setupDom() {
document.body.innerHTML = `
<div id="modelModal" class="modal">
<div class="modal-content">
<img class="media-wrapper" src="" alt="">
</div>
</div>
`;
}
describe('MediaViewer Escape handling', () => {
let ModalManager;
let manager;
let openMediaViewer;
let isMediaViewerOpen;
beforeEach(async () => {
vi.useFakeTimers();
setupDom();
window.scrollTo = vi.fn();
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
manager = new ModalManager();
manager.initialize();
({ openMediaViewer, isMediaViewerOpen } = await import(MEDIA_VIEWER_MODULE));
});
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
document.body.innerHTML = '';
vi.resetModules();
});
it('closes only the media viewer, not the underlying modal, on Escape', () => {
manager.showModal('modelModal');
expect(manager.getModal('modelModal').isOpen).toBe(true);
openMediaViewer('https://example.com/image.png');
expect(isMediaViewerOpen()).toBe(true);
// Dispatch on document.body (real keydown target is the focused element,
// never document itself) so the capture handler fires before the bubble one.
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(isMediaViewerOpen()).toBe(false);
expect(manager.getModal('modelModal').isOpen).toBe(true);
});
it('still lets Escape close the modal when no viewer is open', () => {
manager.showModal('modelModal');
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(manager.getModal('modelModal').isOpen).toBe(false);
});
});
@@ -246,13 +246,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -284,7 +290,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -293,9 +298,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -370,13 +372,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -408,7 +416,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -417,9 +424,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -464,13 +468,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -502,7 +512,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -511,9 +520,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -573,13 +579,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -611,7 +623,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -620,9 +631,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -662,13 +670,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -700,7 +714,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -709,9 +722,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -765,13 +775,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -803,7 +819,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -812,9 +827,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -885,13 +897,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -923,7 +941,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -932,9 +949,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1019,13 +1033,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1057,7 +1077,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></div>
@@ -1068,9 +1087,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1138,7 +1154,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeLorasList"></div>
<span id="recipeLorasCount"></span>
<button id="viewRecipeLorasBtn"></button>
<button id="copyRecipeSyntaxBtn"></button>
</div>
`;
@@ -1191,7 +1207,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeLorasList"></div>
<span id="recipeLorasCount"></span>
<button id="viewRecipeLorasBtn"></button>
<button id="copyRecipeSyntaxBtn"></button>
</div>
`;
@@ -1255,13 +1271,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1293,7 +1315,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></div>
@@ -1304,9 +1325,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1368,13 +1386,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1406,7 +1430,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></div>
@@ -1417,9 +1440,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1486,13 +1506,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1524,7 +1550,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -1533,9 +1558,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1594,13 +1616,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1632,7 +1660,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -1641,9 +1668,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1711,13 +1735,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1749,7 +1779,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -1758,9 +1787,6 @@ describe('Interaction-level regression coverage', () => {
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i>
</button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1808,13 +1834,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1846,7 +1878,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -1932,13 +1963,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content">
<header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions">
<button class="modal-send-btn" id="sendRecipeBtn" title="Send Recipe to ComfyUI">
<i class="fas fa-paper-plane"></i> <span>Send to ComfyUI</span>
</button>
</div>
<div id="recipeTagsContainer"></div>
</header>
<div class="modal-body">
<div class="recipe-top-section">
<div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div>
</div>
<div class="info-section recipe-gen-params">
<div class="gen-params-container">
<div class="param-group info-item">
@@ -1970,7 +2007,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div>
</div>
</div>
</div>
<div class="info-section recipe-bottom-section">
<div class="recipe-section-header">
<h3>Resources</h3>
@@ -4,13 +4,11 @@ const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
LORAS_WIDGET_MODULE,
LORA_LOADER_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
LORAS_WIDGET_MODULE: new URL("../../../web/comfyui/loras_widget.js", import.meta.url).pathname,
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
}));
@@ -59,12 +57,6 @@ vi.mock(UTILS_MODULE, () => ({
LORA_PATTERN: /<lora:([^:]+):([-\d.]+)(?::([-\d.]+))?>/g,
}));
const addLorasWidget = vi.fn();
vi.mock(LORAS_WIDGET_MODULE, () => ({
addLorasWidget,
}));
describe("Lora Loader trigger word updates", () => {
beforeEach(() => {
vi.resetModules();
@@ -82,11 +74,6 @@ describe("Lora Loader trigger word updates", () => {
getWidgetByName.mockClear();
getWidgetSerializedValue.mockClear();
addLorasWidget.mockClear();
addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({
widget: { value: [], callback },
}));
});
it("refreshes trigger word toggles after LoRA syntax edits in the input widget", async () => {
@@ -113,9 +100,18 @@ describe("Lora Loader trigger word updates", () => {
options: {},
};
// Declared LORAS input widget, created by the LoraManager.LorasWidget
// extension and taken over by the loader's onNodeCreated.
const lorasWidget = {
name: "loras",
value: [],
options: {},
callback: null, // Will be set by onNodeCreated
};
const node = {
comfyClass: "Lora Loader (LoraManager)",
widgets: [metadataWidget, inputWidget],
widgets: [metadataWidget, inputWidget, lorasWidget],
addInput: vi.fn(),
graph: {},
};
@@ -124,8 +120,9 @@ describe("Lora Loader trigger word updates", () => {
// The widget is now the AUTOCOMPLETE_TEXT_LORAS type, created automatically by Vue widgets
expect(node.inputWidget).toBe(inputWidget);
expect(node.lorasWidget).toBeDefined();
expect(node.lorasWidget).toBe(lorasWidget);
expect(getWidgetByName).toHaveBeenCalledWith(node, "text");
expect(typeof lorasWidget.callback).toBe("function");
// The callback should have been set up by onNodeCreated
const inputCallback = inputWidget.callback;
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../../../static/js/utils/constants.js';
const {
MODEL_CARD_MODULE,
@@ -108,9 +109,9 @@ describe('ModelCard drag & drop preview upload', () => {
return createModelCard(model, 'loras');
}
function dispatchDrop(card, files) {
function dispatchDrop(card, files, types = []) {
const event = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { files } });
Object.defineProperty(event, 'dataTransfer', { value: { files, types } });
card.dispatchEvent(event);
return event;
}
@@ -179,4 +180,41 @@ describe('ModelCard drag & drop preview upload', () => {
expect(event.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(false);
});
it('ignores drops tagged as internal card drags (move-to-folder)', () => {
const card = createCard();
const file = new File(['data'], 'preview.png', { type: 'image/png' });
const event = dispatchDrop(card, [file], [MODEL_CARD_DRAG_MIME_TYPE]);
expect(uploadPreviewMock).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
expect(card.classList.contains('drag-over')).toBe(false);
});
it('does not highlight or intercept internal card drags during dragover', () => {
const card = createCard();
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
Object.defineProperty(dragOverEvent, 'dataTransfer', {
value: { types: [MODEL_CARD_DRAG_MIME_TYPE] },
});
card.dispatchEvent(dragOverEvent);
expect(dragOverEvent.defaultPrevented).toBe(false);
expect(card.classList.contains('drag-over')).toBe(false);
});
it('keeps the card draggable (move-to-folder) but the preview image non-draggable', () => {
const card = createCard();
// The card itself must stay draggable for sidebar move-to-folder drags.
expect(card.draggable).toBe(true);
// The preview image must not start a native image drag: the browser would
// synthesize a File payload from it, which the drop handler would mistake
// for an external preview replacement.
const img = card.querySelector('.card-preview img');
expect(img.getAttribute('draggable')).toBe('false');
});
});
@@ -45,8 +45,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
}));
vi.mock(SHOWCASE_MODULE, () => ({
toggleShowcase: vi.fn(),
setupShowcaseScroll: vi.fn(),
scrollToTop: vi.fn(),
loadExampleImages: vi.fn(),
}));
@@ -43,8 +43,6 @@ vi.mock(MODAL_MANAGER_MODULE, () => ({
}));
vi.mock(SHOWCASE_MODULE, () => ({
toggleShowcase: vi.fn(),
setupShowcaseScroll: vi.fn(),
scrollToTop: vi.fn(),
loadExampleImages: vi.fn(),
}));
@@ -4,14 +4,12 @@ const {
APP_MODULE,
API_MODULE,
UTILS_MODULE,
LORAS_WIDGET_MODULE,
LORA_LOADER_MODULE,
LORA_STACKER_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
UTILS_MODULE: new URL("../../../web/comfyui/utils.js", import.meta.url).pathname,
LORAS_WIDGET_MODULE: new URL("../../../web/comfyui/loras_widget.js", import.meta.url).pathname,
LORA_LOADER_MODULE: new URL("../../../web/comfyui/lora_loader.js", import.meta.url).pathname,
LORA_STACKER_MODULE: new URL("../../../web/comfyui/lora_stacker.js", import.meta.url).pathname,
}));
@@ -80,12 +78,6 @@ vi.mock(UTILS_MODULE, async (importOriginal) => {
};
});
const addLorasWidget = vi.fn();
vi.mock(LORAS_WIDGET_MODULE, () => ({
addLorasWidget,
}));
describe("Node mode change handling", () => {
beforeEach(() => {
vi.resetModules();
@@ -109,11 +101,6 @@ describe("Node mode change handling", () => {
getWidgetByName.mockClear();
getWidgetSerializedValue.mockClear();
addLorasWidget.mockClear();
addLorasWidget.mockImplementation((_node, _name, _opts, callback) => ({
widget: { value: [], callback },
}));
});
describe("Lora Stacker mode change handling", () => {
@@ -222,6 +209,13 @@ describe("Node mode change handling", () => {
options: {},
callback: null, // Will be set by onNodeCreated
},
{
// Declared LORAS input widget, taken over by onNodeCreated
name: "loras",
value: [],
options: {},
callback: null,
},
],
addInput: vi.fn(),
mode: 0, // Initial mode
@@ -0,0 +1,251 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.fn();
const translateMock = vi.fn((key, params, fallback) => (typeof fallback === 'string' ? fallback : key));
const loadingManagerStub = {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
restoreProgressBar: vi.fn(),
};
const stateStub = {
global: { settings: {}, loadingManager: loadingManagerStub },
loadingManager: loadingManagerStub,
virtualScroller: { updateSingleItem: vi.fn() },
};
const sendRecipeWorkflowMock = vi.fn();
const fetchRecipeDetailsMock = vi.fn();
const updateRecipeMetadataMock = vi.fn(() => Promise.resolve({ success: true }));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: showToastMock,
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
openCivitaiByMetadata: vi.fn(),
stripLoraTags: vi.fn((text) => text),
sendPromptToWorkflow: vi.fn(),
sendGenParamsToWorkflow: vi.fn(),
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: translateMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: stateStub,
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
setSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
getStorageItem: vi.fn(() => null),
setStorageItem: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
fetchRecipeDetails: fetchRecipeDetailsMock,
updateRecipeMetadata: updateRecipeMetadataMock,
sendRecipeWorkflow: sendRecipeWorkflowMock,
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
MODEL_TYPES: {
LORA: 'loras',
CHECKPOINT: 'checkpoints',
EMBEDDING: 'embeddings',
},
}));
async function flushAsyncTasks() {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function createRecipeModal() {
const { RecipeModal } = await import('../../../static/js/components/RecipeModal.js');
return new RecipeModal();
}
describe('RecipeModal send workflow to ComfyUI', () => {
beforeEach(() => {
vi.clearAllMocks();
document.body.innerHTML = `
<div class="recipe-header-actions" id="recipeHeaderActions"></div>
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
describe('syncHeaderActions', () => {
it('inserts the send-workflow button when the recipe embeds a workflow', async () => {
const recipeModal = await createRecipeModal();
recipeModal.currentRecipe = { has_workflow: true };
recipeModal.recipeId = 'recipe-1';
sendRecipeWorkflowMock.mockResolvedValue({ success: true });
const sendSpy = vi.spyOn(recipeModal, 'sendWorkflowToComfyUI');
recipeModal.syncHeaderActions();
const button = document.getElementById('sendWorkflowBtn');
expect(button).not.toBeNull();
expect(button.classList.contains('recipe-source-url-btn')).toBe(true);
button.dispatchEvent(new Event('click', { bubbles: true }));
await flushAsyncTasks();
expect(sendSpy).toHaveBeenCalledTimes(1);
expect(sendRecipeWorkflowMock).toHaveBeenCalledWith('recipe-1');
});
it('does not insert the send-workflow button when has_workflow is not true', async () => {
const recipeModal = await createRecipeModal();
recipeModal.currentRecipe = { has_workflow: false };
recipeModal.syncHeaderActions();
expect(document.getElementById('sendWorkflowBtn')).toBeNull();
recipeModal.currentRecipe = {};
recipeModal.syncHeaderActions();
expect(document.getElementById('sendWorkflowBtn')).toBeNull();
});
it('clears previously injected buttons on every call', async () => {
const recipeModal = await createRecipeModal();
recipeModal.currentRecipe = {
has_workflow: true,
source_path: 'https://civitai.com/models/123',
};
recipeModal.syncHeaderActions();
recipeModal.syncHeaderActions();
const buttons = document.querySelectorAll('#recipeHeaderActions .recipe-source-url-btn');
expect(buttons).toHaveLength(2);
expect(document.querySelectorAll('#sendWorkflowBtn')).toHaveLength(1);
});
it('inserts the Open Source URL button for http(s) source paths', async () => {
const recipeModal = await createRecipeModal();
recipeModal.currentRecipe = { source_path: 'https://civitai.com/models/123' };
recipeModal.syncHeaderActions();
const urlButton = document.querySelector('#recipeHeaderActions .recipe-source-url-btn');
expect(urlButton).not.toBeNull();
expect(urlButton.id).not.toBe('sendWorkflowBtn');
expect(urlButton.title).toBe('https://civitai.com/models/123');
recipeModal.currentRecipe = { source_path: '/local/path/recipe.webp' };
recipeModal.syncHeaderActions();
expect(document.querySelector('#recipeHeaderActions .recipe-source-url-btn')).toBeNull();
});
});
describe('sendWorkflowToComfyUI', () => {
it('shows a success toast when the workflow is sent', async () => {
const recipeModal = await createRecipeModal();
recipeModal.recipeId = 'recipe-1';
sendRecipeWorkflowMock.mockResolvedValue({ success: true });
await recipeModal.sendWorkflowToComfyUI();
expect(sendRecipeWorkflowMock).toHaveBeenCalledWith('recipe-1');
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.workflowSent',
{},
'success',
'Workflow sent to ComfyUI'
);
});
it('shows a warning toast in standalone mode', async () => {
const recipeModal = await createRecipeModal();
recipeModal.recipeId = 'recipe-1';
sendRecipeWorkflowMock.mockResolvedValue({
success: false,
error: 'Standalone Mode Active',
});
await recipeModal.sendWorkflowToComfyUI();
expect(showToastMock).toHaveBeenCalledWith(
'toast.general.cannotInteractStandalone',
{},
'warning',
'Cannot interact with ComfyUI in standalone mode'
);
});
it('shows a warning toast when the recipe has no embedded workflow', async () => {
const recipeModal = await createRecipeModal();
recipeModal.recipeId = 'recipe-1';
sendRecipeWorkflowMock.mockResolvedValue({
success: false,
error: 'no_workflow',
});
await recipeModal.sendWorkflowToComfyUI();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.workflowNoWorkflow',
{},
'warning',
'No embedded workflow found in this recipe'
);
});
it('shows an error toast for other backend errors', async () => {
const recipeModal = await createRecipeModal();
recipeModal.recipeId = 'recipe-1';
sendRecipeWorkflowMock.mockResolvedValue({
success: false,
error: 'ComfyUI unreachable',
});
await recipeModal.sendWorkflowToComfyUI();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.workflowSendFailed',
{ error: 'ComfyUI unreachable' },
'error',
'Failed to send workflow to ComfyUI: ComfyUI unreachable'
);
});
it('shows an error toast when the API call throws', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const recipeModal = await createRecipeModal();
recipeModal.recipeId = 'recipe-1';
sendRecipeWorkflowMock.mockRejectedValue(new Error('network down'));
await recipeModal.sendWorkflowToComfyUI();
expect(showToastMock).toHaveBeenCalledWith(
'toast.recipes.workflowSendFailed',
{ error: 'network down' },
'error',
'Failed to send workflow to ComfyUI: network down'
);
errorSpy.mockRestore();
});
it('does not call the API without a recipe ID', async () => {
const recipeModal = await createRecipeModal();
recipeModal.recipeId = null;
await recipeModal.sendWorkflowToComfyUI();
expect(sendRecipeWorkflowMock).not.toHaveBeenCalled();
expect(showToastMock).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,200 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
const { SHOWCASE_MODULE, MEDIA_UTILS_MODULE, MEDIA_VIEWER_MODULE } = vi.hoisted(() => ({
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
MEDIA_UTILS_MODULE: new URL('../../../static/js/components/shared/showcase/MediaUtils.js', import.meta.url).pathname,
MEDIA_VIEWER_MODULE: new URL('../../../static/js/components/shared/MediaViewer.js', import.meta.url).pathname,
}));
vi.mock(MEDIA_UTILS_MODULE, () => ({
initLazyLoading: vi.fn(),
initNsfwBlurHandlers: vi.fn(),
initMetadataPanelHandlers: vi.fn(),
initMediaControlHandlers: vi.fn(),
positionAllMediaControls: vi.fn(),
}));
vi.mock(MEDIA_VIEWER_MODULE, () => ({
openMediaViewer: vi.fn(),
}));
const PREVIEW_URL = '/loras_static/preview/abc.png';
const IMAGES = [
{ url: 'https://image.civitai.com/abc/111.jpeg', width: 512, height: 768, nsfwLevel: 0 },
{ url: 'https://image.civitai.com/abc/222.jpeg', width: 768, height: 512, nsfwLevel: 0 },
{ url: 'https://image.civitai.com/abc/333.mp4', width: 512, height: 512, nsfwLevel: 0 },
];
describe('Showcase gallery', () => {
let state;
beforeEach(async () => {
Element.prototype.scrollIntoView = vi.fn();
const stateModule = await import('../../../static/js/state/index.js');
state = stateModule.state;
state.settings.show_only_sfw = false;
state.settings.blur_mature_content = false;
state.global.settings.example_images_path = '/tmp/examples';
document.body.innerHTML = '';
});
afterEach(() => {
document.body.innerHTML = '';
});
it('starts collapsed: slim indicator bar only, no remote examples rendered', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.showcase-gallery')).toBeTruthy();
expect(host.querySelector('.gallery-indicator-bar')).toBeTruthy();
// Collapsed bar carries the count and the local preview thumbnail
expect(host.querySelector('#galleryShowBtn')?.textContent).toContain('3');
expect(host.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
// No thumbnails / media wrappers → no remote fetches until expanded
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(0);
expect(host.querySelector('.media-wrapper')).toBeNull();
// Import zone exists but stays collapsed
const zone = host.querySelector('.gallery-import-zone');
expect(zone?.classList.contains('hidden')).toBe(true);
});
it('expanded render shows toolbar, main viewer, thumbnails and nav controls', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.gallery-indicator-bar')).toBeNull();
expect(host.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
expect(host.querySelector('.main-media-container .media-wrapper')).toBeTruthy();
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(3);
expect(host.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
expect(host.querySelector('#galleryPrevBtn')).toBeTruthy();
expect(host.querySelector('#galleryNextBtn')).toBeTruthy();
});
it('show/hide button toggles between indicator bar and gallery', async () => {
const { renderShowcaseContent, initShowcaseContent } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
initShowcaseContent(document.querySelector('.showcase-gallery'));
// Expand
document.querySelector('#galleryShowBtn').click();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(3);
expect(document.querySelector('.gallery-indicator-bar')).toBeNull();
// Collapse back to the indicator bar
document.querySelector('#galleryShowBtn').click();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
expect(document.querySelector('.gallery-preview-thumb img')?.getAttribute('src')).toBe(PREVIEW_URL);
});
it('omits the import zone when the example images path is not configured', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
state.global.settings.example_images_path = '';
const html = renderShowcaseContent(IMAGES, [], PREVIEW_URL, true);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('#galleryImportBtn')).toBeTruthy();
expect(host.querySelector('.gallery-import-zone')).toBeNull();
});
it('filters NSFW examples and reports the hidden count', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
state.settings.show_only_sfw = true;
const images = [
IMAGES[0],
{ url: 'https://image.civitai.com/abc/444.jpeg', width: 10, height: 10, nsfwLevel: 32 },
];
const html = renderShowcaseContent(images, [], '', true);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelectorAll('.gallery-thumb')).toHaveLength(1);
expect(host.querySelector('.nsfw-filter-notification')).toBeTruthy();
// Only one example left → no prev/next controls
expect(host.querySelector('#galleryPrevBtn')).toBeNull();
});
it('renders the import interface when there are no examples', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
const html = renderShowcaseContent([], [], PREVIEW_URL);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.example-import-area.empty')).toBeTruthy();
expect(host.querySelector('#selectExampleFilesBtn')).toBeTruthy();
});
it('renders the setup guidance when the path is missing and there are no examples', async () => {
const { renderShowcaseContent } = await import(SHOWCASE_MODULE);
state.global.settings.example_images_path = '';
const html = renderShowcaseContent([], []);
const host = document.createElement('div');
host.innerHTML = html;
expect(host.querySelector('.import-container--needs-setup')).toBeTruthy();
expect(host.querySelector('#openExampleSettingsBtn')).toBeTruthy();
});
it('switches the main display, position and active thumbnail when expanded', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
updateMainDisplay(2);
const activeThumb = document.querySelector('.gallery-thumb.active');
expect(activeThumb?.dataset.index).toBe('2');
expect(document.querySelector('#galleryPosition')?.textContent).toBe('3 / 3');
const mainWrapper = document.querySelector('#mainMediaContainer .media-wrapper');
expect(mainWrapper).toBeTruthy();
// The third example is a video
expect(mainWrapper.querySelector('video')).toBeTruthy();
// Wraps around past the end
updateMainDisplay(3);
expect(document.querySelector('.gallery-thumb.active')?.dataset.index).toBe('0');
expect(document.querySelector('#galleryPosition')?.textContent).toBe('1 / 3');
});
it('fits the main viewer to the active media aspect ratio', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL, true)}</div>`;
// First image is portrait 512x768 → aspect 0.667
const container = document.getElementById('mainMediaContainer');
expect(container.style.getPropertyValue('--media-aspect')).toBe(String(512 / 768));
// Second image is landscape 768x512 → aspect 1.5
updateMainDisplay(1);
expect(container.style.getPropertyValue('--media-aspect')).toBe('1.5');
});
it('ignores main-display updates while collapsed', async () => {
const { renderShowcaseContent, updateMainDisplay } = await import(SHOWCASE_MODULE);
document.body.innerHTML = `<div id="showcase-tab">${renderShowcaseContent(IMAGES, [], PREVIEW_URL)}</div>`;
updateMainDisplay(1);
// Still collapsed: indicator bar untouched, no gallery rendered
expect(document.querySelector('.gallery-indicator-bar')).toBeTruthy();
expect(document.querySelectorAll('.gallery-thumb')).toHaveLength(0);
});
});
@@ -1,72 +0,0 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest';
const { SHOWCASE_MODULE } = vi.hoisted(() => ({
SHOWCASE_MODULE: new URL('../../../static/js/components/shared/showcase/ShowcaseView.js', import.meta.url).pathname,
}));
describe('Showcase listener metrics', () => {
beforeEach(() => {
document.body.innerHTML = `
<div id="modelModal">
<div class="modal-content">
<div class="showcase-section">
<div class="carousel collapsed">
<div class="scroll-indicator"></div>
</div>
<button class="back-to-top"></button>
</div>
</div>
</div>
`;
});
afterEach(() => {
document.body.innerHTML = '';
});
it('tracks wheel/mutation/back-to-top listeners and resets after cleanup', async () => {
const {
setupShowcaseScroll,
resetShowcaseListenerMetrics,
showcaseListenerMetrics,
} = await import(SHOWCASE_MODULE);
resetShowcaseListenerMetrics();
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
const cleanup = setupShowcaseScroll('modelModal');
expect(showcaseListenerMetrics.wheelListeners).toBe(1);
expect(showcaseListenerMetrics.mutationObservers).toBe(1);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(1);
cleanup();
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
});
it('remains stable after repeated setup/cleanup cycles', async () => {
const {
setupShowcaseScroll,
resetShowcaseListenerMetrics,
showcaseListenerMetrics,
} = await import(SHOWCASE_MODULE);
resetShowcaseListenerMetrics();
const cleanupA = setupShowcaseScroll('modelModal');
cleanupA();
const cleanupB = setupShowcaseScroll('modelModal');
cleanupB();
expect(showcaseListenerMetrics.wheelListeners).toBe(0);
expect(showcaseListenerMetrics.mutationObservers).toBe(0);
expect(showcaseListenerMetrics.backToTopHandlers).toBe(0);
});
});
@@ -211,6 +211,115 @@ describe("LoraManager.WorkflowRegistry", () => {
});
});
describe("loadWorkflowFromMessage", () => {
beforeEach(() => {
appMock.loadApiJson = vi.fn().mockResolvedValue(undefined);
appMock.loadGraphData = vi.fn().mockResolvedValue(undefined);
});
it("warns and returns when the message carries no workflow payload", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await extension.loadWorkflowFromMessage({ name: "My Recipe" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("without a workflow payload"),
expect.anything()
);
expect(appMock.loadApiJson).not.toHaveBeenCalled();
expect(appMock.loadGraphData).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it("parses a string workflow before loading", async () => {
const workflow = { nodes: [], links: [] };
await extension.loadWorkflowFromMessage({
workflow: JSON.stringify(workflow),
name: "Parsed",
});
expect(appMock.loadGraphData).toHaveBeenCalledWith(
workflow,
true,
true,
"Parsed",
{ openSource: "file_button" }
);
});
it("warns and returns when the workflow string is not valid JSON", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await extension.loadWorkflowFromMessage({ workflow: "{not json" });
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("non-JSON workflow string"),
expect.anything()
);
expect(appMock.loadApiJson).not.toHaveBeenCalled();
expect(appMock.loadGraphData).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it("loads API-format workflows via app.loadApiJson", async () => {
const workflow = {
"1": { class_type: "KSampler", inputs: {} },
"2": { class_type: "CLIPTextEncode", inputs: {} },
};
await extension.loadWorkflowFromMessage({ workflow, name: "API Recipe" });
expect(appMock.loadApiJson).toHaveBeenCalledWith(workflow, "API Recipe");
expect(appMock.loadGraphData).not.toHaveBeenCalled();
});
it("loads UI-format workflows via app.loadGraphData", async () => {
const workflow = { nodes: [{ id: 1 }], links: [] };
await extension.loadWorkflowFromMessage({ workflow, name: "UI Recipe" });
expect(appMock.loadGraphData).toHaveBeenCalledWith(
workflow,
true,
true,
"UI Recipe",
{ openSource: "file_button" }
);
expect(appMock.loadApiJson).not.toHaveBeenCalled();
});
it("defaults the workflow name to 'Recipe Workflow'", async () => {
const workflow = { nodes: [], links: [] };
await extension.loadWorkflowFromMessage({ workflow });
expect(appMock.loadGraphData).toHaveBeenCalledWith(
workflow,
true,
true,
"Recipe Workflow",
{ openSource: "file_button" }
);
});
it("logs an error when loading the workflow throws", async () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const failure = new Error("load failed");
appMock.loadGraphData.mockRejectedValue(failure);
await extension.loadWorkflowFromMessage({
workflow: { nodes: [], links: [] },
});
expect(errorSpy).toHaveBeenCalledWith(
expect.stringContaining("failed to load workflow"),
failure
);
errorSpy.mockRestore();
});
});
describe("setup link-change hooks", () => {
it("hooks root events, existing subgraphs, and future subgraphs", () => {
const subgraph = createSubgraph({ id: "sub-1", nodes: [] });
@@ -0,0 +1,164 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
} = vi.hoisted(() => ({
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
setStatus: vi.fn(),
})),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
})),
resetAndReload: vi.fn(),
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
const MULTI_FILE_VERSION = {
id: 201,
name: 'Multi-file version',
images: [],
files: [
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
],
createdAt: '2026-01-01T00:00:00Z',
};
const SINGLE_FILE_VERSION = {
id: 202,
name: 'Single-file version',
images: [],
files: [{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' }],
createdAt: '2026-01-01T00:00:00Z',
};
describe('DownloadManager multi-file version badge (#1058)', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
document.body.innerHTML = `
<div id="urlStep"></div>
<div id="versionStep"></div>
<div id="versionList"></div>
<button id="nextFromVersion"></button>
`;
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('shows the file-select badge for a multi-file version already in library', () => {
const manager = new DownloadManager();
manager.versions = [
{
...MULTI_FILE_VERSION,
existsLocally: true,
localPath: '/models/loras/file-a.safetensors',
},
];
manager.showVersionStep();
const badge = document.querySelector('.file-select-badge');
expect(badge).not.toBeNull();
expect(badge.dataset.versionId).toBe('201');
expect(badge.textContent).toContain('2');
// The in-library badge is still shown alongside
expect(document.querySelector('.local-badge')).not.toBeNull();
});
it('still shows the file-select badge for a multi-file version not in library', () => {
const manager = new DownloadManager();
manager.versions = [{ ...MULTI_FILE_VERSION, existsLocally: false }];
manager.showVersionStep();
const badge = document.querySelector('.file-select-badge');
expect(badge).not.toBeNull();
expect(badge.dataset.versionId).toBe('201');
});
it('does not show the file-select badge for a single-file version in library', () => {
const manager = new DownloadManager();
manager.versions = [
{
...SINGLE_FILE_VERSION,
existsLocally: true,
localPath: '/models/loras/file-c.safetensors',
},
];
manager.showVersionStep();
expect(document.querySelector('.file-select-badge')).toBeNull();
});
});
@@ -0,0 +1,332 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const {
DOWNLOAD_MANAGER_MODULE,
MODAL_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
LOADING_MANAGER_MODULE,
API_FACTORY_MODULE,
STORAGE_HELPERS_MODULE,
FOLDER_TREE_MANAGER_MODULE,
I18N_HELPERS_MODULE,
SUMMARY_MODULE,
mockApiClient,
mockLoadingManager,
showToastMock,
showDownloadBatchSummaryMock,
resetAndReloadMock,
} = vi.hoisted(() => {
// Shared API client returned by the mocked getModelApiClient factory.
const mockApiClient = {
modelType: 'loras',
apiConfig: {
config: {
displayName: 'LoRA',
singularName: 'lora',
},
},
fetchCivitaiVersions: vi.fn(),
fetchModelRoots: vi.fn(async () => ({ roots: ['/models/loras'] })),
fetchUnifiedFolderTree: vi.fn(async () => ({ success: false })),
downloadModel: vi.fn(),
cancelDownload: vi.fn(),
getPageState: vi.fn(() => ({})),
};
const mockLoadingManager = {
showSimpleLoading: vi.fn(),
setStatus: vi.fn(),
hide: vi.fn(),
restoreProgressBar: vi.fn(),
showDownloadProgress: vi.fn(() => vi.fn()),
showCancelButton: vi.fn(),
};
return {
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
LOADING_MANAGER_MODULE: new URL('../../../static/js/managers/LoadingManager.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STORAGE_HELPERS_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
FOLDER_TREE_MANAGER_MODULE: new URL('../../../static/js/components/FolderTreeManager.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
SUMMARY_MODULE: new URL('../../../static/js/components/DownloadBatchSummaryModal.js', import.meta.url).pathname,
mockApiClient,
mockLoadingManager,
showToastMock: vi.fn(),
showDownloadBatchSummaryMock: vi.fn(),
resetAndReloadMock: vi.fn(),
};
});
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: vi.fn(),
closeModal: vi.fn(),
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
setupAutoNewlineOnPaste: vi.fn(),
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: {
settings: {},
},
loadingManager: mockLoadingManager,
},
}));
vi.mock(LOADING_MANAGER_MODULE, () => ({
LoadingManager: vi.fn(() => mockLoadingManager),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => mockApiClient),
resetAndReload: resetAndReloadMock,
}));
vi.mock(STORAGE_HELPERS_MODULE, () => ({
getStorageItem: vi.fn((_key, defaultValue) => defaultValue),
setStorageItem: vi.fn(),
}));
vi.mock(FOLDER_TREE_MANAGER_MODULE, () => ({
FolderTreeManager: vi.fn(() => ({
clearSelection: vi.fn(),
init: vi.fn(),
getSelectedPath: vi.fn(() => ''),
})),
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(SUMMARY_MODULE, () => ({
showDownloadBatchSummary: showDownloadBatchSummaryMock,
}));
/** DOM covering the file-selection, version and location steps. */
function setupDownloadDom() {
document.body.innerHTML = `
<div id="downloadModal">
<div class="download-step" id="urlStep"></div>
<div class="download-step" id="versionStep"></div>
<div class="download-step" id="fileSelectionStep"></div>
<div class="download-step" id="downloadLocationStep"></div>
<div id="fileSelectionList"></div>
<div id="fileSelectionVersionName"></div>
<button id="nextFromVersion"></button>
<div id="downloadModalTitle"></div>
<select id="modelRoot"></select>
<input id="folderPath" />
<div id="targetPathDisplay"></div>
<input id="useDefaultPath" type="checkbox" />
<div id="manualPathSelection"></div>
</div>
`;
}
function makeMultiFileVersion(overrides = {}) {
return {
id: 201,
name: 'Multi-file version',
baseModel: 'SDXL',
images: [],
files: [
{ id: 1001, type: 'Model', sizeKB: 2048, name: 'file-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
],
createdAt: '2026-01-01T00:00:00Z',
existsLocally: true,
...overrides,
};
}
function getFileOption(fileId) {
return document.querySelector(`.file-option[data-file-id="${fileId}"]`);
}
describe('DownloadManager multi-select file dialog (#1058)', () => {
let DownloadManager;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
setupDownloadDom();
({ DownloadManager } = await import(DOWNLOAD_MANAGER_MODULE));
});
afterEach(() => {
document.body.innerHTML = '';
});
it('renders downloaded files disabled with an In Library tag', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
})];
manager.showFileSelectionStep('201');
const downloadedOption = getFileOption('1001');
expect(downloadedOption.classList.contains('disabled')).toBe(true);
expect(downloadedOption.querySelector('input[type="checkbox"]').disabled).toBe(true);
expect(downloadedOption.querySelector('.file-tag.in-library').textContent).toBe('In Library');
// Remaining files stay selectable
const otherOption = getFileOption('1002');
expect(otherOption.classList.contains('disabled')).toBe(false);
expect(otherOption.querySelector('input[type="checkbox"]').disabled).toBe(false);
});
it('ignores clicks on already-downloaded options', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
})];
manager.showFileSelectionStep('201');
getFileOption('1001').click();
expect(getFileOption('1001').querySelector('input[type="checkbox"]').checked).toBe(false);
expect(manager.selectedFiles).toHaveLength(0);
});
it('confirmFileSelection collects multiple checked files into selectedFiles', () => {
const manager = new DownloadManager();
manager.apiClient = mockApiClient;
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
manager.showFileSelectionStep('201');
getFileOption('1001').click();
getFileOption('1003').click();
manager.confirmFileSelection();
expect(manager.selectedFiles.map(f => f.id)).toEqual([1001, 1003]);
// selectedFile stays the first selected file for single-file flows
expect(manager.selectedFile?.id).toBe(1001);
expect(document.getElementById('fileSelectionStep').style.display).toBe('none');
expect(document.getElementById('downloadLocationStep').style.display).toBe('block');
});
it('confirmFileSelection warns when nothing is selected', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({ downloadedFiles: [] })];
manager.showFileSelectionStep('201');
manager.confirmFileSelection();
expect(showToastMock).toHaveBeenCalledWith('toast.loras.pleaseSelectFile', {}, 'error');
expect(manager.selectedFiles).toHaveLength(0);
expect(document.getElementById('downloadLocationStep').style.display).not.toBe('block');
});
it('disables the other routing group once a file is checked and re-enables when unchecked', () => {
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion({
downloadedFiles: [],
files: [
{ id: 1001, type: 'UNet', sizeKB: 2048, name: 'unet-a.safetensors' },
{ id: 1002, type: 'Model', sizeKB: 2048, name: 'file-b.safetensors' },
{ id: 1003, type: 'Model', sizeKB: 2048, name: 'file-c.safetensors' },
],
})];
manager.showFileSelectionStep('201');
// Checking a regular Model file disables the UNet option
getFileOption('1002').click();
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(true);
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(true);
expect(getFileOption('1003').classList.contains('group-disabled')).toBe(false);
// Clicking a group-disabled option does nothing
getFileOption('1001').click();
expect(manager.selectedFiles.map(f => f.id)).toEqual([1002]);
// Unchecking everything re-enables the other group
getFileOption('1002').click();
expect(manager.selectedFiles).toHaveLength(0);
expect(getFileOption('1001').classList.contains('group-disabled')).toBe(false);
expect(getFileOption('1001').querySelector('input[type="checkbox"]').disabled).toBe(false);
});
it('keeps Next enabled for a partially downloaded multi-file version', () => {
const manager = new DownloadManager();
manager.currentVersion = makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
],
});
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(false);
expect(nextButton.classList.contains('disabled')).toBe(false);
});
it('disables Next when every weight file is already downloaded', () => {
const manager = new DownloadManager();
manager.currentVersion = makeMultiFileVersion({
downloadedFiles: [
{ fileId: 1001, fileName: 'file-a.safetensors', filePath: '/models/loras/file-a.safetensors' },
{ fileId: 1002, fileName: 'file-b.safetensors', filePath: '/models/loras/file-b.safetensors' },
{ fileId: 1003, fileName: 'file-c.safetensors', filePath: '/models/loras/file-c.safetensors' },
],
});
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(true);
expect(nextButton.classList.contains('disabled')).toBe(true);
});
it('disables Next for an in-library single-file version', () => {
const manager = new DownloadManager();
manager.currentVersion = {
id: 202,
name: 'Single-file version',
files: [{ id: 1004, type: 'Model', sizeKB: 2048, name: 'file-d.safetensors' }],
existsLocally: true,
downloadedFiles: [],
};
manager.updateNextButtonState();
const nextButton = document.getElementById('nextFromVersion');
expect(nextButton.disabled).toBe(true);
});
it('hides every other step (including the URL step) when the file dialog shows', () => {
// Regression: entering via openFileSelectionForVersion (ModelVersionsTab)
// left the URL step visible alongside the file selection step (#1058).
const manager = new DownloadManager();
manager.versions = [makeMultiFileVersion()];
document.getElementById('urlStep').style.display = 'block';
document.getElementById('versionStep').style.display = 'block';
manager.showFileSelectionStep('201');
expect(document.getElementById('urlStep').style.display).toBe('none');
expect(document.getElementById('versionStep').style.display).toBe('none');
expect(document.getElementById('fileSelectionStep').style.display).toBe('block');
});
});
@@ -83,3 +83,64 @@ def test_checkpoint_names_empty_when_scanner_fails(tmp_path, monkeypatch):
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
assert CheckpointLoaderLM._get_checkpoint_names() == []
def test_checkpoint_available_base_models(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
sd15 = tmp_path / "sd15.safetensors"
sd15.write_bytes(b"x")
flux = tmp_path / "flux.safetensors"
flux.write_bytes(b"x")
missing = tmp_path / "missing.safetensors" # referenced but never created
raw_data = [
{"sub_type": "checkpoint", "file_path": str(sd15), "base_model": "SD1.5"},
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "Flux.1 D"},
# Deleted files must drop out; wrong sub_type must be excluded.
{"sub_type": "checkpoint", "file_path": str(missing), "base_model": "SDXL 1.0"},
{"sub_type": "diffusion_model", "file_path": str(flux), "base_model": "Flux.1 D"},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
assert CheckpointLoaderLM._get_available_base_models() == [
"Any",
"Flux.1 D",
"SD1.5",
]
def test_unet_available_base_models(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
flux = tmp_path / "flux.safetensors"
flux.write_bytes(b"x")
raw_data = [
{
"sub_type": "diffusion_model",
"file_path": str(flux),
"base_model": "Flux.1 D",
},
# Checkpoint entries must stay excluded by the sub_type filter.
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "SD1.5"},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
assert UNETLoaderLM._get_available_base_models() == ["Any", "Flux.1 D"]
def test_available_base_models_empty_when_scanner_fails(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
def _boom():
raise RuntimeError("scanner not available")
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
assert CheckpointLoaderLM._get_available_base_models() == ["Any"]
+1
View File
@@ -197,5 +197,6 @@ def test_lora_loader_qwen_model_raises_clear_error_when_helper_import_fails(monk
loader.load_loras(
qwen_model,
"",
[],
lora_stack=[("stack_qwen.safetensors", 0.6, 0.1)],
)
+143
View File
@@ -908,6 +908,33 @@ class FakeExistenceScanner:
return []
class FakeVersionFilesCache:
"""Cache stub exposing the multi-valued version->files index (#1058)."""
def __init__(self, entries):
self._entries = entries
def get_files_by_version_id(self, _version_id):
return list(self._entries)
class FakeDownloadedFilesScanner:
"""Scanner stub with one known version and per-file cache entries."""
def __init__(self, version_id, entries):
self._version_id = version_id
self._cache = FakeVersionFilesCache(entries)
async def check_model_version_exists(self, version_id):
return version_id == self._version_id
async def get_model_versions_by_id(self, _model_id):
return []
async def get_cached_data(self):
return self._cache
class FakeMetadataProvider:
async def get_model_versions(self, _model_id):
return {"modelVersions": [], "name": "", "type": "lora"}
@@ -1524,9 +1551,125 @@ async def test_check_model_exists_returns_download_history_when_file_missing():
"exists": False,
"modelType": "checkpoint",
"hasBeenDownloaded": True,
"downloadedFiles": [],
}
@pytest.mark.asyncio
async def test_check_model_exists_returns_downloaded_files():
"""The version branch exposes per-file downloaded state (#1058)."""
civitai_payload = {
"id": 100,
"files": [
{
"id": 1001,
"name": "model-fp16.safetensors",
"hashes": {"SHA256": "A" * 64},
},
{
"id": 1002,
"name": "model-fp32.safetensors",
"hashes": {"SHA256": "B" * 64},
},
],
}
entries = [
{
"file_name": "model-fp16",
"file_path": "/models/loras/model-fp16.safetensors",
"sha256": "a" * 64,
"civitai": civitai_payload,
},
{
# Legacy entry without a hash: matched by extension-less name (D2)
"file_name": "model-fp32",
"file_path": "/models/loras/model-fp32.safetensors",
"sha256": "",
"civitai": civitai_payload,
},
]
lora_scanner = FakeDownloadedFilesScanner(100, entries)
async def lora_factory():
return lora_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=lora_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.check_model_exists(
FakeRequest(query={"modelId": "5", "modelVersionId": "100"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert payload == {
"success": True,
"exists": True,
"modelType": "lora",
"hasBeenDownloaded": False,
"downloadedFiles": [
{
"fileId": 1001,
"fileName": "model-fp16.safetensors",
"filePath": "/models/loras/model-fp16.safetensors",
},
{
"fileId": 1002,
"fileName": "model-fp32.safetensors",
"filePath": "/models/loras/model-fp32.safetensors",
},
],
}
@pytest.mark.asyncio
async def test_check_model_exists_downloaded_files_without_remote_metadata():
"""Unmatchable local files are still reported with fileId None (#1058)."""
entries = [
{
"file_name": "renamed-model",
"file_path": "/models/loras/renamed-model.safetensors",
"sha256": "c" * 64,
"civitai": {"id": 100}, # no remote files list cached
},
]
lora_scanner = FakeDownloadedFilesScanner(100, entries)
async def lora_factory():
return lora_scanner
handler = ModelLibraryHandler(
ServiceRegistryAdapter(
get_lora_scanner=lora_factory,
get_checkpoint_scanner=fake_scanner_factory,
get_embedding_scanner=fake_scanner_factory,
get_downloaded_version_history_service=fake_download_history_service_factory,
),
metadata_provider_factory=fake_metadata_provider_factory,
)
response = await handler.check_model_exists(
FakeRequest(query={"modelId": "5", "modelVersionId": "100"}) # pyright: ignore[reportArgumentType]
)
payload = _json_payload(response)
assert payload["success"] is True
assert payload["exists"] is True
assert payload["downloadedFiles"] == [
{
"fileId": None,
"fileName": "renamed-model",
"filePath": "/models/loras/renamed-model.safetensors",
}
]
@pytest.mark.asyncio
async def test_model_version_download_status_endpoints():
history_service = FakeDownloadHistoryService({"lora": {123}})
@@ -0,0 +1,77 @@
"""Unit tests for per-file downloaded-state matching (#1058)."""
from py.routes.handlers.model_handlers import ModelCivitaiHandler
VERSION = {
"id": 42,
"files": [
{
"id": 1001,
"name": "file-a.safetensors",
"hashes": {"SHA256": "AAA111"},
},
{
"id": 1002,
"name": "file-b.safetensors",
"hashes": {"SHA256": "BBB222"},
},
],
}
def _entry(file_name: str, sha256: str = "", file_path: str | None = None):
return {
"file_name": file_name,
"file_path": file_path or f"/models/{file_name}.safetensors",
"sha256": sha256,
}
def test_matches_by_sha256():
entries = [_entry("renamed-locally", "bbb222")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert result == [
{
"fileId": 1002,
"fileName": "file-b.safetensors",
"filePath": "/models/renamed-locally.safetensors",
}
]
def test_falls_back_to_name_when_hash_missing():
entries = [_entry("file-a", "")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1001]
def test_hash_takes_precedence_over_name():
# Hash points at file-b while the name points at file-a: hash wins.
entries = [_entry("file-a", "bbb222")]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1002]
def test_unmatched_entries_are_skipped():
entries = [
_entry("unrelated", "ccc333"),
_entry("file-b", ""), # name match
]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1002]
def test_multiple_files_of_same_version():
entries = [
_entry("file-a", "aaa111"),
_entry("file-b", "bbb222"),
]
result = ModelCivitaiHandler._match_downloaded_files(VERSION, entries)
assert [r["fileId"] for r in result] == [1001, 1002]
def test_empty_inputs():
assert ModelCivitaiHandler._match_downloaded_files(VERSION, []) == []
assert ModelCivitaiHandler._match_downloaded_files({"id": 1}, [_entry("x")]) == []
assert ModelCivitaiHandler._match_downloaded_files(VERSION, None) == []
+89
View File
@@ -0,0 +1,89 @@
"""Tests for the loader-pool endpoint backing the Random Checkpoint/Unet
Loader nodes' front-end base_model filtering.
"""
import json
import pytest
from py.routes.checkpoint_routes import CheckpointRoutes
from py.services.service_registry import ServiceRegistry
class _FakeCache:
def __init__(self, raw_data):
self.raw_data = raw_data
class _FakeScanner:
def __init__(self, raw_data, model_roots):
self._raw_data = raw_data
self._model_roots = model_roots
async def get_cached_data(self, force_refresh=False):
return _FakeCache(self._raw_data)
def get_model_roots(self):
return self._model_roots
class DummyRequest:
def __init__(self, query=None):
self.query = query or {}
@pytest.fixture
def routes(tmp_path, monkeypatch):
existing = tmp_path / "flux.safetensors"
existing.write_bytes(b"x")
missing = tmp_path / "missing.safetensors" # referenced but never created
raw_data = [
{"sub_type": "checkpoint", "file_path": str(existing), "base_model": "Flux.1 D"},
{"sub_type": "checkpoint", "file_path": str(missing), "base_model": "SDXL 1.0"},
{
"sub_type": "diffusion_model",
"file_path": str(existing),
"base_model": "Flux.1 D",
},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
return CheckpointRoutes()
async def test_loader_pool_checkpoint_subtype(routes):
response = await routes.get_loader_pool(DummyRequest(query={"sub_type": "checkpoint"}))
assert response.status == 200
payload = json.loads(response.text)
assert payload == {
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
}
async def test_loader_pool_diffusion_model_subtype(routes):
response = await routes.get_loader_pool(
DummyRequest(query={"sub_type": "diffusion_model"})
)
assert response.status == 200
payload = json.loads(response.text)
assert payload == {
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
}
async def test_loader_pool_default_subtype_is_checkpoint(routes):
response = await routes.get_loader_pool(DummyRequest())
assert response.status == 200
payload = json.loads(response.text)
assert payload == {
"items": [{"name": "flux.safetensors", "base_model": "Flux.1 D"}]
}
async def test_loader_pool_invalid_subtype(routes):
response = await routes.get_loader_pool(DummyRequest(query={"sub_type": "lora"}))
assert response.status == 400
@@ -0,0 +1,293 @@
"""Handler tests for the recipe workflow send endpoint.
Covers ``RecipeWorkflowHandler.send_recipe_workflow`` and the wiring of the
``send_recipe_workflow`` key in ``RecipeHandlerSet.to_route_mapping``.
"""
from __future__ import annotations
import json
import logging
from typing import Any
from unittest.mock import MagicMock
import pytest
from py.routes.handlers.recipe_handlers import (
RecipeHandlerSet,
RecipeWorkflowHandler,
)
from py.routes.handlers import recipe_handlers
async def _noop_ensure() -> None:
return None
@pytest.fixture(autouse=True)
def _clear_standalone_env(monkeypatch: pytest.MonkeyPatch):
"""Clear the standalone env flag leaked by other modules.
``standalone.py`` sets ``LORA_MANAGER_STANDALONE=1`` at import time, and
unrelated tests import that module; without cleanup the flag bleeds into
later tests and flips the handler's standalone branch.
"""
monkeypatch.delenv("LORA_MANAGER_STANDALONE", raising=False)
class FakeRequest:
"""Minimal request double exposing ``match_info``."""
def __init__(self, *, match_info: dict[str, Any] | None = None) -> None:
self.match_info = match_info or {}
class StubRecipeScanner:
"""Scanner double returning a configurable recipe for an id."""
def __init__(self, recipe: dict[str, Any] | None = None) -> None:
self.recipe = recipe
self.lookup_calls: list[str] = []
async def get_recipe_by_id(self, recipe_id: str) -> dict[str, Any] | None:
self.lookup_calls.append(recipe_id)
return self.recipe
def _json_payload(response) -> dict[str, Any]:
"""Decode the JSON body of a web.Response, asserting it is not null."""
text = response.text
assert text is not None
return json.loads(text)
def _make_prompt_server(send_calls: list[tuple[str, Any]], *, send_error: Exception | None = None):
"""Return a PromptServer-like class whose instance records send_sync calls."""
class RecordingPromptServer:
class Instance:
def send_sync(self, event, payload, sid=None):
if send_error is not None:
raise send_error
send_calls.append((event, payload))
instance = Instance()
return RecordingPromptServer
def _make_handler(
scanner: StubRecipeScanner,
prompt_server,
*,
ensure=_noop_ensure,
) -> RecipeWorkflowHandler:
return RecipeWorkflowHandler(
ensure_dependencies_ready=ensure,
recipe_scanner_getter=lambda: scanner,
prompt_server=prompt_server, # pyright: ignore[reportArgumentType]
logger=logging.getLogger(__name__),
)
async def test_send_recipe_workflow_broadcasts_embedded_workflow(monkeypatch: pytest.MonkeyPatch):
recipe = {
"file_path": "/models/recipes/sample.png",
"title": "My Recipe",
}
scanner = StubRecipeScanner(recipe=recipe)
send_calls: list[tuple[str, Any]] = []
prompt_server = _make_prompt_server(send_calls)
monkeypatch.setattr(
recipe_handlers.ExifUtils,
"_load_structured_metadata",
lambda _image_path: {"workflow": '{"nodes":[]}'},
)
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 200
payload = _json_payload(response)
assert payload == {"success": True, "sent": True}
assert send_calls == [
(
"lm_load_workflow",
{
"workflow": {"nodes": []},
"name": "My Recipe",
"recipe_id": "r1",
},
)
]
assert scanner.lookup_calls == ["r1"]
async def test_send_recipe_workflow_defaults_empty_title(monkeypatch: pytest.MonkeyPatch):
recipe = {"file_path": "/models/recipes/sample.png"}
scanner = StubRecipeScanner(recipe=recipe)
send_calls: list[tuple[str, Any]] = []
prompt_server = _make_prompt_server(send_calls)
monkeypatch.setattr(
recipe_handlers.ExifUtils,
"_load_structured_metadata",
lambda _image_path: {"workflow": "{}"},
)
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r2"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 200
assert send_calls[0][1]["workflow"] == {}
assert send_calls[0][1]["name"] == ""
assert send_calls[0][1]["recipe_id"] == "r2"
async def test_send_recipe_workflow_recipe_not_found():
scanner = StubRecipeScanner(recipe=None)
prompt_server = _make_prompt_server([])
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "missing"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 404
assert _json_payload(response) == {"error": "Recipe not found"}
async def test_send_recipe_workflow_missing_file_path():
scanner = StubRecipeScanner(recipe={"title": "No File"})
prompt_server = _make_prompt_server([])
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 404
assert _json_payload(response) == {"error": "no_workflow"}
async def test_send_recipe_workflow_no_embedded_workflow(monkeypatch: pytest.MonkeyPatch):
recipe = {"file_path": "/models/recipes/sample.png", "title": "No Wf"}
scanner = StubRecipeScanner(recipe=recipe)
prompt_server = _make_prompt_server([])
monkeypatch.setattr(
recipe_handlers.ExifUtils,
"_load_structured_metadata",
lambda _image_path: {"parameters": "some params"},
)
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 404
assert _json_payload(response) == {
"error": "no_workflow",
"message": "No embedded workflow found in recipe image",
}
async def test_send_recipe_workflow_invalid_json_payload(monkeypatch: pytest.MonkeyPatch):
recipe = {"file_path": "/models/recipes/sample.png", "title": "Bad Wf"}
scanner = StubRecipeScanner(recipe=recipe)
prompt_server = _make_prompt_server([])
monkeypatch.setattr(
recipe_handlers.ExifUtils,
"_load_structured_metadata",
lambda _image_path: {"workflow": "not-json{"},
)
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 404
assert _json_payload(response)["error"] == "no_workflow"
async def test_send_recipe_workflow_standalone_mode(monkeypatch: pytest.MonkeyPatch):
recipe = {"file_path": "/models/recipes/sample.png", "title": "Recipe"}
scanner = StubRecipeScanner(recipe=recipe)
prompt_server = _make_prompt_server([])
monkeypatch.setenv("LORA_MANAGER_STANDALONE", "1")
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 400
assert _json_payload(response) == {"error": "Standalone Mode Active"}
async def test_send_recipe_workflow_send_sync_error(monkeypatch: pytest.MonkeyPatch):
recipe = {"file_path": "/models/recipes/sample.png", "title": "Recipe"}
scanner = StubRecipeScanner(recipe=recipe)
prompt_server = _make_prompt_server([], send_error=RuntimeError("boom"))
monkeypatch.setattr(
recipe_handlers.ExifUtils,
"_load_structured_metadata",
lambda _image_path: {"workflow": "{}"},
)
handler = _make_handler(scanner, prompt_server)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 500
assert _json_payload(response) == {"error": "boom"}
async def test_send_recipe_workflow_scanner_unavailable():
handler = RecipeWorkflowHandler(
ensure_dependencies_ready=_noop_ensure,
recipe_scanner_getter=lambda: None,
prompt_server=_make_prompt_server([]), # pyright: ignore[reportArgumentType]
logger=logging.getLogger(__name__),
)
response = await handler.send_recipe_workflow(
FakeRequest(match_info={"recipe_id": "r1"}) # pyright: ignore[reportArgumentType]
)
assert response.status == 500
assert _json_payload(response) == {"error": "Recipe scanner unavailable"}
def test_route_mapping_includes_send_recipe_workflow():
workflow = RecipeWorkflowHandler(
ensure_dependencies_ready=_noop_ensure,
recipe_scanner_getter=lambda: None,
prompt_server=_make_prompt_server([]), # pyright: ignore[reportArgumentType]
logger=logging.getLogger(__name__),
)
handler_set = RecipeHandlerSet(
page_view=MagicMock(),
listing=MagicMock(),
query=MagicMock(),
management=MagicMock(),
analysis=MagicMock(),
sharing=MagicMock(),
batch_import=MagicMock(),
workflow=workflow,
)
mapping = handler_set.to_route_mapping()
assert "send_recipe_workflow" in mapping
assert mapping["send_recipe_workflow"] == workflow.send_recipe_workflow
@@ -3,6 +3,40 @@ import pytest
from py.recipes.parsers.automatic import AutomaticMetadataParser
class LocalRecipeScanner:
class LoraScanner:
@staticmethod
def has_hash(model_hash):
return False
def __init__(self, models):
self.models = models
self.queries = []
self.hash_queries = []
self._lora_scanner = self.LoraScanner()
async def get_local_lora(self, name, base_model=None):
self.queries.append(name)
return self.models.get(name)
async def get_local_lora_by_hash(self, hash_value):
self.hash_queries.append(hash_value)
return next((model for model in self.models.values() if model.get("sha256") == hash_value), None)
def local_lora(file_name="local_only"):
return {
"file_path": f"/models/loras/styles/{file_name}.safetensors",
"file_name": file_name,
"model_name": "Local Only",
"sha256": "a" * 64,
"size": 123456,
"base_model": "Flux.1 D",
"preview_url": f"/models/loras/styles/{file_name}.preview.png",
"civitai": None,
}
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_civitai_resources(monkeypatch):
checkpoint_info = {
@@ -132,6 +166,218 @@ async def test_parse_metadata_merges_lora_hashes_over_empty_hashes_json(monkeypa
assert "UnusedLora" not in lora_names, "UnusedLora should have been skipped"
@pytest.mark.asyncio
async def test_parse_metadata_resolves_local_lora_with_empty_hash(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
raise AssertionError("Local and empty-hash LoRAs must not query Civitai")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.65> <lora:missing:0.4>\n"
"Steps: 20, Sampler: Euler, CFG scale: 7, Seed: 1, "
'Hashes: {"lora:local_only": "", "lora:missing": ""}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
entry = result["loras"][0]
assert entry["name"] == "Local Only"
assert entry["file_name"] == "local_only"
assert entry["weight"] == 0.65
assert entry["hash"] == "a" * 64
assert entry["localPath"].endswith("local_only.safetensors")
assert entry["size"] == 123456
assert entry["baseModel"] == "Flux.1 D"
assert entry["existsLocally"] is True
assert entry["isDeleted"] is False
assert scanner.queries == ["local_only", "missing"]
@pytest.mark.asyncio
async def test_parse_metadata_resolves_prompt_lora_without_hashes(monkeypatch):
async def fake_metadata_provider():
return None
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
model = local_lora()
scanner = LocalRecipeScanner({"styles/local_only": model})
metadata_text = "portrait <lora:styles/local_only:0.7>\nSteps: 20, Seed: 1"
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
assert result["loras"][0]["weight"] == 0.7
assert result["loras"][0]["hash"] == "a" * 64
assert scanner.queries == ["styles/local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_prefers_hash_over_colliding_local_name(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "Hash Match", "type": "LORA"},
"name": "v1",
"files": [{"type": "Model", "primary": True, "name": "hash_match.safetensors", "hashes": {"SHA256": "b" * 64}}],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "deadbeef00"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.8>\n"
"Steps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "deadbeef00"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 1
assert result["loras"][0]["id"] == 100
assert result["loras"][0]["weight"] == 0.8
assert scanner.queries == []
assert scanner.hash_queries == ["deadbeef00"]
@pytest.mark.asyncio
async def test_parse_metadata_falls_back_to_name_when_hash_is_unresolved(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
return None, "Model not found"
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.8>\nSteps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "deadbeef00"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert result["loras"][0]["hash"] == "a" * 64
assert scanner.queries == ["local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_uses_prompt_weight_for_civitai_resource(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "local_only", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote_file.safetensors",
"hashes": {"SHA256": "b" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == 100
return remote_info, None
async def get_model_by_hash(self, model_hash):
raise AssertionError("The Civitai resource should not be fetched again by hash")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:remote_file:0.35> <lora:local_only:0.6>\n"
"Steps: 20, Seed: 1, "
'Civitai resources: [{"type":"lora","modelVersionId":100,"modelName":"local_only"}]'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert len(result["loras"]) == 2
assert [entry["file_name"] for entry in result["loras"]] == ["remote_file", "local_only"]
assert [entry["weight"] for entry in result["loras"]] == [0.35, 0.6]
assert result["loras"][1]["existsLocally"] is True
assert scanner.queries == ["local_only"]
@pytest.mark.asyncio
async def test_parse_metadata_keeps_mixed_local_and_civitai_loras(monkeypatch):
remote_info = {
"id": 100,
"modelId": 200,
"model": {"name": "Remote LoRA", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote.safetensors",
"hashes": {"SHA256": "b" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_by_hash(self, model_hash):
assert model_hash == "bbbbbbbbbb"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.automatic.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local_only": local_lora()})
metadata_text = (
"portrait <lora:local_only:0.6> <lora:remote:0.9>\n"
"Steps: 20, Seed: 1, "
'Hashes: {"lora:local_only": "", "lora:remote": "bbbbbbbbbb"}'
)
result = await AutomaticMetadataParser().parse_metadata(metadata_text, scanner)
assert [entry["name"] for entry in result["loras"]] == ["Local Only", "Remote LoRA"]
assert [entry["weight"] for entry in result["loras"]] == [0.6, 0.9]
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][1]["existsLocally"] is False
@pytest.mark.asyncio
async def test_parse_metadata_extracts_checkpoint_from_model_hash(monkeypatch):
checkpoint_info = {
@@ -2,6 +2,38 @@ import pytest
import json
from py.recipes.parsers.comfy import ComfyMetadataParser
class LocalRecipeScanner:
class LoraScanner:
@staticmethod
def has_hash(model_hash):
return False
def __init__(self, models):
self.models = models
self.queries = []
self.base_models = []
self._lora_scanner = self.LoraScanner()
async def get_local_lora(self, name, base_model=None):
self.queries.append(name)
self.base_models.append(base_model)
return self.models.get(name)
def local_lora(file_name):
return {
"file_path": f"/models/loras/{file_name}.safetensors",
"file_name": file_name.rsplit("/", 1)[-1],
"model_name": file_name.rsplit("/", 1)[-1],
"sha256": file_name[0] * 64,
"size": 4096,
"base_model": "SDXL 1.0",
"preview_url": "",
"civitai": None,
}
@pytest.mark.asyncio
async def test_parse_metadata_without_loras(monkeypatch):
checkpoint_info = {
@@ -84,6 +116,140 @@ async def test_parse_metadata_without_loras(monkeypatch):
assert result["gen_params"]["size"] == "1024x1024"
assert result["from_comfy_metadata"] is True
@pytest.mark.asyncio
async def test_parse_metadata_resolves_standard_and_manager_local_loras(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
raise AssertionError("Local LoRAs must not query Civitai")
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({
"styles/standard.safetensors": local_lora("standard"),
"manager": local_lora("manager"),
})
metadata_json = {
"1": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "styles/standard.safetensors",
"strength_model": 0.55,
},
},
"2": {
"class_type": "LoraLoaderLM",
"inputs": {
"loras": {
"__value__": [
{"name": "manager", "strength": "0.80", "active": True},
{"name": "disabled", "strength": 1.0, "active": False},
{"name": "dummy", "strength": 1.0, "active": True, "_isDummy": True},
]
}
},
},
}
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
assert [entry["file_name"] for entry in result["loras"]] == ["standard", "manager"]
assert [entry["weight"] for entry in result["loras"]] == [0.55, 0.8]
assert all(isinstance(entry["weight"], float) for entry in result["loras"])
assert all(entry["existsLocally"] is True for entry in result["loras"])
assert all(entry["isDeleted"] is False for entry in result["loras"])
assert scanner.queries == ["styles/standard.safetensors", "manager"]
@pytest.mark.asyncio
async def test_parse_metadata_defaults_malformed_weight_and_passes_checkpoint_base_model(monkeypatch):
checkpoint_info = {
"id": 456,
"modelId": 123,
"model": {"name": "Checkpoint", "type": "checkpoint"},
"name": "v1",
"baseModel": "SDXL 1.0",
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
return checkpoint_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"style": local_lora("style")})
metadata_json = {
"1": {"class_type": "LoraLoader", "inputs": {"lora_name": "style", "strength_model": "invalid"}},
"2": {"class_type": "CheckpointLoaderSimple", "inputs": {"ckpt_name": "civitai:123@456"}},
}
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
assert result["loras"][0]["weight"] == 1.0
assert scanner.base_models == ["SDXL 1.0"]
@pytest.mark.asyncio
async def test_parse_metadata_keeps_civitai_urn_with_local_lora(monkeypatch):
remote_info = {
"id": 456,
"modelId": 123,
"model": {"name": "Remote LoRA", "type": "LORA"},
"name": "v1",
"files": [
{
"type": "Model",
"primary": True,
"name": "remote.safetensors",
"hashes": {"SHA256": "c" * 64},
}
],
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == "456"
return remote_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
scanner = LocalRecipeScanner({"local": local_lora("local")})
metadata_json = {
"1": {
"class_type": "LoraLoader",
"inputs": {"lora_name": "local", "strength_model": 0.4},
},
"2": {
"class_type": "LoraLoader",
"inputs": {
"lora_name": "urn:air:sdxl:lora:civitai:123@456",
"strength_model": 0.9,
},
},
}
result = await ComfyMetadataParser().parse_metadata(json.dumps(metadata_json), scanner)
assert [entry["name"] for entry in result["loras"]] == ["local", "Remote LoRA"]
assert [entry["weight"] for entry in result["loras"]] == [0.4, 0.9]
assert result["loras"][0]["existsLocally"] is True
assert result["loras"][1]["id"] == 456
@pytest.mark.asyncio
async def test_parse_metadata_without_extra_metadata(monkeypatch):
async def fake_metadata_provider():
+312 -1
View File
@@ -82,14 +82,18 @@ def stub_metadata(monkeypatch):
class DummyScanner:
def __init__(self, exists: bool = False):
def __init__(self, exists: bool = False, raw_data=None):
self.exists = exists
self.calls = []
self._cache = SimpleNamespace(raw_data=list(raw_data or []))
async def check_model_version_exists(self, version_id):
self.calls.append(version_id)
return self.exists
async def get_cached_data(self):
return self._cache
@pytest.fixture
def scanners(monkeypatch):
@@ -1692,3 +1696,310 @@ async def test_download_proceeds_when_history_skip_disabled(monkeypatch, scanner
assert result.get("skipped") is not True
history_service.has_been_downloaded.assert_not_called()
execute_download.assert_awaited_once()
# ---------------------------------------------------------------------------
# Multi-file downloads within a single model version (#1058)
# ---------------------------------------------------------------------------
def _multi_file_payload(include_hashes: bool = True):
"""Version payload with two weight files under the same version."""
files = [
{
"id": 1001,
"type": "Model",
"primary": True,
"downloadUrl": "https://example.invalid/file-a.safetensors",
"name": "file-a.safetensors",
},
{
"id": 1002,
"type": "Model",
"primary": False,
"downloadUrl": "https://example.invalid/file-b.safetensors",
"name": "file-b.safetensors",
},
]
if include_hashes:
files[0]["hashes"] = {"SHA256": "AAA111"}
files[1]["hashes"] = {"SHA256": "BBB222"}
return {
"id": 42,
"modelId": 7,
"model": {"type": "LoRA", "tags": ["fantasy"]},
"baseModel": "BaseModel",
"creator": {"username": "Author"},
"files": files,
}
def _local_entry(file_name: str, sha256: str, version_id: int = 42):
"""A library cache entry for one already-downloaded file of a version."""
return {
"file_name": file_name,
"file_path": f"/tmp/{file_name}.safetensors",
"sha256": sha256,
"civitai": {"id": version_id, "modelId": 7},
}
def _stub_history_service(monkeypatch, has_been_downloaded: bool = False):
history_service = AsyncMock()
history_service.has_been_downloaded = AsyncMock(
return_value=has_been_downloaded
)
history_service.mark_downloaded = AsyncMock()
monkeypatch.setattr(
ServiceRegistry,
"get_downloaded_version_history_service",
AsyncMock(return_value=history_service),
)
return history_service
@pytest.mark.asyncio
async def test_download_allows_other_file_of_in_library_version(
monkeypatch, scanners, metadata_provider
):
"""A different file of an in-library version must still download (#1058)."""
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload()
)
_stub_history_service(monkeypatch)
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={"id": 1002, "type": "Model", "name": "file-b.safetensors"},
)
assert result["success"] is True
execute_download.assert_awaited_once()
# Version-level gates must not run for an explicit file selection
assert scanners.lora.calls == []
@pytest.mark.asyncio
async def test_download_blocks_same_file_of_in_library_version(
monkeypatch, scanners, metadata_provider
):
"""Re-downloading the SAME file of an in-library version stays blocked."""
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload()
)
_stub_history_service(monkeypatch)
execute_download = AsyncMock(return_value={"success": True})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={"id": 1001, "type": "Model", "name": "file-a.safetensors"},
)
assert result["success"] is False
assert "file-a.safetensors" in result["error"]
assert "already exists in lora library" in result["error"]
execute_download.assert_not_called()
@pytest.mark.asyncio
async def test_download_unresolvable_file_params_falls_back_to_version_gate(
monkeypatch, scanners, metadata_provider
):
"""file_params that match no file must not bypass version-level gates."""
scanners.lora.exists = True
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload()
)
_stub_history_service(monkeypatch)
execute_download = AsyncMock(return_value={"success": True})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={"id": 9999, "type": "Model"},
)
assert result["success"] is False
assert result["error"] == "Model version already exists in lora library"
execute_download.assert_not_called()
@pytest.mark.asyncio
async def test_download_empty_file_params_treated_as_no_selection(
monkeypatch, scanners, metadata_provider
):
"""An empty file_params dict is normalized to None (version-level gates)."""
scanners.lora.exists = True
execute_download = AsyncMock(return_value={"success": True})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={},
)
assert result["success"] is False
assert result["error"] == "Model version already exists in lora library"
# Early version-level gate fired (file_params normalized to None)
assert scanners.lora.calls == [42]
execute_download.assert_not_called()
@pytest.mark.asyncio
async def test_download_explicit_file_bypasses_history_skip(
monkeypatch, scanners, metadata_provider
):
"""Explicit file selection bypasses the previously-downloaded skip."""
get_settings_manager().settings[
"skip_previously_downloaded_model_versions"
] = True
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload()
)
history_service = _stub_history_service(monkeypatch, has_been_downloaded=True)
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={"id": 1002, "type": "Model", "name": "file-b.safetensors"},
)
assert result["success"] is True
execute_download.assert_awaited_once()
# History gate is bypassed before it even queries the service
history_service.has_been_downloaded.assert_not_called()
@pytest.mark.asyncio
async def test_download_file_match_by_name_when_local_hash_missing(
monkeypatch, scanners, metadata_provider
):
"""Legacy local metadata without sha256 falls back to name matching."""
scanners.lora._cache.raw_data.append(_local_entry("file-a", ""))
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload()
)
_stub_history_service(monkeypatch)
execute_download = AsyncMock(return_value={"success": True})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={"id": 1001, "type": "Model"},
)
assert result["success"] is False
assert "already exists in lora library" in result["error"]
execute_download.assert_not_called()
@pytest.mark.asyncio
async def test_download_no_false_positive_when_both_hashes_empty(
monkeypatch, scanners, metadata_provider
):
"""Two empty hashes must never compare equal; name decides instead."""
# Local entry for a DIFFERENT file of the same version, no hash stored
scanners.lora._cache.raw_data.append(_local_entry("file-b", ""))
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload(include_hashes=False)
)
_stub_history_service(monkeypatch)
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_version_id=42,
save_dir="/tmp",
file_params={"id": 1001, "type": "Model"},
)
assert result["success"] is True
execute_download.assert_awaited_once()
@pytest.mark.asyncio
async def test_download_model_id_only_with_file_params_resolves_file(
monkeypatch, scanners, metadata_provider
):
"""model_id-only requests with file_params resolve the file post-fetch."""
scanners.lora.exists = True # version-level index says "in library"
scanners.lora._cache.raw_data.append(_local_entry("file-a", "aaa111"))
metadata_provider.get_model_version = AsyncMock(
return_value=_multi_file_payload()
)
_stub_history_service(monkeypatch)
execute_download = AsyncMock(return_value={"success": True, "download_id": "done"})
monkeypatch.setattr(
DownloadManager, "_execute_download", execute_download, raising=False
)
manager = DownloadManager()
result = await manager.download_from_civitai(
model_id=7,
save_dir="/tmp",
file_params={"id": 1002, "type": "Model"},
)
assert result["success"] is True
execute_download.assert_awaited_once()
def test_resolve_target_file_by_id():
files = _multi_file_payload()["files"]
resolved = DownloadManager._resolve_target_file(files, {"id": 1002})
assert resolved is files[1]
def test_resolve_target_file_by_primary_flag():
files = _multi_file_payload()["files"]
resolved = DownloadManager._resolve_target_file(files, {"isPrimary": True})
assert resolved is files[0]
def test_resolve_target_file_returns_none_for_no_match():
files = _multi_file_payload()["files"]
assert DownloadManager._resolve_target_file(files, {"id": 9999}) is None
assert DownloadManager._resolve_target_file(files, None) is None
assert DownloadManager._resolve_target_file(files, {}) is None
@@ -5,6 +5,9 @@ Covers the new ``download_id``-based code paths in
compatibility with ``id``.
"""
import json
import sqlite3
import time
from pathlib import Path
import pytest
@@ -191,3 +194,304 @@ async def test_retry_unknown_download_id(tmp_path: Path) -> None:
item = await svc.retry_from_history(download_id="dl-nope")
assert item is None
# ---------------------------------------------------------------------------
# file_params persistence across queue -> history -> retry (#1058)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_file_params_survive_queue_to_history(tmp_path: Path) -> None:
"""complete_download copies the queue row's file_params into history."""
svc = _make_service(tmp_path)
await svc.add_to_queue(
download_id="dl-fp",
model_id=1,
model_version_id=100,
file_params={"id": 1002, "type": "Model"},
)
await svc.complete_download("dl-fp", status="failed", error="boom")
history = await svc.get_history()
assert len(history["items"]) == 1
assert json.loads(history["items"][0]["file_params"]) == {
"id": 1002,
"type": "Model",
}
@pytest.mark.asyncio
async def test_retry_restores_file_params(tmp_path: Path) -> None:
"""retry_from_history re-queues with the originally selected file."""
svc = _make_service(tmp_path)
await svc.add_to_history(
download_id="dl-fail-fp",
model_id=1,
model_version_id=100,
status="failed",
file_params={"id": 1002, "type": "Model"},
)
item = await svc.retry_from_history(download_id="dl-fail-fp")
assert item is not None
assert item["status"] == "queued"
assert json.loads(item["file_params"]) == {"id": 1002, "type": "Model"}
@pytest.mark.asyncio
async def test_retry_all_restores_file_params(tmp_path: Path) -> None:
"""retry_all_failed preserves file_params for every re-queued item."""
svc = _make_service(tmp_path)
await svc.add_to_history(
download_id="dl-f1", status="failed", file_params={"id": 1001}
)
await svc.add_to_history(download_id="dl-f2", status="canceled")
count = await svc.retry_all_failed()
assert count == 2
queue = await svc.get_queue()
restored = sorted(
(q["file_params"] or "") for q in queue
)
assert restored[0] == "" # dl-f2 never had file_params
assert json.loads(restored[1]) == {"id": 1001}
@pytest.mark.asyncio
async def test_legacy_history_db_gains_file_params_column(tmp_path: Path) -> None:
"""Databases created before the file_params column get migrated (#1058)."""
db_path = tmp_path / "queue.sqlite"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE download_queue (
download_id TEXT PRIMARY KEY,
model_id INTEGER,
model_version_id INTEGER,
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
source TEXT,
file_params TEXT,
status TEXT NOT NULL DEFAULT 'queued',
priority INTEGER DEFAULT 0,
progress INTEGER DEFAULT 0,
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
bytes_per_second REAL DEFAULT 0.0,
error TEXT,
file_path TEXT,
added_at REAL NOT NULL,
started_at REAL,
completed_at REAL
);
CREATE TABLE download_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
download_id TEXT,
model_id INTEGER,
model_version_id INTEGER,
model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '',
status TEXT NOT NULL,
error TEXT,
file_path TEXT,
bytes_downloaded INTEGER DEFAULT 0,
total_bytes INTEGER,
completed_at REAL NOT NULL,
is_already_exists INTEGER DEFAULT 0
);
"""
)
conn.close()
svc = DownloadQueueService(db_path=str(db_path))
# The migrated table accepts file_params writes
await svc.add_to_history(
download_id="dl-legacy-fp", status="failed", file_params={"id": 5}
)
history = await svc.get_history()
assert json.loads(history["items"][0]["file_params"]) == {"id": 5}
# And retry restores them
item = await svc.retry_from_history(download_id="dl-legacy-fp")
assert item is not None
assert json.loads(item["file_params"]) == {"id": 5}
# ---------------------------------------------------------------------------
# deduplicate() — file identity in the dedup key (#1058)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_dedup_queue_keeps_distinct_files_of_same_version(tmp_path: Path) -> None:
"""Two queued downloads of different files of one version both survive."""
svc = _make_service(tmp_path)
await svc.add_to_queue(
download_id="dl-a",
model_id=1,
model_version_id=100,
file_params={"id": 1001, "type": "Model"},
)
await svc.add_to_queue(
download_id="dl-b",
model_id=1,
model_version_id=100,
file_params={"id": 1002, "type": "Model"},
)
result = await svc.deduplicate()
assert result["removed_queue"] == 0
queue = await svc.get_queue()
assert sorted(item["download_id"] for item in queue) == ["dl-a", "dl-b"]
@pytest.mark.asyncio
async def test_dedup_queue_collapses_same_file_and_legacy_rows(tmp_path: Path) -> None:
"""Same version + same/absent file id still collapses to the latest row."""
svc = _make_service(tmp_path)
# Same file id -> only the most recently enqueued row survives
await svc.add_to_queue(
download_id="dl-old",
model_id=1,
model_version_id=100,
file_params={"id": 1001},
)
await svc.add_to_queue(
download_id="dl-new",
model_id=1,
model_version_id=100,
file_params={"id": 1001},
)
# Rows without file identity keep the old per-version behavior
await svc.add_to_queue(
download_id="dl-legacy-old", model_id=2, model_version_id=200
)
await svc.add_to_queue(
download_id="dl-legacy-new", model_id=2, model_version_id=200
)
result = await svc.deduplicate()
assert result["removed_queue"] == 2
remaining = {item["download_id"] for item in await svc.get_queue()}
assert remaining == {"dl-new", "dl-legacy-new"}
@pytest.mark.asyncio
async def test_dedup_queue_does_not_mix_null_and_file_id(tmp_path: Path) -> None:
"""A row with a file id never dedups against a row without one."""
svc = _make_service(tmp_path)
await svc.add_to_queue(
download_id="dl-file",
model_id=1,
model_version_id=100,
file_params={"id": 1001},
)
await svc.add_to_queue(
download_id="dl-nofile", model_id=1, model_version_id=100
)
result = await svc.deduplicate()
assert result["removed_queue"] == 0
assert len(await svc.get_queue()) == 2
@pytest.mark.asyncio
async def test_dedup_queue_unparseable_file_params_treated_as_none(tmp_path: Path) -> None:
"""Corrupt file_params JSON falls back to the NULL file identity."""
svc = _make_service(tmp_path)
await svc.add_to_queue(
download_id="dl-plain", model_id=1, model_version_id=100
)
conn = sqlite3.connect(str(tmp_path / "queue.sqlite"))
conn.execute(
"INSERT INTO download_queue (download_id, model_id, model_version_id, "
"file_params, status, added_at) VALUES (?, ?, ?, ?, 'queued', ?)",
("dl-corrupt", 1, 100, "{not json", time.time()),
)
conn.commit()
conn.close()
result = await svc.deduplicate()
assert result["removed_queue"] == 1
assert len(await svc.get_queue()) == 1
@pytest.mark.asyncio
async def test_dedup_history_keeps_distinct_files_of_same_version(tmp_path: Path) -> None:
"""History rows of different files never collapse, even across statuses."""
svc = _make_service(tmp_path)
await svc.add_to_history(
download_id="dl-h1",
model_id=1,
model_version_id=100,
status="completed",
file_params={"id": 1001},
)
await svc.add_to_history(
download_id="dl-h2",
model_id=1,
model_version_id=100,
status="completed",
file_params={"id": 1002},
)
# A failed entry for file 1002 must not remove file 1001's completed row
await svc.add_to_history(
download_id="dl-h3",
model_id=1,
model_version_id=100,
status="failed",
file_params={"id": 1002},
)
result = await svc.deduplicate()
assert result["removed_history"] == 1 # only dl-h3 collapses (same file)
history = await svc.get_history()
remaining = {item["download_id"] for item in history["items"]}
assert remaining == {"dl-h1", "dl-h2"}
@pytest.mark.asyncio
async def test_dedup_history_collapses_same_file_and_legacy_rows(tmp_path: Path) -> None:
"""Same file id / absent file id history rows still dedup as before."""
svc = _make_service(tmp_path)
# Same file id + same status -> keep the most recent row
await svc.add_to_history(
download_id="dl-x1",
model_id=1,
model_version_id=100,
status="completed",
file_params={"id": 1001},
)
await svc.add_to_history(
download_id="dl-x2",
model_id=1,
model_version_id=100,
status="completed",
file_params={"id": 1001},
)
# Legacy rows without file identity: cross-status dedup still applies
await svc.add_to_history(
download_id="dl-y1", model_id=2, model_version_id=200, status="failed"
)
await svc.add_to_history(
download_id="dl-y2", model_id=2, model_version_id=200, status="completed"
)
result = await svc.deduplicate()
assert result["removed_history"] == 2
history = await svc.get_history()
remaining = {item["download_id"] for item in history["items"]}
assert remaining == {"dl-x2", "dl-y2"}
@@ -68,3 +68,94 @@ async def test_download_history_bulk_lookup(tmp_path: Path) -> None:
5: {501, 502},
6: {601},
}
@pytest.mark.asyncio
async def test_per_file_history_tracking(tmp_path: Path) -> None:
"""Per-file records coexist with the version-level row (#1058)."""
db_path = tmp_path / "download-history.sqlite"
service = DownloadedVersionHistoryService(
str(db_path),
settings_manager=DummySettings(),
)
await service.mark_downloaded(
"lora", 101, model_id=11, source="download",
file_path="/models/a.safetensors", file_id=1001, file_name="a.safetensors",
)
await service.mark_downloaded(
"lora", 101, model_id=11, source="download",
file_path="/models/b.safetensors", file_id=1002, file_name="b.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
# Version-level tracking remains single-row per version
assert await service.get_downloaded_version_ids("lora", 11) == [101]
# Re-downloading the same file updates in place, no duplicate
await service.mark_downloaded(
"lora", 101, source="download", file_id=1001, file_name="a.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 101) == [1001, 1002]
# Single-file deletion keeps the sibling record
await service.mark_file_deleted("lora", 101, 1001)
assert await service.get_downloaded_file_ids("lora", 101) == [1002]
# Whole-version deletion clears per-file records
await service.mark_as_deleted("lora", 101)
assert await service.get_downloaded_file_ids("lora", 101) == []
assert await service.has_been_downloaded("lora", 101) is False
@pytest.mark.asyncio
async def test_per_file_history_ignores_invalid_ids(tmp_path: Path) -> None:
service = DownloadedVersionHistoryService(
str(tmp_path / "download-history.sqlite"),
settings_manager=DummySettings(),
)
# mark_downloaded without a file id only touches the version-level table
await service.mark_downloaded("lora", 201, model_id=21, source="scan")
assert await service.get_downloaded_file_ids("lora", 201) == []
# Invalid inputs are no-ops
await service.mark_file_deleted("lora", 201, None) # type: ignore[arg-type]
assert await service.get_downloaded_file_ids("unknown-type", 201) == []
@pytest.mark.asyncio
async def test_file_history_table_created_for_legacy_db(tmp_path: Path) -> None:
"""Existing databases gain the per-file table via CREATE IF NOT EXISTS."""
import sqlite3
db_path = tmp_path / "download-history.sqlite"
conn = sqlite3.connect(db_path)
conn.executescript(
"""
CREATE TABLE downloaded_model_versions (
model_type TEXT NOT NULL,
version_id INTEGER NOT NULL,
model_id INTEGER,
first_seen_at REAL NOT NULL,
last_seen_at REAL NOT NULL,
source TEXT NOT NULL,
last_file_path TEXT,
last_library_name TEXT,
is_deleted_override INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_type, version_id)
);
"""
)
conn.close()
service = DownloadedVersionHistoryService(
str(db_path),
settings_manager=DummySettings(),
)
await service.mark_downloaded(
"lora", 301, model_id=31, source="download",
file_id=9001, file_name="file.safetensors",
)
assert await service.get_downloaded_file_ids("lora", 301) == [9001]
assert await service.has_been_downloaded("lora", 301) is True
+84
View File
@@ -61,3 +61,87 @@ async def test_model_cache_tracks_versions_by_model_id():
assert cache.get_versions_by_model_id(2) == [
{'versionId': 201, 'name': 'Gamma', 'fileName': 'model-b'},
]
@pytest.mark.asyncio
async def test_version_files_index_tracks_multiple_files_per_version():
"""Two downloaded files of the same version both stay indexed (#1058)."""
item_a = {
'file_path': '/models/v1-a.safetensors',
'file_name': 'model-v1-a',
'folder': '',
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
}
item_b = {
'file_path': '/models/v1-b.safetensors',
'file_name': 'model-v1-b',
'folder': '',
'civitai': {'id': 301, 'modelId': 3, 'name': 'Multi'},
}
cache = ModelCache(
raw_data=[item_a, item_b],
folders=[],
name_display_mode='model_name',
)
files = cache.get_files_by_version_id(301)
assert {f['file_path'] for f in files} == {
'/models/v1-a.safetensors',
'/models/v1-b.safetensors',
}
# Re-adding an existing entry must not duplicate it
cache.add_to_version_index(item_a)
assert len(cache.get_files_by_version_id(301)) == 2
# Removing the indexed file re-points version_index to the sibling
indexed = cache.version_index[301]
sibling = item_b if indexed is item_a else item_a
cache.remove_from_version_index(indexed)
assert 301 in cache.version_index
assert cache.version_index[301]['file_path'] == sibling['file_path']
assert cache.get_versions_by_model_id(3) == [
{'versionId': 301, 'name': 'Multi', 'fileName': sibling['file_name']},
]
remaining = cache.get_files_by_version_id(301)
assert [f['file_path'] for f in remaining] == [sibling['file_path']]
# Removing the last file drops the version from all indexes
cache.remove_from_version_index(sibling)
assert 301 not in cache.version_index
assert cache.get_files_by_version_id(301) == []
assert cache.get_versions_by_model_id(3) == []
assert 3 not in cache.model_id_index
@pytest.mark.asyncio
async def test_version_files_index_rebuild_from_raw_data():
"""rebuild_version_index reconstructs the multi-valued index (#1058)."""
item_a = {
'file_path': '/models/v1-a.safetensors',
'file_name': 'model-v1-a',
'folder': '',
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
}
item_b = {
'file_path': '/models/v1-b.safetensors',
'file_name': 'model-v1-b',
'folder': '',
'civitai': {'id': 401, 'modelId': 4, 'name': 'Multi'},
}
cache = ModelCache(
raw_data=[item_a, item_b],
folders=[],
name_display_mode='model_name',
)
cache.version_files_index = {}
cache.rebuild_version_index()
assert len(cache.get_files_by_version_id(401)) == 2
# Invalid ids normalize to empty results
assert cache.get_files_by_version_id('not-an-int') == []
assert cache.get_files_by_version_id(None) == []
+313 -2
View File
@@ -57,9 +57,33 @@ class StubLoraScanner:
meta = self._hash_meta.get(hash_value.lower())
return meta.get("path") if meta else None
async def get_model_info_by_name(self, name: str):
async def get_model_info_by_name(
self,
name: str,
*,
require_unique: bool = False,
base_model: str | None = None,
):
if require_unique or base_model:
matches = ModelScanner.find_matching_models(
self._cache.raw_data,
name,
base_model=base_model,
extensions={".safetensors"},
)
if require_unique and len(matches) != 1:
return None
return matches[0] if matches else None
return self._models_by_name.get(name)
async def find_models_by_name(self, name: str, *, base_model: str | None = None):
return ModelScanner.find_matching_models(
self._cache.raw_data,
name,
base_model=base_model,
extensions={".safetensors"},
)
def register_model(self, name: str, info: Dict[str, Any]) -> None:
self._models_by_name[name] = info
hash_value = (info.get("sha256") or "").lower()
@@ -107,6 +131,39 @@ def recipe_scanner(tmp_path: Path, monkeypatch):
settings_manager_module.reset_settings_manager()
@pytest.mark.asyncio
async def test_local_lora_lookup_requires_unambiguous_name_and_matching_base_model(recipe_scanner):
scanner, stub = recipe_scanner
models = [
{
"file_name": "style.safetensors",
"folder": "sd15",
"file_path": "/models/loras/sd15/style.safetensors",
"sha256": "a" * 64,
"base_model": "SD 1.5",
},
{
"file_name": "style.safetensors",
"folder": "sdxl",
"file_path": "/models/loras/sdxl/style.safetensors",
"sha256": "b" * 64,
"base_model": "SDXL 1.0",
},
]
stub._cache.raw_data = models
stub._hash_meta["b" * 64] = {"path": models[1]["file_path"]}
assert await scanner.get_local_lora("style") is None
assert await scanner.get_local_lora("style", "SDXL 1.0") is models[1]
assert await scanner.get_local_lora("sdxl/style.safetensors", "SDXL 1.0") is models[1]
# The lora scanner only indexes .safetensors, so a .pt name must not be
# stripped into a cross-extension match.
assert await scanner.get_local_lora("sdxl/style.pt", "SDXL 1.0") is None
assert await scanner.get_local_lora("sdxl/style.safetensors", "SD 1.5") is None
assert await scanner.get_local_lora("other/style.safetensors") is None
assert await scanner.get_local_lora_by_hash("b" * 64) is models[1]
def test_recipes_dir_uses_custom_settings_path(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
@@ -336,6 +393,214 @@ async def test_load_recipe_upgrades_string_checkpoint(tmp_path: Path, recipe_sca
assert loaded["checkpoint"]["file_name"] == "sd15"
# ---------------------------------------------------------------------------
# has_workflow detection (plan 3.1)
# ---------------------------------------------------------------------------
def _recipe_json(recipes_dir: Path, recipe_id: str, image_path: Path, **extra: Any) -> Path:
recipe_path = recipes_dir / f"{recipe_id}.recipe.json"
data: Dict[str, Any] = {
"id": recipe_id,
"file_path": str(image_path),
"title": recipe_id,
"modified": 0.0,
"created_date": 0.0,
"loras": [],
}
data.update(extra)
recipe_path.write_text(json.dumps(data))
return recipe_path
def _mock_metadata(monkeypatch, workflow=None, raises=False):
def _load_structured_metadata(_image_path):
if raises:
raise RuntimeError("metadata parse failure")
return {
"parameters": None,
"prompt": "a test prompt",
"workflow": workflow,
"comment": None,
}
monkeypatch.setattr(
"py.services.recipe_scanner.ExifUtils._load_structured_metadata",
_load_structured_metadata,
)
@pytest.mark.asyncio
async def test_load_recipe_detects_embedded_workflow(tmp_path: Path, recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "with-workflow.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "with-workflow", image_path)
_mock_metadata(monkeypatch, workflow='{"nodes": []}')
loaded = await scanner._load_recipe_file(str(recipe_path))
assert loaded["has_workflow"] is True
persisted = json.loads(recipe_path.read_text())
assert persisted["has_workflow"] is True
@pytest.mark.asyncio
async def test_load_recipe_detects_no_workflow(tmp_path: Path, recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "without-workflow.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "without-workflow", image_path)
_mock_metadata(monkeypatch, workflow=None)
loaded = await scanner._load_recipe_file(str(recipe_path))
assert loaded["has_workflow"] is False
persisted = json.loads(recipe_path.read_text())
assert persisted["has_workflow"] is False
@pytest.mark.asyncio
async def test_load_recipe_metadata_exception_yields_false(
tmp_path: Path, recipe_scanner, monkeypatch
):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "broken-meta.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "broken-meta", image_path)
_mock_metadata(monkeypatch, raises=True)
loaded = await scanner._load_recipe_file(str(recipe_path))
assert loaded["has_workflow"] is False
@pytest.mark.asyncio
async def test_load_recipe_missing_image_yields_false(
tmp_path: Path, recipe_scanner, monkeypatch
):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "missing-image.webp"
recipe_path = _recipe_json(recipes_dir, "missing-image", image_path)
_mock_metadata(monkeypatch, workflow='{"nodes": []}')
loaded = await scanner._load_recipe_file(str(recipe_path))
assert loaded["has_workflow"] is False
@pytest.mark.asyncio
async def test_load_recipe_keeps_existing_has_workflow(
tmp_path: Path, recipe_scanner, monkeypatch
):
scanner, _ = recipe_scanner
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "pre-recorded.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "pre-recorded", image_path, has_workflow=True)
_mock_metadata(monkeypatch, workflow=None)
loaded = await scanner._load_recipe_file(str(recipe_path))
assert loaded["has_workflow"] is True
persisted = json.loads(recipe_path.read_text())
assert persisted["has_workflow"] is True
def test_load_recipe_file_sync_detects_embedded_workflow(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
scanner = RecipeScanner(lora_scanner=StubLoraScanner()) # pyright: ignore[reportArgumentType]
try:
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "sync-workflow.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "sync-workflow", image_path)
_mock_metadata(monkeypatch, workflow='{"nodes": []}')
loaded = scanner._load_recipe_file_sync(str(recipe_path))
assert loaded["has_workflow"] is True
persisted = json.loads(recipe_path.read_text())
assert persisted["has_workflow"] is True
finally:
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
def test_load_recipe_file_sync_detects_no_workflow(tmp_path: Path, monkeypatch):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
scanner = RecipeScanner(lora_scanner=StubLoraScanner()) # pyright: ignore[reportArgumentType]
try:
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "sync-no-workflow.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "sync-no-workflow", image_path)
_mock_metadata(monkeypatch, workflow=None)
loaded = scanner._load_recipe_file_sync(str(recipe_path))
assert loaded["has_workflow"] is False
persisted = json.loads(recipe_path.read_text())
assert persisted["has_workflow"] is False
finally:
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
def test_load_recipe_file_sync_metadata_exception_yields_false(
tmp_path: Path, monkeypatch
):
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
monkeypatch.setattr(config, "loras_roots", [str(tmp_path)])
scanner = RecipeScanner(lora_scanner=StubLoraScanner()) # pyright: ignore[reportArgumentType]
try:
recipes_dir = Path(config.loras_roots[0]) / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
image_path = recipes_dir / "sync-broken-meta.webp"
image_path.write_bytes(b"fake-webp")
recipe_path = _recipe_json(recipes_dir, "sync-broken-meta", image_path)
_mock_metadata(monkeypatch, raises=True)
loaded = scanner._load_recipe_file_sync(str(recipe_path))
assert loaded["has_workflow"] is False
finally:
RecipeScanner._instance = None
settings_manager_module.reset_settings_manager()
@pytest.mark.asyncio
async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner):
scanner, _ = recipe_scanner
@@ -360,7 +625,6 @@ async def test_get_paginated_data_normalizes_legacy_checkpoint(recipe_scanner):
assert checkpoint["name"] == "legacy.safetensors"
assert checkpoint["file_name"] == "legacy"
@pytest.mark.asyncio
async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
scanner, _ = recipe_scanner
@@ -383,6 +647,53 @@ async def test_get_recipe_by_id_handles_non_dict_checkpoint(recipe_scanner):
assert recipe["checkpoint"]["file_name"] == "by-id"
@pytest.mark.asyncio
async def test_get_recipe_by_id_detects_workflow_for_legacy_recipes(
recipe_scanner, monkeypatch
):
scanner, _ = recipe_scanner
await scanner.add_recipe(
{
"id": "legacy-no-flag",
"file_path": "/models/recipes/legacy.webp",
"title": "Legacy",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
}
)
monkeypatch.setattr(
RecipeScanner, "_detect_has_workflow", lambda self, path: True
)
recipe = await scanner.get_recipe_by_id("legacy-no-flag")
assert recipe is not None
assert recipe["has_workflow"] is True
@pytest.mark.asyncio
async def test_get_recipe_by_id_keeps_existing_has_workflow_flag(recipe_scanner):
scanner, _ = recipe_scanner
await scanner.add_recipe(
{
"id": "flagged",
"file_path": "/models/recipes/flagged.webp",
"title": "Flagged",
"modified": 0.0,
"created_date": 0.0,
"loras": [],
"has_workflow": False,
}
)
recipe = await scanner.get_recipe_by_id("flagged")
assert recipe is not None
assert recipe["has_workflow"] is False
@pytest.mark.asyncio
async def test_get_recipe_by_id_merges_recipe_json_details(recipe_scanner):
scanner, _ = recipe_scanner
+157
View File
@@ -17,6 +17,7 @@ from py.services.recipes.errors import (
RecipeValidationError,
)
from py.services.recipes.persistence_service import RecipePersistenceService
from py.services.model_scanner import ModelScanner
from py.recipes.parsers.civitai_image import CivitaiApiMetadataParser
from py.utils.exif_utils import ExifUtils
@@ -25,6 +26,7 @@ class DummyExifUtils:
def __init__(self):
self.appended = None
self.optimized_calls = 0
self.workflow_value = None
def optimize_image(self, image_data, target_width, format, quality, preserve_metadata):
self.optimized_calls += 1
@@ -36,6 +38,14 @@ class DummyExifUtils:
def extract_image_metadata(self, path):
return {}
def _load_structured_metadata(self, image_path):
return {
"parameters": None,
"prompt": None,
"workflow": self.workflow_value,
"comment": None,
}
@pytest.mark.asyncio
async def test_save_recipe_video_bypasses_optimization(tmp_path):
@@ -212,6 +222,84 @@ async def test_save_recipe_reports_duplicates(tmp_path):
assert service._exif_utils.appended[0] == expected_image_path
@pytest.mark.asyncio
async def test_save_recipe_records_has_workflow(tmp_path):
exif_utils = DummyExifUtils()
exif_utils.workflow_value = '{"nodes": []}'
class DummyCache:
def __init__(self):
self.raw_data = []
async def resort(self):
pass
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
self._cache = DummyCache()
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
self._cache.raw_data.append(recipe_data)
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"image-bytes",
image_base64=None,
name="Workflow Recipe",
tags=[],
metadata={"base_model": "sd", "loras": []},
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["has_workflow"] is True
assert scanner._cache.raw_data[0]["has_workflow"] is True
@pytest.mark.asyncio
async def test_save_recipe_records_no_workflow(tmp_path):
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root)
async def find_recipes_by_fingerprint(self, fingerprint):
return []
async def add_recipe(self, recipe_data):
return None
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"image-bytes",
image_base64=None,
name="Plain Recipe",
tags=[],
metadata={"base_model": "sd", "loras": []},
)
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["has_workflow"] is False
@pytest.mark.asyncio
async def test_save_recipe_persists_checkpoint_metadata(tmp_path):
exif_utils = DummyExifUtils()
@@ -1156,3 +1244,72 @@ async def test_analyze_local_image_fingerprint_uses_sha256_normalized_hash(tmp_p
assert result.payload["loras"][0]["hash"] == sha256
assert result.payload["fingerprint"] == f"{sha256}:1.0"
@pytest.mark.asyncio
async def test_reconnect_lora_distinguishes_ambiguous_mismatched_and_missing(tmp_path):
service = RecipePersistenceService(
exif_utils=DummyExifUtils(),
card_preview_width=512,
logger=logging.getLogger("test"),
)
models = [
{
"file_name": "style.safetensors",
"folder": "sd15",
"file_path": "/models/loras/sd15/style.safetensors",
"base_model": "SD 1.5",
},
{
"file_name": "style.safetensors",
"folder": "sdxl",
"file_path": "/models/loras/sdxl/style.safetensors",
"base_model": "SDXL 1.0",
},
]
class DummyScanner:
def __init__(self, recipe_path):
self._recipe_path = recipe_path
async def get_recipe_json_path(self, recipe_id):
return str(self._recipe_path)
async def get_local_lora(self, name, base_model=None):
matches = ModelScanner.find_matching_models(models, name, base_model=base_model)
return matches[0] if len(matches) == 1 else None
async def find_local_loras_by_name(self, name, base_model=None):
return ModelScanner.find_matching_models(models, name, base_model=base_model)
def write_recipe(base_model):
recipe_path = tmp_path / "recipe.json"
recipe_path.write_text(
json.dumps({"id": "r1", "base_model": base_model, "loras": []})
)
return DummyScanner(recipe_path)
# Ambiguous bare name: two candidates survive (recipe base model unknown)
scanner = write_recipe("")
with pytest.raises(RecipeValidationError, match="include the folder path"):
await service.reconnect_lora(
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="style"
)
# Confident base-model mismatch: the only candidate belongs to another family
scanner = write_recipe("SD 1.5")
with pytest.raises(RecipeValidationError, match="different base model"):
await service.reconnect_lora(
recipe_scanner=scanner,
recipe_id="r1",
lora_index=0,
target_name="sdxl/style",
)
# No candidate at all
scanner = write_recipe("SDXL 1.0")
with pytest.raises(RecipeNotFoundError, match="not found"):
await service.reconnect_lora(
recipe_scanner=scanner, recipe_id="r1", lora_index=0, target_name="missing"
)
+83
View File
@@ -551,3 +551,86 @@ class TestPersistentRecipeCache:
loaded = cache.load_cache()
assert loaded is not None
assert loaded.image_id_map == {"222": "new-only"}
class TestHasWorkflowColumn:
"""has_workflow column persistence (plan 3.1)."""
def test_save_and_load_roundtrip(self, temp_db_path):
"""has_workflow must round-trip through save_cache()/load_cache()."""
cache = PersistentRecipeCache(db_path=temp_db_path)
recipes = [
{"id": "wf-1", "title": "Has Workflow", "has_workflow": True},
{"id": "wf-2", "title": "No Workflow", "has_workflow": False},
{"id": "wf-3", "title": "Unset Workflow"},
]
cache.save_cache(recipes)
loaded = cache.load_cache()
assert loaded is not None
by_id = {r["id"]: r for r in loaded.raw_data}
assert by_id["wf-1"]["has_workflow"] is True
assert by_id["wf-2"]["has_workflow"] is False
assert by_id["wf-3"]["has_workflow"] is False
def test_prepare_recipe_row_matches_column_order(self, temp_db_path):
"""The prepared row must append has_workflow in column order."""
cache = PersistentRecipeCache(db_path=temp_db_path)
row_true = cache._prepare_recipe_row({"id": "r1", "has_workflow": True}, "")
row_false = cache._prepare_recipe_row({"id": "r2", "has_workflow": False}, "")
assert row_true[-1] == 1
assert row_false[-1] == 0
assert len(row_true) == len(cache._RECIPE_COLUMNS)
assert cache._RECIPE_COLUMNS[-1] == "has_workflow"
def test_update_recipe_preserves_has_workflow(self, temp_db_path):
"""update_recipe() must write the has_workflow column correctly."""
cache = PersistentRecipeCache(db_path=temp_db_path)
cache.save_cache([{"id": "wf-update", "title": "x", "has_workflow": True}])
cache.update_recipe({"id": "wf-update", "title": "y", "has_workflow": False})
loaded = cache.load_cache()
assert loaded is not None
assert loaded.raw_data[0]["has_workflow"] is False
def test_migrates_legacy_database_adds_has_workflow(self, temp_db_path):
"""A database created before has_workflow existed must still load."""
import sqlite3
conn = sqlite3.connect(temp_db_path)
conn.execute(
"""
CREATE TABLE recipes (
recipe_id TEXT PRIMARY KEY,
file_path TEXT,
json_path TEXT,
title TEXT,
folder TEXT,
source_path TEXT,
base_model TEXT,
fingerprint TEXT,
created_date REAL,
modified REAL,
file_mtime REAL,
file_size INTEGER,
favorite INTEGER DEFAULT 0,
repair_version INTEGER DEFAULT 0,
preview_nsfw_level INTEGER DEFAULT 0,
loras_json TEXT,
checkpoint_json TEXT,
gen_params_json TEXT,
tags_json TEXT
)
"""
)
conn.execute("INSERT INTO recipes (recipe_id, title) VALUES ('legacy-1', 'Legacy')")
conn.commit()
conn.close()
cache = PersistentRecipeCache(db_path=temp_db_path)
loaded = cache.load_cache()
assert loaded is not None
assert loaded.raw_data[0]["id"] == "legacy-1"
assert loaded.raw_data[0]["has_workflow"] is False
-88
View File
@@ -85,9 +85,6 @@ export function createAutocompleteTextWidgetInstanceId() {
return autocompleteTextWidgetInstanceId
}
// Cache for dynamically loaded addLorasWidget module
let addLorasWidgetCache: any = null
// @ts-ignore
function createLoraPoolWidget(node) {
const container = document.createElement('div')
@@ -876,91 +873,6 @@ app.registerExtension({
CYCLER_CONFIG(node) {
return createLoraCyclerWidget(node)
},
// @ts-ignore
async LORAS(node: any) {
if (!addLorasWidgetCache) {
// @ts-ignore
const module = await import(/* @vite-ignore */ '../loras_widget.js')
addLorasWidgetCache = module.addLorasWidget
}
// Check if this is a randomizer node to enable lock buttons
const isRandomizerNode = node.comfyClass === 'Lora Randomizer (LoraManager)'
// For randomizer nodes, add a callback to update connected trigger words
const callback = isRandomizerNode ? () => {
updateDownstreamLoaders(node)
} : null
const opts: { isRandomizerNode?: boolean; onSelectionChange?: (selection: any) => void } = {
isRandomizerNode,
}
if (isRandomizerNode) {
opts.onSelectionChange = async (selection: any) => {
if (!selection?.name || !selection?.active) return
// Walk outputs to find directly connected Lora Info nodes
const infoNodes: any[] = []
if (node.outputs) {
for (const output of node.outputs) {
if (!output?.links?.length) continue
for (const linkId of output.links) {
const links = node.graph?.links
if (!links) continue
const link = Array.isArray(links) ? links[linkId] : links.get?.(linkId)
if (!link) continue
const targetNode = node.graph?.getNodeById?.(link.target_id)
if (targetNode?.comfyClass === 'Lora Info (LoraManager)') {
infoNodes.push(targetNode)
}
}
}
}
if (infoNodes.length === 0) return
// Bump request token to guard against stale async responses
for (const infoNode of infoNodes) {
infoNode.__loraInfoReqId = (infoNode.__loraInfoReqId || 0) + 1
}
const reqIdSnapshot = new Map<any, number>()
for (const infoNode of infoNodes) {
reqIdSnapshot.set(infoNode, infoNode.__loraInfoReqId)
}
// Fetch notes via the real ComfyUI api
let infoData: any
try {
const response = await api.fetchApi(
`/lm/loras/get-notes?name=${encodeURIComponent(selection.name)}`,
{ method: 'GET' }
)
if (response?.ok) {
const data = await response.json()
infoData = {
name: selection.name,
notes: data?.notes || '',
filePath: data?.file_path || '',
}
} else {
infoData = { name: selection.name, notes: '[Error loading notes]', filePath: '' }
}
} catch {
infoData = { name: selection.name, notes: '[Error loading notes]', filePath: '' }
}
for (const infoNode of infoNodes) {
if (infoNode.__loraInfoReqId !== reqIdSnapshot.get(infoNode)) {
continue
}
if (typeof infoNode._setLoraInfo === 'function') {
infoNode._setLoraInfo(infoData)
}
}
}
}
return addLorasWidgetCache(node, 'loras', opts, callback)
},
// Autocomplete text widget for LoRAs (used by Lora Loader, Lora Stacker, WanVideo Lora Select)
// @ts-ignore
AUTOCOMPLETE_TEXT_LORAS(node) {
+3 -3
View File
@@ -2893,14 +2893,14 @@ class AutoComplete {
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
detail: command.feedbackDetail || (enabled
? 'Tag autocomplete is now ON. Type to see suggestions.'
: 'Tag autocomplete is now OFF. Use /ac to re-enable.'),
: 'Tag autocomplete is now OFF. Use /autocomplete to re-enable.'),
life: 3000
});
}
/**
* Clear the current command token from input
* Preserves leading spaces after delimiters (e.g., "1girl, /ac" -> "1girl, ")
* Preserves leading spaces after delimiters (e.g., "1girl, /emb" -> "1girl, ")
*/
_clearCurrentToken() {
const currentValue = this.inputElement.value;
@@ -2909,7 +2909,7 @@ class AutoComplete {
const lastSegment = activeRange.rawText;
// Find the command start position, preserving leading spaces
// lastSegment includes leading spaces (e.g., " /ac"), find where command actually starts
// lastSegment includes leading spaces (e.g., " /emb"), find where command actually starts
const commandMatch = lastSegment.match(/^(\s*)(\/\w+)/);
if (commandMatch) {
// commandMatch[1] is leading spaces, commandMatch[2] is the command

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