Compare commits

..

57 Commits

Author SHA1 Message Date
Will Miao 08895f77ff fix(update): align update-check summary count with Updates filter scope (#1083) 2026-08-25 20:36:46 +08:00
Will Miao 74f889f160 fix(recipes): validate FTS index from stored metadata instead of scanning 2026-08-25 17:38:31 +08:00
Will Miao c51090ab16 fix(recipes): make source_path backfill a one-shot migration 2026-08-25 17:38:17 +08:00
Will Miao cdb044cb45 fix(scanner): offload persisted-cache hydration from the event loop 2026-08-25 17:38:09 +08:00
Will Miao c83b26b556 fix(delete): run startup reconciliation walk off the event loop 2026-08-25 17:38:01 +08:00
Will Miao a202c666bc fix(download): return 200 for missing queue items and quiet download-progress 404s
The browser extension's apiFetch treats any 404 as a missing endpoint and
retries the legacy non-/api/lm URL, producing two spurious
'error_middleware - WARNING - API GET ... 404' log lines per occurrence.

- complete_download_in_queue / update_download_queue_status /
  retry_download_from_history: 'not found' is a normal business outcome,
  return 200 + success:false instead of 404 (extension behavior unchanged;
  apiGet ignores the HTTP status)
- error_middleware: downgrade /api/lm/download-progress/ 404s to debug like
  previews - the 404 status itself stays (extension uses it for failure
  detection), only the log level is lowered
2026-08-25 09:59:33 +08:00
Will Miao e05046af10 fix(loaders): sanitize invalid control_after_generate values when loading old workflows
Old workflows (saved before the control_after_generate feature) carry a
shorter widgets_values array. The frontend's index-based widget restore
then shifts the old weight_dtype value into the hidden control widget
(leaving an invalid value like 'default') and silently resets
weight_dtype to its default. On graph load, hand the shifted value back
to weight_dtype when it still sits at its default, then reset the
control mode to 'fixed' so old workflows keep loading deterministically.
2026-08-25 08:30:17 +08:00
Will Miao 41ed03e5c6 fix(download): stop stale aria2 GIDs from spamming errors after queue clears
- Log expected "GID not found" tellStatus probes at DEBUG, and treat a
  forgotten GID as permanent so the poll loop recovers immediately
  instead of burning 4 retries x 3s of ERROR lines per cycle
- cancel_download tolerates a forgotten GID and always pops the
  in-memory transfer so concurrent polls cannot re-register a
  cancelled download
- Restore sweep deletes aria2 state records with no resolvable target
  path instead of skipping them forever
- Clearing the download queue now also cancels in-memory tasks, removes
  live aria2 transfers and drops persisted state for the cleared ids
  (partial files on disk are preserved)
2026-08-25 08:19:54 +08:00
Will Miao da071e8452 feat(versions): add file-variant badge and hide download button for in-library versions (#1058) 2026-08-24 23:17:10 +08:00
Will Miao a0bb6df2b8 test(recipe): reset RecipeScanner singleton in lora availability fixture 2026-08-24 17:23:20 +08:00
Will Miao 6f5c444ec5 feat(recipes): add lora availability filter to recipe filter panel 2026-08-24 17:00:02 +08:00
Will Miao 20f66a4fe1 fix(ui): reload listing when an invalid folder selection falls back to root
After a drag move empties the selected folder, refresh() resets the
stale activeFolder to root but the grid kept showing the old filtered
(empty) view until a manual reload. Trigger resetAndReload when the
fallback happens post-initialization; the initial page load is untouched
because it picks up the cleared filter on its own.
2026-08-24 14:17:06 +08:00
Will Miao 879745da53 fix(init): add missing /api/lm/init-status endpoint used by polling fallback
initialization.js falls back to polling /api/lm/init-status when the
/ws/init-progress WebSocket cannot be established, but no route ever
registered that path — each poll 404'd and the page never reloaded after
the scan completed. Report the aggregate status of all four scanners and
omit pageType so every initialization page accepts the update.
2026-08-24 14:17:06 +08:00
Will Miao 3afec0a0be fix(ui): fall back to folder root when persisted active folder no longer exists
restoreSelectedFolder trusted localStorage blindly: a stale activeFolder
(moved/deleted, or saved while the tree was still empty) left the grid
filtered to a nonexistent folder with a phantom breadcrumb and no way to
recover short of clicking the root breadcrumb. Validate the persisted
path against the freshly loaded tree and reset to root when it is gone;
skip validation when the tree load failed so transient errors don't wipe
the saved location.
2026-08-24 14:17:06 +08:00
Will Miao 06c270a6e1 fix(recipes): show initialization screen and auto-reload during first scan
The recipes page always rendered with is_initializing=False, so a cold
start displayed an empty grid that never updated until a manual refresh.
Mirror the model pages: gate render_page on the scanner state, broadcast
init progress from RecipeScanner (including a completion message, and a
failure fallback so the page never stalls), and teach initialization.js
to detect the /loras/recipes page before the generic /loras match.
2026-08-24 14:17:06 +08:00
Will Miao 87e93636dc fix(recipes): wait for in-flight cache initialization instead of returning empty cache
get_cached_data() claimed to wait for a running initialization but
actually returned the placeholder empty cache, so API requests during
startup saw zero recipes. The initializing flag was also set only after
the LoRA scanner wait, leaving an unguarded window. Mark initialization
before the first await and have callers await the in-flight task.
2026-08-24 14:17:06 +08:00
Will Miao 074d1f2e51 feat(ui): improve tag autocomplete toggle discoverability in prompt nodes
- Add Tag Autocomplete ON/OFF entry to the Prompt (LoraManager) node
  right-click menu, cross-referencing the slash commands
- Show the current autocomplete state (/autocomplete or /noautocomplete
  hint) below the slash command list
- Show a one-time dismissible tip in the suggestion dropdown on first use
- Clarify toggle command labels (Turn autocomplete ON/OFF) and cross-link
  all three entry points in the settings tooltip
- Share the setting write path via setLoraManagerSettingValue()
2026-08-24 12:21:31 +08:00
Will Miao 40f922b0e8 fix(ui): right-anchor license icons and delete button as one group in model modal 2026-08-24 11:39:17 +08:00
Will Miao a7214b6cff fix(i18n): translate remaining workflow-related UI strings 2026-08-24 09:31:17 +08:00
Will Miao 8ca66e72eb feat(ui): add delete button and Del shortcut to model and recipe modals 2026-08-24 09:27:00 +08:00
Will Miao 90be5799e4 fix(ui): preserve group editor scroll position when toggling tags 2026-08-24 08:17:47 +08:00
Will Miao 1a93b0eca2 feat(recipes): add prev/next navigation buttons and keyboard shortcuts to recipe modal 2026-08-24 08:15:19 +08:00
Will Miao c2360a35ad fix(ui): show empty folders as move and download destinations (#999) 2026-08-23 21:09:16 +08:00
Will Miao 030a32f8fa feat(ui): add hash search option and de-emphasized hash display in model modal 2026-08-23 10:09:55 +08:00
Will Miao 25e72b43ce fix(download): disable netrc auto-auth to avoid Authorization header conflict (#1070)
With trust_env=True, aiohttp auto-loads credentials from ~/.netrc (e.g. a
'machine civitai.red' or 'default' entry) and refuses to combine them with
the explicit Authorization: Bearer header, aborting every authenticated
CivitAI request with 'Cannot combine AUTHORIZATION header with AUTH
argument or credentials encoded in URL'.
2026-08-22 19:33:01 +08:00
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
161 changed files with 15505 additions and 3624 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 |
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "Modellname", "modelname": "Modellname",
"tags": "Tags", "tags": "Tags",
"creator": "Ersteller", "creator": "Ersteller",
"hash": "Hash",
"title": "Rezept-Titel", "title": "Rezept-Titel",
"loraName": "LoRA-Dateiname", "loraName": "LoRA-Dateiname",
"loraModel": "LoRA-Modellname", "loraModel": "LoRA-Modellname",
@@ -259,7 +260,11 @@
"any": "Beliebig", "any": "Beliebig",
"all": "Alle", "all": "Alle",
"tagLogicAny": "Jedes Tag abgleichen (ODER)", "tagLogicAny": "Jedes Tag abgleichen (ODER)",
"tagLogicAll": "Alle Tags abgleichen (UND)" "tagLogicAll": "Alle Tags abgleichen (UND)",
"loraAvailability": "LoRA-Verfügbarkeit",
"availabilityReady": "Einsatzbereit",
"availabilityMissing": "Mit fehlenden LoRAs",
"availabilityDeleted": "Mit gelöschten LoRAs"
}, },
"theme": { "theme": {
"toggle": "Theme wechseln", "toggle": "Theme wechseln",
@@ -623,8 +628,8 @@
"help": "Nur Early-Access-Updates" "help": "Nur Early-Access-Updates"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "Bezahlte Updates ausblenden",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "Wenn aktiviert, zeigen Modelle mit nur bezahlten Updates kein 'Update verfügbar'-Badge an"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden", "useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRA-Rezepte", "title": "LoRA-Rezepte",
"actions": { "actions": {
"sendCheckpoint": "Send to ComfyUI" "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI",
"deleteRecipeWithShortcut": "Rezept löschen (Del)"
},
"navigation": {
"label": "Rezeptnavigation",
"previousWithShortcut": "Vorheriges Rezept (←)",
"nextWithShortcut": "Nächstes Rezept (→)"
},
"workflow": {
"sendWorkflow": "Workflow an ComfyUI senden",
"sent": "Workflow an ComfyUI gesendet",
"sendFailed": "Fehler beim Senden des Workflows an ComfyUI",
"noWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "Importieren", "action": "Importieren",
"title": "Ein Rezept aus Bild oder URL importieren", "title": "Ein Rezept aus Bild oder URL importieren",
"urlLocalPath": "URL / Lokaler Pfad", "dropZoneLabel": "Bild hochladen",
"uploadImage": "Bild hochladen", "dropZoneHint": "Bild hierher ziehen, aus der Zwischenablage einfügen oder klicken zum Durchsuchen",
"urlSectionDescription": "Geben Sie eine Civitai-Bild-URL oder einen lokalen Dateipfad ein, um es als Rezept zu importieren.", "orDivider": "oder Bild per Drag & Drop / Einfügen hinzufügen",
"imageUrlOrPath": "Bild-URL oder Dateipfad:", "imageUrlOrPath": "Bild-URL oder Dateipfad:",
"urlPlaceholder": "https://civitai.com/images/... oder C:/pfad/zu/bild.png", "urlPlaceholder": "https://civitai.com/images/... oder C:/pfad/zu/bild.png",
"fetchImage": "Bild abrufen", "fetchImage": "Bild abrufen",
"uploadSectionDescription": "Laden Sie ein Bild mit LoRA-Metadaten hoch, um es als Rezept zu importieren.",
"selectImage": "Bild auswählen",
"recipeName": "Rezeptname", "recipeName": "Rezeptname",
"recipeNamePlaceholder": "Rezeptname eingeben", "recipeNamePlaceholder": "Rezeptname eingeben",
"tagsOptional": "Tags (optional)", "tagsOptional": "Tags (optional)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "Bitte wählen Sie eine Bilddatei aus", "selectImageFile": "Bitte wählen Sie eine Bilddatei aus",
"enterUrlOrPath": "Bitte geben Sie eine URL oder einen Dateipfad ein", "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" "selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "Heruntergeladen", "downloaded": "Heruntergeladen",
"downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.", "downloadedTooltip": "Zuvor heruntergeladen, aber derzeit nicht in Ihrer Bibliothek.",
"alreadyInLibrary": "Bereits in Bibliothek", "alreadyInLibrary": "Bereits in Bibliothek",
"partiallyDownloaded": "Teilweise heruntergeladen",
"autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]", "autoOrganizedPath": "[Automatisch organisiert durch Pfadvorlage]",
"fileSelection": { "fileSelection": {
"title": "Dateiformat auswählen", "title": "Dateiformat auswählen",
"files": "Dateien", "files": "Dateien",
"select": "Datei auswählen" "select": "Datei auswählen",
"inLibrary": "In Bibliothek"
}, },
"errors": { "errors": {
"invalidUrl": "Ungültiges Civitai URL-Format", "invalidUrl": "Ungültiges Civitai URL-Format",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "Ersteller-Profil anzeigen", "viewCreatorProfile": "Ersteller-Profil anzeigen",
"openFileLocation": "Dateispeicherort öffnen", "openFileLocation": "Dateispeicherort öffnen",
"sendToWorkflow": "An ComfyUI senden", "sendToWorkflow": "An ComfyUI senden",
"sendToWorkflowText": "An ComfyUI senden" "sendToWorkflowText": "An ComfyUI senden",
"copyHash": "Hash kopieren",
"deleteModelWithShortcut": "Modell löschen (Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Dateispeicherort erfolgreich geöffnet", "success": "Dateispeicherort erfolgreich geöffnet",
@@ -1441,6 +1463,7 @@
"location": "Speicherort", "location": "Speicherort",
"baseModel": "Basis-Modell", "baseModel": "Basis-Modell",
"size": "Größe", "size": "Größe",
"hashes": "Hashes",
"unknown": "Unbekannt", "unknown": "Unbekannt",
"usageTips": "Nutzungstipps", "usageTips": "Nutzungstipps",
"additionalNotes": "Zusätzliche Notizen", "additionalNotes": "Zusätzliche Notizen",
@@ -1532,6 +1555,30 @@
"examples": "Beispiele werden geladen...", "examples": "Beispiele werden geladen...",
"versions": "Versionen 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": { "versions": {
"heading": "Modellversionen", "heading": "Modellversionen",
"copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.", "copy": "Verwalten Sie alle Versionen dieses Modells an einem Ort.",
@@ -1559,8 +1606,8 @@
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version", "newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff", "earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich", "earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"paid": "[TODO: Translate] Paid", "paid": "Bezahlt",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "Diese Version erfordert eine Zahlung zum Herunterladen",
"ignored": "Ignoriert", "ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert", "ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site", "onSiteOnly": "Nur On-Site",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "Herunterladen", "download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen", "downloadTooltip": "Diese Version herunterladen",
"downloadChooseFilesTooltip": "Auswählen, welche Dateien heruntergeladen werden sollen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai 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", "downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen", "delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen", "deleteTooltip": "Diese lokale Version löschen",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt", "recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow", "recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar", "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", "noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert", "modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens", "modelFailed": "Fehler beim Aktualisieren des Modellknotens",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "{completed} von {total} LoRAs heruntergeladen", "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.", "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", "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", "versionExists": "Diese Version existiert bereits in Ihrer Bibliothek",
"downloadCompleted": "Download erfolgreich abgeschlossen", "downloadCompleted": "Download erfolgreich abgeschlossen",
"downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist", "downloadSkippedByBaseModel": "Download übersprungen, weil das Basismodell {baseModel} ausgeschlossen ist",
@@ -1950,6 +1999,8 @@
"createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen", "createMissingData": "Erforderliche Daten zum Erstellen des Rezepts fehlen",
"created": "Rezept erfolgreich erstellt", "created": "Rezept erfolgreich erstellt",
"noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen", "noMissingLoras": "Keine fehlenden LoRAs zum Herunterladen",
"noPreviousRecipe": "Kein vorheriges Rezept verfügbar",
"noNextRecipe": "Kein weiteres Rezept verfügbar",
"missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs", "missingLorasInfoFailed": "Fehler beim Abrufen der Informationen für fehlende LoRAs",
"preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download", "preparingForDownloadFailed": "Fehler beim Vorbereiten der LoRAs für den Download",
"enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein", "enterLoraName": "Bitte geben Sie einen LoRA-Namen oder Syntax ein",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})", "reimportBulkComplete": "Neuimport abgeschlossen: {completed} importiert, {failed} fehlgeschlagen (von {total})",
"reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen", "reimportBulkFailed": "Neuimport einiger Rezepte fehlgeschlagen",
"noMissingLorasInSelection": "Keine fehlenden LoRAs in ausgewählten Rezepten gefunden", "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": "Workflow an ComfyUI gesendet",
"workflowSendFailed": "Fehler beim Senden des Workflows an ComfyUI: {error}",
"workflowNoWorkflow": "Kein eingebetteter Workflow in diesem Rezept gefunden"
}, },
"models": { "models": {
"noModelsSelected": "Keine Modelle ausgewählt", "noModelsSelected": "Keine Modelle ausgewählt",
+65 -11
View File
@@ -222,6 +222,7 @@
"modelname": "Model Name", "modelname": "Model Name",
"tags": "Tags", "tags": "Tags",
"creator": "Creator", "creator": "Creator",
"hash": "Hash",
"title": "Recipe Title", "title": "Recipe Title",
"loraName": "LoRA Filename", "loraName": "LoRA Filename",
"loraModel": "LoRA Model Name", "loraModel": "LoRA Model Name",
@@ -259,7 +260,11 @@
"any": "Any", "any": "Any",
"all": "All", "all": "All",
"tagLogicAny": "Match any tag (OR)", "tagLogicAny": "Match any tag (OR)",
"tagLogicAll": "Match all tags (AND)" "tagLogicAll": "Match all tags (AND)",
"loraAvailability": "Lora Availability",
"availabilityReady": "Ready to use",
"availabilityMissing": "Has missing",
"availabilityDeleted": "Has deleted"
}, },
"theme": { "theme": {
"toggle": "Toggle theme", "toggle": "Toggle theme",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRA Recipes", "title": "LoRA Recipes",
"actions": { "actions": {
"sendCheckpoint": "Send to ComfyUI" "sendCheckpoint": "Send to ComfyUI",
"sendRecipe": "Send to ComfyUI",
"deleteRecipeWithShortcut": "Delete recipe (Del)"
},
"navigation": {
"label": "Recipe navigation",
"previousWithShortcut": "Previous recipe (\u2190)",
"nextWithShortcut": "Next recipe (\u2192)"
},
"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": { "controls": {
"import": { "import": {
"action": "Import", "action": "Import",
"title": "Import a recipe from image or URL", "title": "Import a recipe from image or URL",
"urlLocalPath": "URL / Local Path", "dropZoneLabel": "Upload image",
"uploadImage": "Upload Image", "dropZoneHint": "Drag & drop an image here, paste from clipboard, or click to browse",
"urlSectionDescription": "Input a Civitai image URL from civitai.com or civitai.red, or a local file path, to import as a recipe.", "orDivider": "or drag & drop / paste an image",
"imageUrlOrPath": "Image URL or File Path:", "imageUrlOrPath": "Image URL or File Path:",
"urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... or https://civitai.red/images/... or C:/path/to/image.png",
"fetchImage": "Fetch Image", "fetchImage": "Fetch Image",
"uploadSectionDescription": "Upload an image with LoRA metadata to import as a recipe.",
"selectImage": "Select Image",
"recipeName": "Recipe Name", "recipeName": "Recipe Name",
"recipeNamePlaceholder": "Enter recipe name", "recipeNamePlaceholder": "Enter recipe name",
"tagsOptional": "Tags (optional)", "tagsOptional": "Tags (optional)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "Please select an image file", "selectImageFile": "Please select an image file",
"enterUrlOrPath": "Please enter a URL or file path", "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" "selectLoraRoot": "Please select a LoRA root directory"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "Downloaded", "downloaded": "Downloaded",
"downloadedTooltip": "Previously downloaded, but it is not currently in your library.", "downloadedTooltip": "Previously downloaded, but it is not currently in your library.",
"alreadyInLibrary": "Already in Library", "alreadyInLibrary": "Already in Library",
"partiallyDownloaded": "Partially downloaded",
"autoOrganizedPath": "[Auto-organized by path template]", "autoOrganizedPath": "[Auto-organized by path template]",
"fileSelection": { "fileSelection": {
"title": "Select File Format", "title": "Select File Format",
"files": "files", "files": "files",
"select": "Select File" "select": "Select File",
"inLibrary": "In Library"
}, },
"errors": { "errors": {
"invalidUrl": "Invalid Civitai URL format", "invalidUrl": "Invalid Civitai URL format",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "View Creator Profile", "viewCreatorProfile": "View Creator Profile",
"openFileLocation": "Open File Location", "openFileLocation": "Open File Location",
"sendToWorkflow": "Send to ComfyUI", "sendToWorkflow": "Send to ComfyUI",
"sendToWorkflowText": "Send to ComfyUI" "sendToWorkflowText": "Send to ComfyUI",
"copyHash": "Copy hash",
"deleteModelWithShortcut": "Delete model (Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "File location opened successfully", "success": "File location opened successfully",
@@ -1441,6 +1463,7 @@
"location": "Location", "location": "Location",
"baseModel": "Base Model", "baseModel": "Base Model",
"size": "Size", "size": "Size",
"hashes": "Hashes",
"unknown": "Unknown", "unknown": "Unknown",
"usageTips": "Usage Tips", "usageTips": "Usage Tips",
"additionalNotes": "Additional Notes", "additionalNotes": "Additional Notes",
@@ -1532,6 +1555,30 @@
"examples": "Loading examples...", "examples": "Loading examples...",
"versions": "Loading versions..." "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": { "versions": {
"heading": "Model versions", "heading": "Model versions",
"copy": "Track and manage every version of this model in one place.", "copy": "Track and manage every version of this model in one place.",
@@ -1569,6 +1616,7 @@
"actions": { "actions": {
"download": "Download", "download": "Download",
"downloadTooltip": "Download this version", "downloadTooltip": "Download this version",
"downloadChooseFilesTooltip": "Choose which files to download",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai", "downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai", "downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai", "downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "Downloaded {completed} of {total} LoRAs", "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.", "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", "pleaseSelectVersion": "Please select a version",
"pleaseSelectFile": "Please select at least one file",
"versionExists": "This version already exists in your library", "versionExists": "This version already exists in your library",
"downloadCompleted": "Download completed successfully", "downloadCompleted": "Download completed successfully",
"downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded", "downloadSkippedByBaseModel": "Skipped download because base model {baseModel} is excluded",
@@ -1950,6 +1999,8 @@
"createMissingData": "Missing required data to create recipe", "createMissingData": "Missing required data to create recipe",
"created": "Recipe created successfully", "created": "Recipe created successfully",
"noMissingLoras": "No missing LoRAs to download", "noMissingLoras": "No missing LoRAs to download",
"noPreviousRecipe": "No previous recipe available",
"noNextRecipe": "No next recipe available",
"missingLorasInfoFailed": "Failed to get information for missing LoRAs", "missingLorasInfoFailed": "Failed to get information for missing LoRAs",
"preparingForDownloadFailed": "Error preparing LoRAs for download", "preparingForDownloadFailed": "Error preparing LoRAs for download",
"enterLoraName": "Please enter a LoRA name or syntax", "enterLoraName": "Please enter a LoRA name or syntax",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})", "reimportBulkComplete": "Re-import complete: {completed} re-imported, {failed} failed (of {total})",
"reimportBulkFailed": "Failed to re-import some recipes", "reimportBulkFailed": "Failed to re-import some recipes",
"noMissingLorasInSelection": "No missing LoRAs found in selected 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": { "models": {
"noModelsSelected": "No models selected", "noModelsSelected": "No models selected",
@@ -2332,4 +2386,4 @@
"retry": "Retry" "retry": "Retry"
} }
} }
} }
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "Nombre del modelo", "modelname": "Nombre del modelo",
"tags": "Etiquetas", "tags": "Etiquetas",
"creator": "Creador", "creator": "Creador",
"hash": "Hash",
"title": "Título de la receta", "title": "Título de la receta",
"loraName": "Nombre de archivo LoRA", "loraName": "Nombre de archivo LoRA",
"loraModel": "Nombre del modelo LoRA", "loraModel": "Nombre del modelo LoRA",
@@ -259,7 +260,11 @@
"any": "Cualquiera", "any": "Cualquiera",
"all": "Todos", "all": "Todos",
"tagLogicAny": "Coincidir con cualquier etiqueta (O)", "tagLogicAny": "Coincidir con cualquier etiqueta (O)",
"tagLogicAll": "Coincidir con todas las etiquetas (Y)" "tagLogicAll": "Coincidir con todas las etiquetas (Y)",
"loraAvailability": "Disponibilidad de LoRAs",
"availabilityReady": "Listos para usar",
"availabilityMissing": "Con LoRAs faltantes",
"availabilityDeleted": "Con LoRAs eliminados"
}, },
"theme": { "theme": {
"toggle": "Cambiar tema", "toggle": "Cambiar tema",
@@ -623,8 +628,8 @@
"help": "Solo actualizaciones de acceso temprano" "help": "Solo actualizaciones de acceso temprano"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "Ocultar actualizaciones de pago",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "Cuando está activado, los modelos que solo tienen actualizaciones de pago no mostrarán la insignia de 'Actualización disponible'"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados", "useNewStyle": "Usar iconos de licencia actualizados",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "Recetas de LoRA", "title": "Recetas de LoRA",
"actions": { "actions": {
"sendCheckpoint": "Enviar a ComfyUI" "sendCheckpoint": "Enviar a ComfyUI",
"sendRecipe": "Enviar a ComfyUI",
"deleteRecipeWithShortcut": "Eliminar receta (Del)"
},
"navigation": {
"label": "Navegación de recetas",
"previousWithShortcut": "Receta anterior (←)",
"nextWithShortcut": "Siguiente receta (→)"
},
"workflow": {
"sendWorkflow": "Enviar workflow a ComfyUI",
"sent": "Workflow enviado a ComfyUI",
"sendFailed": "Error al enviar el workflow a ComfyUI",
"noWorkflow": "No se encontró ningún workflow integrado en esta receta"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "Importar", "action": "Importar",
"title": "Importar una receta desde imagen o URL", "title": "Importar una receta desde imagen o URL",
"urlLocalPath": "URL / Ruta local", "dropZoneLabel": "Subir imagen",
"uploadImage": "Subir imagen", "dropZoneHint": "Arrastra y suelta una imagen aquí, pégala desde el portapapeles o haz clic para examinar",
"urlSectionDescription": "Introduce una URL de imagen de Civitai o ruta de archivo local para importar como receta.", "orDivider": "o arrastra y suelta / pega una imagen",
"imageUrlOrPath": "URL de imagen o ruta de archivo:", "imageUrlOrPath": "URL de imagen o ruta de archivo:",
"urlPlaceholder": "https://civitai.com/images/... o C:/ruta/a/imagen.png", "urlPlaceholder": "https://civitai.com/images/... o C:/ruta/a/imagen.png",
"fetchImage": "Obtener imagen", "fetchImage": "Obtener imagen",
"uploadSectionDescription": "Sube una imagen con metadatos de LoRA para importar como receta.",
"selectImage": "Seleccionar imagen",
"recipeName": "Nombre de receta", "recipeName": "Nombre de receta",
"recipeNamePlaceholder": "Introduce nombre de receta", "recipeNamePlaceholder": "Introduce nombre de receta",
"tagsOptional": "Etiquetas (opcional)", "tagsOptional": "Etiquetas (opcional)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "Por favor selecciona un archivo de imagen", "selectImageFile": "Por favor selecciona un archivo de imagen",
"enterUrlOrPath": "Por favor introduce una URL o ruta de archivo", "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" "selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "Descargado", "downloaded": "Descargado",
"downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.", "downloadedTooltip": "Descargado anteriormente, pero actualmente no está en tu biblioteca.",
"alreadyInLibrary": "Ya en la biblioteca", "alreadyInLibrary": "Ya en la biblioteca",
"partiallyDownloaded": "Descargado parcialmente",
"autoOrganizedPath": "[Auto-organizado por plantilla de ruta]", "autoOrganizedPath": "[Auto-organizado por plantilla de ruta]",
"fileSelection": { "fileSelection": {
"title": "Seleccionar formato de archivo", "title": "Seleccionar formato de archivo",
"files": "archivos", "files": "archivos",
"select": "Seleccionar archivo" "select": "Seleccionar archivo",
"inLibrary": "En la biblioteca"
}, },
"errors": { "errors": {
"invalidUrl": "Formato de URL de Civitai inválido", "invalidUrl": "Formato de URL de Civitai inválido",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "Ver perfil del creador", "viewCreatorProfile": "Ver perfil del creador",
"openFileLocation": "Abrir ubicación del archivo", "openFileLocation": "Abrir ubicación del archivo",
"sendToWorkflow": "Enviar a ComfyUI", "sendToWorkflow": "Enviar a ComfyUI",
"sendToWorkflowText": "Enviar a ComfyUI" "sendToWorkflowText": "Enviar a ComfyUI",
"copyHash": "Copiar hash",
"deleteModelWithShortcut": "Eliminar modelo (Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Ubicación del archivo abierta exitosamente", "success": "Ubicación del archivo abierta exitosamente",
@@ -1441,6 +1463,7 @@
"location": "Ubicación", "location": "Ubicación",
"baseModel": "Modelo base", "baseModel": "Modelo base",
"size": "Tamaño", "size": "Tamaño",
"hashes": "Hashes",
"unknown": "Desconocido", "unknown": "Desconocido",
"usageTips": "Consejos de uso", "usageTips": "Consejos de uso",
"additionalNotes": "Notas adicionales", "additionalNotes": "Notas adicionales",
@@ -1532,6 +1555,30 @@
"examples": "Cargando ejemplos...", "examples": "Cargando ejemplos...",
"versions": "Cargando versiones..." "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": { "versions": {
"heading": "Versiones del modelo", "heading": "Versiones del modelo",
"copy": "Administra todas las versiones de este modelo en un solo lugar.", "copy": "Administra todas las versiones de este modelo en un solo lugar.",
@@ -1559,8 +1606,8 @@
"newerTooltip": "Esta versión es más reciente que tu última versión local", "newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano", "earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai", "earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"paid": "[TODO: Translate] Paid", "paid": "De pago",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "Esta versión requiere pago para descargarse",
"ignored": "Ignorada", "ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión", "ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio", "onSiteOnly": "Solo en Sitio",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "Descargar", "download": "Descargar",
"downloadTooltip": "Descargar esta versión", "downloadTooltip": "Descargar esta versión",
"downloadChooseFilesTooltip": "Elegir qué archivos descargar",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai", "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", "downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar", "delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local", "deleteTooltip": "Eliminar esta versión local",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo", "recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo", "recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual", "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", "noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo", "modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo", "modelFailed": "Error al actualizar nodo de modelo",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "Descargados {completed} de {total} LoRAs", "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.", "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", "pleaseSelectVersion": "Por favor selecciona una versión",
"pleaseSelectFile": "Por favor selecciona al menos un archivo",
"versionExists": "Esta versión ya existe en tu biblioteca", "versionExists": "Esta versión ya existe en tu biblioteca",
"downloadCompleted": "Descarga completada exitosamente", "downloadCompleted": "Descarga completada exitosamente",
"downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido", "downloadSkippedByBaseModel": "Descarga omitida porque el modelo base {baseModel} está excluido",
@@ -1950,6 +1999,8 @@
"createMissingData": "Faltan datos necesarios para crear la receta", "createMissingData": "Faltan datos necesarios para crear la receta",
"created": "Receta creada exitosamente", "created": "Receta creada exitosamente",
"noMissingLoras": "No hay LoRAs faltantes para descargar", "noMissingLoras": "No hay LoRAs faltantes para descargar",
"noPreviousRecipe": "No hay receta anterior disponible",
"noNextRecipe": "No hay siguiente receta disponible",
"missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes", "missingLorasInfoFailed": "Error al obtener información de LoRAs faltantes",
"preparingForDownloadFailed": "Error preparando LoRAs para descarga", "preparingForDownloadFailed": "Error preparando LoRAs para descarga",
"enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis", "enterLoraName": "Por favor introduce un nombre de LoRA o sintaxis",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})", "reimportBulkComplete": "Reimportación completa: {completed} reimportadas, {failed} fallidas (de {total})",
"reimportBulkFailed": "Error al reimportar algunas recetas", "reimportBulkFailed": "Error al reimportar algunas recetas",
"noMissingLorasInSelection": "No se encontraron LoRAs faltantes en las recetas seleccionadas", "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": "Workflow enviado a ComfyUI",
"workflowSendFailed": "Error al enviar el workflow a ComfyUI: {error}",
"workflowNoWorkflow": "No se encontró ningún workflow integrado en esta receta"
}, },
"models": { "models": {
"noModelsSelected": "No hay modelos seleccionados", "noModelsSelected": "No hay modelos seleccionados",
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "Nom du modèle", "modelname": "Nom du modèle",
"tags": "Tags", "tags": "Tags",
"creator": "Créateur", "creator": "Créateur",
"hash": "Hash",
"title": "Titre de la recipe", "title": "Titre de la recipe",
"loraName": "Nom de fichier LoRA", "loraName": "Nom de fichier LoRA",
"loraModel": "Nom du modèle LoRA", "loraModel": "Nom du modèle LoRA",
@@ -259,7 +260,11 @@
"any": "N'importe quel", "any": "N'importe quel",
"all": "Tous", "all": "Tous",
"tagLogicAny": "Correspondre à n'importe quel tag (OU)", "tagLogicAny": "Correspondre à n'importe quel tag (OU)",
"tagLogicAll": "Correspondre à tous les tags (ET)" "tagLogicAll": "Correspondre à tous les tags (ET)",
"loraAvailability": "Disponibilité des LoRAs",
"availabilityReady": "Prêts à l'emploi",
"availabilityMissing": "Avec LoRAs manquants",
"availabilityDeleted": "Avec LoRAs supprimés"
}, },
"theme": { "theme": {
"toggle": "Basculer le thème", "toggle": "Basculer le thème",
@@ -623,8 +628,8 @@
"help": "Seulement les mises à jour en accès anticipé" "help": "Seulement les mises à jour en accès anticipé"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "Masquer les mises à jour payantes",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "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": { "licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour", "useNewStyle": "Utiliser les icônes de licence mises à jour",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRA Recipes", "title": "LoRA Recipes",
"actions": { "actions": {
"sendCheckpoint": "Envoyer vers ComfyUI" "sendCheckpoint": "Envoyer vers ComfyUI",
"sendRecipe": "Envoyer vers ComfyUI",
"deleteRecipeWithShortcut": "Supprimer la recette (Del)"
},
"navigation": {
"label": "Navigation des recettes",
"previousWithShortcut": "Recette précédente (←)",
"nextWithShortcut": "Recette suivante (→)"
},
"workflow": {
"sendWorkflow": "Envoyer le workflow vers ComfyUI",
"sent": "Workflow envoyé vers ComfyUI",
"sendFailed": "Échec de l'envoi du workflow vers ComfyUI",
"noWorkflow": "Aucun workflow intégré trouvé dans cette recette"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "Importer", "action": "Importer",
"title": "Importer une recipe depuis une image ou une URL", "title": "Importer une recipe depuis une image ou une URL",
"urlLocalPath": "URL / Chemin local", "dropZoneLabel": "Téléverser une image",
"uploadImage": "Téléverser une image", "dropZoneHint": "Glissez-déposez une image ici, collez-la depuis le presse-papiers ou cliquez pour parcourir",
"urlSectionDescription": "Saisissez une URL d'image Civitai ou un chemin de fichier local pour l'importer comme recipe.", "orDivider": "ou glissez-déposez / collez une image",
"imageUrlOrPath": "URL d'image ou chemin de fichier :", "imageUrlOrPath": "URL d'image ou chemin de fichier :",
"urlPlaceholder": "https://civitai.com/images/... ou C:/chemin/vers/image.png", "urlPlaceholder": "https://civitai.com/images/... ou C:/chemin/vers/image.png",
"fetchImage": "Récupérer l'image", "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", "recipeName": "Nom de la recipe",
"recipeNamePlaceholder": "Entrez le nom de la recipe", "recipeNamePlaceholder": "Entrez le nom de la recipe",
"tagsOptional": "Tags (optionnel)", "tagsOptional": "Tags (optionnel)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "Veuillez sélectionner un fichier image", "selectImageFile": "Veuillez sélectionner un fichier image",
"enterUrlOrPath": "Veuillez entrer une URL ou un chemin de fichier", "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" "selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "Téléchargé", "downloaded": "Téléchargé",
"downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.", "downloadedTooltip": "Déjà téléchargé, mais il n'est actuellement pas dans votre bibliothèque.",
"alreadyInLibrary": "Déjà dans la bibliothèque", "alreadyInLibrary": "Déjà dans la bibliothèque",
"partiallyDownloaded": "Téléchargé partiellement",
"autoOrganizedPath": "[Auto-organisé par modèle de chemin]", "autoOrganizedPath": "[Auto-organisé par modèle de chemin]",
"fileSelection": { "fileSelection": {
"title": "Choisir le format de fichier", "title": "Choisir le format de fichier",
"files": "fichiers", "files": "fichiers",
"select": "Choisir le fichier" "select": "Choisir le fichier",
"inLibrary": "Dans la bibliothèque"
}, },
"errors": { "errors": {
"invalidUrl": "Format d'URL Civitai invalide", "invalidUrl": "Format d'URL Civitai invalide",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "Voir le profil du créateur", "viewCreatorProfile": "Voir le profil du créateur",
"openFileLocation": "Ouvrir l'emplacement du fichier", "openFileLocation": "Ouvrir l'emplacement du fichier",
"sendToWorkflow": "Envoyer vers ComfyUI", "sendToWorkflow": "Envoyer vers ComfyUI",
"sendToWorkflowText": "Envoyer vers ComfyUI" "sendToWorkflowText": "Envoyer vers ComfyUI",
"copyHash": "Copier le hash",
"deleteModelWithShortcut": "Supprimer le modèle (Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Emplacement du fichier ouvert avec succès", "success": "Emplacement du fichier ouvert avec succès",
@@ -1441,6 +1463,7 @@
"location": "Emplacement", "location": "Emplacement",
"baseModel": "Modèle de base", "baseModel": "Modèle de base",
"size": "Taille", "size": "Taille",
"hashes": "Hashes",
"unknown": "Inconnu", "unknown": "Inconnu",
"usageTips": "Conseils d'utilisation", "usageTips": "Conseils d'utilisation",
"additionalNotes": "Notes supplémentaires", "additionalNotes": "Notes supplémentaires",
@@ -1532,6 +1555,30 @@
"examples": "Chargement des exemples...", "examples": "Chargement des exemples...",
"versions": "Chargement des versions..." "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": { "versions": {
"heading": "Versions du modèle", "heading": "Versions du modèle",
"copy": "Gérez toutes les versions de ce modèle en un seul endroit.", "copy": "Gérez toutes les versions de ce modèle en un seul endroit.",
@@ -1559,8 +1606,8 @@
"newerTooltip": "Cette version est plus récente que votre dernière version locale", "newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé", "earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai", "earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"paid": "[TODO: Translate] Paid", "paid": "Payant",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "Cette version nécessite un paiement pour être téléchargée",
"ignored": "Ignorée", "ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version", "ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site", "onSiteOnly": "Uniquement sur Site",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "Télécharger", "download": "Télécharger",
"downloadTooltip": "Télécharger cette version", "downloadTooltip": "Télécharger cette version",
"downloadChooseFilesTooltip": "Choisir les fichiers à télécharger",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai", "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", "downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer", "delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale", "deleteTooltip": "Supprimer cette version locale",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow", "recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow", "recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel", "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é", "noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow", "modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle", "modelFailed": "Échec de la mise à jour du nœud modèle",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "{completed} sur {total} LoRAs téléchargés", "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é.", "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", "pleaseSelectVersion": "Veuillez sélectionner une version",
"pleaseSelectFile": "Veuillez sélectionner au moins un fichier",
"versionExists": "Cette version existe déjà dans votre bibliothèque", "versionExists": "Cette version existe déjà dans votre bibliothèque",
"downloadCompleted": "Téléchargement terminé avec succès", "downloadCompleted": "Téléchargement terminé avec succès",
"downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu", "downloadSkippedByBaseModel": "Téléchargement ignoré, car le modèle de base {baseModel} est exclu",
@@ -1950,6 +1999,8 @@
"createMissingData": "Données requises manquantes pour créer le Recipe", "createMissingData": "Données requises manquantes pour créer le Recipe",
"created": "Recipe créé avec succès", "created": "Recipe créé avec succès",
"noMissingLoras": "Aucun LoRA manquant à télécharger", "noMissingLoras": "Aucun LoRA manquant à télécharger",
"noPreviousRecipe": "Aucune recette précédente",
"noNextRecipe": "Aucune recette suivante",
"missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants", "missingLorasInfoFailed": "Échec de l'obtention des informations pour les LoRAs manquants",
"preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement", "preparingForDownloadFailed": "Erreur lors de la préparation des LoRAs pour le téléchargement",
"enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA", "enterLoraName": "Veuillez entrer un nom ou une syntaxe LoRA",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})", "reimportBulkComplete": "Ré-import terminé : {completed} ré-importé(s), {failed} échec(s) (sur {total})",
"reimportBulkFailed": "Échec du ré-import de certaines recettes", "reimportBulkFailed": "Échec du ré-import de certaines recettes",
"noMissingLorasInSelection": "Aucun LoRA manquant trouvé dans les recettes sélectionnées", "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": "Workflow envoyé vers ComfyUI",
"workflowSendFailed": "Échec de l'envoi du workflow vers ComfyUI: {error}",
"workflowNoWorkflow": "Aucun workflow intégré trouvé dans cette recette"
}, },
"models": { "models": {
"noModelsSelected": "Aucun modèle sélectionné", "noModelsSelected": "Aucun modèle sélectionné",
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "שם מודל", "modelname": "שם מודל",
"tags": "תגיות", "tags": "תגיות",
"creator": "יוצר", "creator": "יוצר",
"hash": "האש",
"title": "כותרת מתכון", "title": "כותרת מתכון",
"loraName": "שם קובץ LoRA", "loraName": "שם קובץ LoRA",
"loraModel": "שם מודל LoRA", "loraModel": "שם מודל LoRA",
@@ -259,7 +260,11 @@
"any": "כלשהו", "any": "כלשהו",
"all": "כל התגים", "all": "כל התגים",
"tagLogicAny": "התאם כל תג (או)", "tagLogicAny": "התאם כל תג (או)",
"tagLogicAll": "התאם את כל התגים (וגם)" "tagLogicAll": "התאם את כל התגים (וגם)",
"loraAvailability": "זמינות LoRA",
"availabilityReady": "מוכנים לשימוש",
"availabilityMissing": "עם LoRAs חסרים",
"availabilityDeleted": "עם LoRAs שנמחקו"
}, },
"theme": { "theme": {
"toggle": "החלף ערכת נושא", "toggle": "החלף ערכת נושא",
@@ -623,8 +628,8 @@
"help": "רק עדכוני גישה מוקדמת" "help": "רק עדכוני גישה מוקדמת"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "הסתר עדכונים בתשלום",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "כשאפשרות זו מופעלת, מודלים עם עדכונים בתשלום בלבד לא יציגו את תגית 'עדכון זמין'"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים", "useNewStyle": "השתמש בסמלי רישיון מעודכנים",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "מתכוני LoRA", "title": "מתכוני LoRA",
"actions": { "actions": {
"sendCheckpoint": "שלח ל-ComfyUI" "sendCheckpoint": "שלח ל-ComfyUI",
"sendRecipe": "שלח ל-ComfyUI",
"deleteRecipeWithShortcut": "מחק מתכון (Del)"
},
"navigation": {
"label": "ניווט מתכונים",
"previousWithShortcut": "המתכון הקודם (←)",
"nextWithShortcut": "המתכון הבא (→)"
},
"workflow": {
"sendWorkflow": "שלח workflow ל-ComfyUI",
"sent": "ה-workflow נשלח ל-ComfyUI",
"sendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה",
"noWorkflow": "לא נמצא workflow מוטבע במתכון זה"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "ייבא", "action": "ייבא",
"title": "ייבא מתכון מתמונה או כתובת URL", "title": "ייבא מתכון מתמונה או כתובת URL",
"urlLocalPath": "URL / נתיב מקומי", "dropZoneLabel": "העלאת תמונה",
"uploadImage": "העלה תמונה", "dropZoneHint": "גררו ושחררו תמונה כאן, הדביקו מהלוח או לחצו לעיון",
"urlSectionDescription": "הזן כתובת URL של תמונה מ-Civitai או נתיב קובץ מקומי לייבוא כמתכון.", "orDivider": "או גררו ושחררו / הדביקו תמונה",
"imageUrlOrPath": "URL של תמונה או נתיב קובץ:", "imageUrlOrPath": "URL של תמונה או נתיב קובץ:",
"urlPlaceholder": "https://civitai.com/images/... או C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... או C:/path/to/image.png",
"fetchImage": "אחזר תמונה", "fetchImage": "אחזר תמונה",
"uploadSectionDescription": "העלה תמונה עם מטא-דאטה של LoRA לייבוא כמתכון.",
"selectImage": "בחר תמונה",
"recipeName": "שם המתכון", "recipeName": "שם המתכון",
"recipeNamePlaceholder": "הזן שם מתכון", "recipeNamePlaceholder": "הזן שם מתכון",
"tagsOptional": "תגיות (אופציונלי)", "tagsOptional": "תגיות (אופציונלי)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "אנא בחר קובץ תמונה", "selectImageFile": "אנא בחר קובץ תמונה",
"enterUrlOrPath": "אנא הזן URL או נתיב קובץ", "enterUrlOrPath": "אנא הזן URL או נתיב קובץ",
"invalidUrl": "נא להזין כתובת URL תקינה",
"invalidInputFormat": "נא להזין כתובת URL של תמונה או נתיב קובץ מקומי",
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA" "selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "הורד", "downloaded": "הורד",
"downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.", "downloadedTooltip": "הורד בעבר, אך הוא אינו נמצא כרגע בספרייה שלך.",
"alreadyInLibrary": "כבר בספרייה", "alreadyInLibrary": "כבר בספרייה",
"partiallyDownloaded": "הורד חלקית",
"autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]", "autoOrganizedPath": "[מאורגן אוטומטית לפי תבנית נתיב]",
"fileSelection": { "fileSelection": {
"title": "בחר פורמט קובץ", "title": "בחר פורמט קובץ",
"files": "קבצים", "files": "קבצים",
"select": "בחר קובץ" "select": "בחר קובץ",
"inLibrary": "בספרייה"
}, },
"errors": { "errors": {
"invalidUrl": "פורמט URL של Civitai לא חוקי", "invalidUrl": "פורמט URL של Civitai לא חוקי",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "הצג פרופיל יוצר", "viewCreatorProfile": "הצג פרופיל יוצר",
"openFileLocation": "פתח מיקום קובץ", "openFileLocation": "פתח מיקום קובץ",
"sendToWorkflow": "שלח ל-ComfyUI", "sendToWorkflow": "שלח ל-ComfyUI",
"sendToWorkflowText": "שלח ל-ComfyUI" "sendToWorkflowText": "שלח ל-ComfyUI",
"copyHash": "העתק האש",
"deleteModelWithShortcut": "מחק מודל (Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "מיקום הקובץ נפתח בהצלחה", "success": "מיקום הקובץ נפתח בהצלחה",
@@ -1441,6 +1463,7 @@
"location": "מיקום", "location": "מיקום",
"baseModel": "מודל בסיס", "baseModel": "מודל בסיס",
"size": "גודל", "size": "גודל",
"hashes": "האשים",
"unknown": "לא ידוע", "unknown": "לא ידוע",
"usageTips": "טיפים לשימוש", "usageTips": "טיפים לשימוש",
"additionalNotes": "הערות נוספות", "additionalNotes": "הערות נוספות",
@@ -1532,6 +1555,30 @@
"examples": "טוען דוגמאות...", "examples": "טוען דוגמאות...",
"versions": "טוען גרסאות..." "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": { "versions": {
"heading": "גרסאות המודל", "heading": "גרסאות המודל",
"copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.", "copy": "נהל את כל הגרסאות של המודל הזה במקום אחד.",
@@ -1559,8 +1606,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך", "newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת", "earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai", "earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "[TODO: Translate] Paid", "paid": "בתשלום",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "גרסה זו דורשת תשלום כדי להוריד",
"ignored": "התעלם", "ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו", "ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר", "onSiteOnly": "רק באתר",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "הורדה", "download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו", "downloadTooltip": "הורד את הגרסה הזו",
"downloadChooseFilesTooltip": "בחר אילו קבצים להוריד",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai", "downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "הורד את הגרסה בתשלום הזו מ-Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai", "downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה", "delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו", "deleteTooltip": "מחק את הגרסה המקומית הזו",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow", "recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה", "recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-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": "לא נבחר צומת יעד", "noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow", "modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל", "modelFailed": "עדכון צומת המודל נכשל",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs", "downloadPartialSuccess": "הורדו {completed} מתוך {total} LoRAs",
"downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.", "downloadPartialWithAccess": "הורדו {completed} מתוך {total} LoRAs. {accessFailures} נכשלו עקב הגבלות גישה. בדוק את מפתח ה-API שלך בהגדרות או את סטטוס הגישה המוקדמת.",
"pleaseSelectVersion": "אנא בחר גרסה", "pleaseSelectVersion": "אנא בחר גרסה",
"pleaseSelectFile": "אנא בחר לפחות קובץ אחד",
"versionExists": "גרסה זו כבר קיימת בספרייה שלך", "versionExists": "גרסה זו כבר קיימת בספרייה שלך",
"downloadCompleted": "ההורדה הושלמה בהצלחה", "downloadCompleted": "ההורדה הושלמה בהצלחה",
"downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג", "downloadSkippedByBaseModel": "ההורדה דולגה כי מודל הבסיס {baseModel} מוחרג",
@@ -1950,6 +1999,8 @@
"createMissingData": "חסרים נתונים נדרשים ליצירת המתכון", "createMissingData": "חסרים נתונים נדרשים ליצירת המתכון",
"created": "המתכון נוצר בהצלחה", "created": "המתכון נוצר בהצלחה",
"noMissingLoras": "אין LoRAs חסרים להורדה", "noMissingLoras": "אין LoRAs חסרים להורדה",
"noPreviousRecipe": "אין מתכון קודם זמין",
"noNextRecipe": "אין מתכון נוסף זמין",
"missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה", "missingLorasInfoFailed": "קבלת מידע עבור LoRAs חסרים נכשלה",
"preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה", "preparingForDownloadFailed": "שגיאה בהכנת LoRAs להורדה",
"enterLoraName": "אנא הזן שם LoRA או תחביר", "enterLoraName": "אנא הזן שם LoRA או תחביר",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})", "reimportBulkComplete": "ייבוא מחדש הושלם: {completed} יובאו, {failed} נכשלו (מתוך {total})",
"reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל", "reimportBulkFailed": "ייבוא מחדש של חלק מהמתכונים נכשל",
"noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו", "noMissingLorasInSelection": "לא נמצאו LoRAs חסרים במתכונים שנבחרו",
"noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות." "noLoraRootConfigured": "תיקיית השורש של LoRA לא מוגדרת. אנא הגדר תיקיית שורש LoRA ברירת מחדל בהגדרות.",
"workflowSent": "ה-workflow נשלח ל-ComfyUI",
"workflowSendFailed": "שליחת ה-workflow ל-ComfyUI נכשלה: {error}",
"workflowNoWorkflow": "לא נמצא workflow מוטבע במתכון זה"
}, },
"models": { "models": {
"noModelsSelected": "לא נבחרו מודלים", "noModelsSelected": "לא נבחרו מודלים",
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "モデル名", "modelname": "モデル名",
"tags": "タグ", "tags": "タグ",
"creator": "作成者", "creator": "作成者",
"hash": "ハッシュ",
"title": "レシピタイトル", "title": "レシピタイトル",
"loraName": "LoRAファイル名", "loraName": "LoRAファイル名",
"loraModel": "LoRAモデル名", "loraModel": "LoRAモデル名",
@@ -259,7 +260,11 @@
"any": "いずれか", "any": "いずれか",
"all": "すべて", "all": "すべて",
"tagLogicAny": "いずれかのタグに一致 (OR)", "tagLogicAny": "いずれかのタグに一致 (OR)",
"tagLogicAll": "すべてのタグに一致 (AND)" "tagLogicAll": "すべてのタグに一致 (AND)",
"loraAvailability": "LoRA の利用状況",
"availabilityReady": "使用可能",
"availabilityMissing": "不足 LoRA あり",
"availabilityDeleted": "削除済み LoRA あり"
}, },
"theme": { "theme": {
"toggle": "テーマの切り替え", "toggle": "テーマの切り替え",
@@ -623,8 +628,8 @@
"help": "早期アクセスのみの更新" "help": "早期アクセスのみの更新"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "有料更新を非表示",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "有効にすると、有料の更新のみがあるモデルには「更新あり」バッジが表示されません"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用", "useNewStyle": "更新されたライセンスアイコンを使用",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRAレシピ", "title": "LoRAレシピ",
"actions": { "actions": {
"sendCheckpoint": "ComfyUIへ送信" "sendCheckpoint": "ComfyUIへ送信",
"sendRecipe": "ComfyUIへ送信",
"deleteRecipeWithShortcut": "レシピを削除(Del"
},
"navigation": {
"label": "レシピナビゲーション",
"previousWithShortcut": "前のレシピ(←)",
"nextWithShortcut": "次のレシピ(→)"
},
"workflow": {
"sendWorkflow": "ワークフローをComfyUIへ送信",
"sent": "ワークフローをComfyUIへ送信しました",
"sendFailed": "ワークフローをComfyUIへ送信できませんでした",
"noWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "インポート", "action": "インポート",
"title": "画像またはURLからレシピをインポート", "title": "画像またはURLからレシピをインポート",
"urlLocalPath": "URL / ローカルパス", "dropZoneLabel": "画像をアップロード",
"uploadImage": "画像をアップード", "dropZoneHint": "画像をここにドラッグ&ドロップ、クリップードから貼り付け、またはクリックして参照",
"urlSectionDescription": "Civitai画像URLまたはローカルファイルパスを入力してレシピとしてインポートします。", "orDivider": "または画像をドラッグ&ドロップ / 貼り付け",
"imageUrlOrPath": "画像URLまたはファイルパス:", "imageUrlOrPath": "画像URLまたはファイルパス:",
"urlPlaceholder": "https://civitai.com/images/... または C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... または C:/path/to/image.png",
"fetchImage": "画像を取得", "fetchImage": "画像を取得",
"uploadSectionDescription": "LoRAメタデータを含む画像をアップロードしてレシピとしてインポートします。",
"selectImage": "画像を選択",
"recipeName": "レシピ名", "recipeName": "レシピ名",
"recipeNamePlaceholder": "レシピ名を入力", "recipeNamePlaceholder": "レシピ名を入力",
"tagsOptional": "タグ(任意)", "tagsOptional": "タグ(任意)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "画像ファイルを選択してください", "selectImageFile": "画像ファイルを選択してください",
"enterUrlOrPath": "URLまたはファイルパスを入力してください", "enterUrlOrPath": "URLまたはファイルパスを入力してください",
"invalidUrl": "有効なURLを入力してください",
"invalidInputFormat": "画像のURLまたはローカルの画像ファイルパスを入力してください",
"selectLoraRoot": "LoRAルートディレクトリを選択してください" "selectLoraRoot": "LoRAルートディレクトリを選択してください"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "ダウンロード済み", "downloaded": "ダウンロード済み",
"downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。", "downloadedTooltip": "以前にダウンロード済みですが、現在はライブラリにありません。",
"alreadyInLibrary": "既にライブラリ内", "alreadyInLibrary": "既にライブラリ内",
"partiallyDownloaded": "一部ダウンロード済み",
"autoOrganizedPath": "[パステンプレートによる自動整理]", "autoOrganizedPath": "[パステンプレートによる自動整理]",
"fileSelection": { "fileSelection": {
"title": "ファイル形式を選択", "title": "ファイル形式を選択",
"files": "ファイル", "files": "ファイル",
"select": "ファイルを選択" "select": "ファイルを選択",
"inLibrary": "ライブラリ内"
}, },
"errors": { "errors": {
"invalidUrl": "無効なCivitai URL形式", "invalidUrl": "無効なCivitai URL形式",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "作成者プロフィールを表示", "viewCreatorProfile": "作成者プロフィールを表示",
"openFileLocation": "ファイルの場所を開く", "openFileLocation": "ファイルの場所を開く",
"sendToWorkflow": "ComfyUI に送信", "sendToWorkflow": "ComfyUI に送信",
"sendToWorkflowText": "ComfyUI に送信" "sendToWorkflowText": "ComfyUI に送信",
"copyHash": "ハッシュをコピー",
"deleteModelWithShortcut": "モデルを削除(Del"
}, },
"openFileLocation": { "openFileLocation": {
"success": "ファイルの場所を正常に開きました", "success": "ファイルの場所を正常に開きました",
@@ -1441,6 +1463,7 @@
"location": "場所", "location": "場所",
"baseModel": "ベースモデル", "baseModel": "ベースモデル",
"size": "サイズ", "size": "サイズ",
"hashes": "ハッシュ",
"unknown": "不明", "unknown": "不明",
"usageTips": "使用のヒント", "usageTips": "使用のヒント",
"additionalNotes": "追加メモ", "additionalNotes": "追加メモ",
@@ -1532,6 +1555,30 @@
"examples": "例を読み込み中...", "examples": "例を読み込み中...",
"versions": "バージョンを読み込み中..." "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": { "versions": {
"heading": "モデルバージョン", "heading": "モデルバージョン",
"copy": "このモデルのすべてのバージョンを一か所で管理します。", "copy": "このモデルのすべてのバージョンを一か所で管理します。",
@@ -1559,8 +1606,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです", "newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス", "earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です", "earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "[TODO: Translate] Paid", "paid": "有料",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "このバージョンのダウンロードには支払いが必要です",
"ignored": "無視中", "ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です", "ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ", "onSiteOnly": "サイト内のみ",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "ダウンロード", "download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード", "downloadTooltip": "このバージョンをダウンロード",
"downloadChooseFilesTooltip": "ダウンロードするファイルを選択",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード", "downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "Civitai からこの有料バージョンをダウンロード",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません", "downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除", "delete": "削除",
"deleteTooltip": "このローカルバージョンを削除", "deleteTooltip": "このローカルバージョンを削除",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "レシピがワークフローで置換されました", "recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした", "recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません", "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": "ターゲットノードが選択されていません", "noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました", "modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました", "modelFailed": "モデルノードの更新に失敗しました",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました", "downloadPartialSuccess": "{total} LoRAのうち {completed} がダウンロードされました",
"downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。", "downloadPartialWithAccess": "{total} LoRAのうち {completed} がダウンロードされました。{accessFailures} はアクセス制限により失敗しました。設定でAPIキーまたはアーリーアクセス状況を確認してください。",
"pleaseSelectVersion": "バージョンを選択してください", "pleaseSelectVersion": "バージョンを選択してください",
"pleaseSelectFile": "ファイルを1つ以上選択してください",
"versionExists": "このバージョンは既にライブラリに存在します", "versionExists": "このバージョンは既にライブラリに存在します",
"downloadCompleted": "ダウンロードが正常に完了しました", "downloadCompleted": "ダウンロードが正常に完了しました",
"downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました", "downloadSkippedByBaseModel": "ベースモデル {baseModel} が除外されているため、ダウンロードをスキップしました",
@@ -1950,6 +1999,8 @@
"createMissingData": "レシピ作成に必要なデータが不足しています", "createMissingData": "レシピ作成に必要なデータが不足しています",
"created": "レシピを作成しました", "created": "レシピを作成しました",
"noMissingLoras": "ダウンロードする不足LoRAがありません", "noMissingLoras": "ダウンロードする不足LoRAがありません",
"noPreviousRecipe": "前のレシピがありません",
"noNextRecipe": "次のレシピがありません",
"missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました", "missingLorasInfoFailed": "不足LoRAの情報取得に失敗しました",
"preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました", "preparingForDownloadFailed": "ダウンロード用LoRAの準備中にエラーが発生しました",
"enterLoraName": "LoRA名または構文を入力してください", "enterLoraName": "LoRA名または構文を入力してください",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)", "reimportBulkComplete": "再インポート完了:{completed} 件成功、{failed} 件失敗(合計 {total} 件)",
"reimportBulkFailed": "一部のレシピの再インポートに失敗しました", "reimportBulkFailed": "一部のレシピの再インポートに失敗しました",
"noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした", "noMissingLorasInSelection": "選択したレシピに不足している LoRA が見つかりませんでした",
"noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。" "noLoraRootConfigured": "LoRA ルートディレクトリが設定されていません。設定でデフォルトの LoRA ルートを設定してください。",
"workflowSent": "ワークフローをComfyUIへ送信しました",
"workflowSendFailed": "ワークフローをComfyUIへ送信できませんでした: {error}",
"workflowNoWorkflow": "このレシピに埋め込まれたワークフローが見つかりません"
}, },
"models": { "models": {
"noModelsSelected": "モデルが選択されていません", "noModelsSelected": "モデルが選択されていません",
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "모델명", "modelname": "모델명",
"tags": "태그", "tags": "태그",
"creator": "제작자", "creator": "제작자",
"hash": "해시",
"title": "레시피 제목", "title": "레시피 제목",
"loraName": "LoRA 파일명", "loraName": "LoRA 파일명",
"loraModel": "LoRA 모델명", "loraModel": "LoRA 모델명",
@@ -259,7 +260,11 @@
"any": "아무", "any": "아무",
"all": "모두", "all": "모두",
"tagLogicAny": "모든 태그 일치 (OR)", "tagLogicAny": "모든 태그 일치 (OR)",
"tagLogicAll": "모든 태그 일치 (AND)" "tagLogicAll": "모든 태그 일치 (AND)",
"loraAvailability": "LoRA 가용성",
"availabilityReady": "바로 사용 가능",
"availabilityMissing": "누락된 LoRA 있음",
"availabilityDeleted": "삭제된 LoRA 있음"
}, },
"theme": { "theme": {
"toggle": "테마 토글", "toggle": "테마 토글",
@@ -623,8 +628,8 @@
"help": "얼리 액세스 업데이트만" "help": "얼리 액세스 업데이트만"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "유료 업데이트 숨기기",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "활성화하면 유료 업데이트만 있는 모델에 '업데이트 가능' 배지가 표시되지 않습니다"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용", "useNewStyle": "업데이트된 라이선스 아이콘 사용",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRA 레시피", "title": "LoRA 레시피",
"actions": { "actions": {
"sendCheckpoint": "ComfyUI로 보내기" "sendCheckpoint": "ComfyUI로 보내기",
"sendRecipe": "ComfyUI로 보내기",
"deleteRecipeWithShortcut": "레시피 삭제(Del)"
},
"navigation": {
"label": "레시피 탐색",
"previousWithShortcut": "이전 레시피(←)",
"nextWithShortcut": "다음 레시피(→)"
},
"workflow": {
"sendWorkflow": "워크플로를 ComfyUI로 보내기",
"sent": "워크플로를 ComfyUI로 보냈습니다",
"sendFailed": "워크플로를 ComfyUI로 보내지 못했습니다",
"noWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "가져오기", "action": "가져오기",
"title": "이미지 또는 URL에서 레시피 가져오기", "title": "이미지 또는 URL에서 레시피 가져오기",
"urlLocalPath": "URL / 로컬 경로", "dropZoneLabel": "이미지 업로드",
"uploadImage": "이미지 업로드", "dropZoneHint": "이미지를 여기에 끌어다 놓거나, 클립보드에서 붙여넣거나, 클릭하여 찾아보세요",
"urlSectionDescription": "Civitai 이미지 URL 또는 로컬 파일 경로를 입력하여 레시피로 가져옵니다.", "orDivider": "또는 이미지를 끌어다 놓기 / 붙여넣기",
"imageUrlOrPath": "이미지 URL 또는 파일 경로:", "imageUrlOrPath": "이미지 URL 또는 파일 경로:",
"urlPlaceholder": "https://civitai.com/images/... 또는 C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... 또는 C:/path/to/image.png",
"fetchImage": "이미지 가져오기", "fetchImage": "이미지 가져오기",
"uploadSectionDescription": "LoRA 메타데이터가 포함된 이미지를 업로드하여 레시피로 가져옵니다.",
"selectImage": "이미지 선택",
"recipeName": "레시피 이름", "recipeName": "레시피 이름",
"recipeNamePlaceholder": "레시피 이름을 입력하세요", "recipeNamePlaceholder": "레시피 이름을 입력하세요",
"tagsOptional": "태그 (선택사항)", "tagsOptional": "태그 (선택사항)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "이미지 파일을 선택해주세요", "selectImageFile": "이미지 파일을 선택해주세요",
"enterUrlOrPath": "URL 또는 파일 경로를 입력해주세요", "enterUrlOrPath": "URL 또는 파일 경로를 입력해주세요",
"invalidUrl": "유효한 URL을 입력하세요",
"invalidInputFormat": "이미지 URL 또는 로컬 이미지 파일 경로를 입력하세요",
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요" "selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "다운로드됨", "downloaded": "다운로드됨",
"downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.", "downloadedTooltip": "이전에 다운로드했지만 현재 라이브러리에 없습니다.",
"alreadyInLibrary": "이미 라이브러리에 있음", "alreadyInLibrary": "이미 라이브러리에 있음",
"partiallyDownloaded": "부분적으로 다운로드됨",
"autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]", "autoOrganizedPath": "[경로 템플릿으로 자동 정리됨]",
"fileSelection": { "fileSelection": {
"title": "파일 형식 선택", "title": "파일 형식 선택",
"files": "개 파일", "files": "개 파일",
"select": "파일 선택" "select": "파일 선택",
"inLibrary": "라이브러리에 있음"
}, },
"errors": { "errors": {
"invalidUrl": "잘못된 Civitai URL 형식", "invalidUrl": "잘못된 Civitai URL 형식",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "제작자 프로필 보기", "viewCreatorProfile": "제작자 프로필 보기",
"openFileLocation": "파일 위치 열기", "openFileLocation": "파일 위치 열기",
"sendToWorkflow": "ComfyUI로 보내기", "sendToWorkflow": "ComfyUI로 보내기",
"sendToWorkflowText": "ComfyUI로 보내기" "sendToWorkflowText": "ComfyUI로 보내기",
"copyHash": "해시 복사",
"deleteModelWithShortcut": "모델 삭제(Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "파일 위치가 성공적으로 열렸습니다", "success": "파일 위치가 성공적으로 열렸습니다",
@@ -1441,6 +1463,7 @@
"location": "위치", "location": "위치",
"baseModel": "베이스 모델", "baseModel": "베이스 모델",
"size": "크기", "size": "크기",
"hashes": "해시",
"unknown": "알 수 없음", "unknown": "알 수 없음",
"usageTips": "사용 팁", "usageTips": "사용 팁",
"additionalNotes": "추가 메모", "additionalNotes": "추가 메모",
@@ -1532,6 +1555,30 @@
"examples": "예시 로딩 중...", "examples": "예시 로딩 중...",
"versions": "버전 로딩 중..." "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": { "versions": {
"heading": "모델 버전", "heading": "모델 버전",
"copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.", "copy": "이 모델의 모든 버전을 한 곳에서 관리하세요.",
@@ -1559,8 +1606,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다", "newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스", "earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다", "earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "[TODO: Translate] Paid", "paid": "유료",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "이 버전은 다운로드하려면 결제가 필요합니다",
"ignored": "무시됨", "ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다", "ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용", "onSiteOnly": "사이트 내 전용",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "다운로드", "download": "다운로드",
"downloadTooltip": "이 버전 다운로드", "downloadTooltip": "이 버전 다운로드",
"downloadChooseFilesTooltip": "다운로드할 파일 선택",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드", "downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "Civitai에서 이 유료 버전 다운로드",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다", "downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제", "delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제", "deleteTooltip": "이 로컬 버전 삭제",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다", "recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다", "recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다", "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": "대상 노드가 선택되지 않았습니다", "noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다", "modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패", "modelFailed": "모델 노드 업데이트 실패",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다", "downloadPartialSuccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다",
"downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.", "downloadPartialWithAccess": "{total}개 중 {completed}개 LoRA가 다운로드되었습니다. {accessFailures}개는 액세스 제한으로 실패했습니다. 설정에서 API 키 또는 얼리 액세스 상태를 확인하세요.",
"pleaseSelectVersion": "버전을 선택해주세요", "pleaseSelectVersion": "버전을 선택해주세요",
"pleaseSelectFile": "파일을 하나 이상 선택해주세요",
"versionExists": "이 버전은 이미 라이브러리에 있습니다", "versionExists": "이 버전은 이미 라이브러리에 있습니다",
"downloadCompleted": "다운로드가 성공적으로 완료되었습니다", "downloadCompleted": "다운로드가 성공적으로 완료되었습니다",
"downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다", "downloadSkippedByBaseModel": "기본 모델 {baseModel}이(가) 제외되어 다운로드를 건너뛰었습니다",
@@ -1950,6 +1999,8 @@
"createMissingData": "레시피 생성에 필요한 데이터가 없습니다", "createMissingData": "레시피 생성에 필요한 데이터가 없습니다",
"created": "레시피가 생성되었습니다", "created": "레시피가 생성되었습니다",
"noMissingLoras": "다운로드할 누락된 LoRA가 없습니다", "noMissingLoras": "다운로드할 누락된 LoRA가 없습니다",
"noPreviousRecipe": "이전 레시피가 없습니다",
"noNextRecipe": "다음 레시피가 없습니다",
"missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다", "missingLorasInfoFailed": "누락된 LoRA 정보를 가져오는데 실패했습니다",
"preparingForDownloadFailed": "LoRA 다운로드 준비 오류", "preparingForDownloadFailed": "LoRA 다운로드 준비 오류",
"enterLoraName": "LoRA 이름 또는 문법을 입력해주세요", "enterLoraName": "LoRA 이름 또는 문법을 입력해주세요",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)", "reimportBulkComplete": "다시 가져오기 완료: {completed}개 성공, {failed}개 실패 (총 {total}개)",
"reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다", "reimportBulkFailed": "일부 레시피를 다시 가져오지 못했습니다",
"noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다", "noMissingLorasInSelection": "선택한 레시피에서 누락된 LoRA를 찾을 수 없습니다",
"noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요." "noLoraRootConfigured": "LoRA 루트 디렉토리가 구성되지 않았습니다. 설정에서 기본 LoRA 루트를 설정하세요.",
"workflowSent": "워크플로를 ComfyUI로 보냈습니다",
"workflowSendFailed": "워크플로를 ComfyUI로 보내지 못했습니다: {error}",
"workflowNoWorkflow": "이 레시피에서 임베드된 워크플로를 찾을 수 없습니다"
}, },
"models": { "models": {
"noModelsSelected": "선택된 모델이 없습니다", "noModelsSelected": "선택된 모델이 없습니다",
+70 -16
View File
@@ -222,6 +222,7 @@
"modelname": "Название модели", "modelname": "Название модели",
"tags": "Теги", "tags": "Теги",
"creator": "Автор", "creator": "Автор",
"hash": "Хэш",
"title": "Название рецепта", "title": "Название рецепта",
"loraName": "Имя файла LoRA", "loraName": "Имя файла LoRA",
"loraModel": "Название модели LoRA", "loraModel": "Название модели LoRA",
@@ -259,7 +260,11 @@
"any": "Любой", "any": "Любой",
"all": "Все", "all": "Все",
"tagLogicAny": "Совпадение с любым тегом (ИЛИ)", "tagLogicAny": "Совпадение с любым тегом (ИЛИ)",
"tagLogicAll": "Совпадение со всеми тегами (И)" "tagLogicAll": "Совпадение со всеми тегами (И)",
"loraAvailability": "Доступность LoRAs",
"availabilityReady": "Готовы к использованию",
"availabilityMissing": "Есть отсутствующие",
"availabilityDeleted": "Есть удалённые"
}, },
"theme": { "theme": {
"toggle": "Переключить тему", "toggle": "Переключить тему",
@@ -623,8 +628,8 @@
"help": "Только обновления раннего доступа" "help": "Только обновления раннего доступа"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "Скрывать платные обновления",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "Если включено, у моделей, для которых доступны только платные обновления, не будет отображаться значок «Доступно обновление»"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии", "useNewStyle": "Использовать обновлённые значки лицензии",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "Рецепты LoRA", "title": "Рецепты LoRA",
"actions": { "actions": {
"sendCheckpoint": "Отправить в ComfyUI" "sendCheckpoint": "Отправить в ComfyUI",
"sendRecipe": "Отправить в ComfyUI",
"deleteRecipeWithShortcut": "Удалить рецепт (Del)"
},
"navigation": {
"label": "Навигация по рецептам",
"previousWithShortcut": "Предыдущий рецепт (←)",
"nextWithShortcut": "Следующий рецепт (→)"
},
"workflow": {
"sendWorkflow": "Отправить workflow в ComfyUI",
"sent": "Workflow отправлен в ComfyUI",
"sendFailed": "Не удалось отправить workflow в ComfyUI",
"noWorkflow": "В этом рецепте не найден встроенный workflow"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "Импортировать", "action": "Импортировать",
"title": "Импортировать рецепт из изображения или URL", "title": "Импортировать рецепт из изображения или URL",
"urlLocalPath": "URL / Локальный путь", "dropZoneLabel": "Загрузить изображение",
"uploadImage": "Загрузить изображение", "dropZoneHint": "Перетащите изображение сюда, вставьте из буфера обмена или нажмите для выбора",
"urlSectionDescription": "Введите URL изображения Civitai или локальный путь к файлу для импорта в качестве рецепта.", "orDivider": "или перетащите / вставьте изображение",
"imageUrlOrPath": "URL изображения или путь к файлу:", "imageUrlOrPath": "URL изображения или путь к файлу:",
"urlPlaceholder": "https://civitai.com/images/... или C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... или C:/path/to/image.png",
"fetchImage": "Получить изображение", "fetchImage": "Получить изображение",
"uploadSectionDescription": "Загрузите изображение с метаданными LoRA для импорта в качестве рецепта.",
"selectImage": "Выбрать изображение",
"recipeName": "Название рецепта", "recipeName": "Название рецепта",
"recipeNamePlaceholder": "Введите название рецепта", "recipeNamePlaceholder": "Введите название рецепта",
"tagsOptional": "Теги (необязательно)", "tagsOptional": "Теги (необязательно)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "Пожалуйста, выберите файл изображения", "selectImageFile": "Пожалуйста, выберите файл изображения",
"enterUrlOrPath": "Пожалуйста, введите URL или путь к файлу", "enterUrlOrPath": "Пожалуйста, введите URL или путь к файлу",
"invalidUrl": "Введите корректный URL",
"invalidInputFormat": "Введите URL изображения или путь к локальному файлу изображения",
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA" "selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "Загружено", "downloaded": "Загружено",
"downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.", "downloadedTooltip": "Ранее загружено, но сейчас этого нет в вашей библиотеке.",
"alreadyInLibrary": "Уже в библиотеке", "alreadyInLibrary": "Уже в библиотеке",
"partiallyDownloaded": "Загружено частично",
"autoOrganizedPath": "[Автоматически организовано по шаблону пути]", "autoOrganizedPath": "[Автоматически организовано по шаблону пути]",
"fileSelection": { "fileSelection": {
"title": "Выбрать формат файла", "title": "Выбрать формат файла",
"files": "файлов", "files": "файлов",
"select": "Выбрать файл" "select": "Выбрать файл",
"inLibrary": "В библиотеке"
}, },
"errors": { "errors": {
"invalidUrl": "Неверный формат URL Civitai", "invalidUrl": "Неверный формат URL Civitai",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "Посмотреть профиль создателя", "viewCreatorProfile": "Посмотреть профиль создателя",
"openFileLocation": "Открыть расположение файла", "openFileLocation": "Открыть расположение файла",
"sendToWorkflow": "Отправить в ComfyUI", "sendToWorkflow": "Отправить в ComfyUI",
"sendToWorkflowText": "Отправить в ComfyUI" "sendToWorkflowText": "Отправить в ComfyUI",
"copyHash": "Копировать хэш",
"deleteModelWithShortcut": "Удалить модель (Del)"
}, },
"openFileLocation": { "openFileLocation": {
"success": "Расположение файла успешно открыто", "success": "Расположение файла успешно открыто",
@@ -1441,6 +1463,7 @@
"location": "Расположение", "location": "Расположение",
"baseModel": "Базовая модель", "baseModel": "Базовая модель",
"size": "Размер", "size": "Размер",
"hashes": "Хэши",
"unknown": "Неизвестно", "unknown": "Неизвестно",
"usageTips": "Советы по использованию", "usageTips": "Советы по использованию",
"additionalNotes": "Дополнительные заметки", "additionalNotes": "Дополнительные заметки",
@@ -1532,6 +1555,30 @@
"examples": "Загрузка примеров...", "examples": "Загрузка примеров...",
"versions": "Загрузка версий..." "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": { "versions": {
"heading": "Версии модели", "heading": "Версии модели",
"copy": "Управляйте всеми версиями этой модели в одном месте.", "copy": "Управляйте всеми версиями этой модели в одном месте.",
@@ -1559,8 +1606,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии", "newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ", "earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai", "earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "[TODO: Translate] Paid", "paid": "Платная",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "Скачивание этой версии платное",
"ignored": "Игнорируется", "ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены", "ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте", "onSiteOnly": "Только на Сайте",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "Скачать", "download": "Скачать",
"downloadTooltip": "Скачать эту версию", "downloadTooltip": "Скачать эту версию",
"downloadChooseFilesTooltip": "Выбрать файлы для скачивания",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai", "downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "Скачать эту платную версию с Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai", "downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить", "delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию", "deleteTooltip": "Удалить эту локальную версию",
@@ -1740,7 +1788,7 @@
"recipeReplaced": "Рецепт заменён в workflow", "recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow", "recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем 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": "Целевой узел не выбран", "noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow", "modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели", "modelFailed": "Не удалось обновить узел модели",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "Загружено {completed} из {total} LoRAs", "downloadPartialSuccess": "Загружено {completed} из {total} LoRAs",
"downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.", "downloadPartialWithAccess": "Загружено {completed} из {total} LoRAs. {accessFailures} не удалось из-за ограничений доступа. Проверьте ваш API ключ в настройках или статус раннего доступа.",
"pleaseSelectVersion": "Пожалуйста, выберите версию", "pleaseSelectVersion": "Пожалуйста, выберите версию",
"pleaseSelectFile": "Пожалуйста, выберите хотя бы один файл",
"versionExists": "Эта версия уже существует в вашей библиотеке", "versionExists": "Эта версия уже существует в вашей библиотеке",
"downloadCompleted": "Загрузка успешно завершена", "downloadCompleted": "Загрузка успешно завершена",
"downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена", "downloadSkippedByBaseModel": "Загрузка пропущена, потому что базовая модель {baseModel} исключена",
@@ -1950,6 +1999,8 @@
"createMissingData": "Отсутствуют необходимые данные для создания рецепта", "createMissingData": "Отсутствуют необходимые данные для создания рецепта",
"created": "Рецепт успешно создан", "created": "Рецепт успешно создан",
"noMissingLoras": "Нет отсутствующих LoRAs для загрузки", "noMissingLoras": "Нет отсутствующих LoRAs для загрузки",
"noPreviousRecipe": "Предыдущий рецепт отсутствует",
"noNextRecipe": "Следующий рецепт отсутствует",
"missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs", "missingLorasInfoFailed": "Не удалось получить информацию для отсутствующих LoRAs",
"preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки", "preparingForDownloadFailed": "Ошибка подготовки LoRAs для загрузки",
"enterLoraName": "Пожалуйста, введите название LoRA или синтаксис", "enterLoraName": "Пожалуйста, введите название LoRA или синтаксис",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})", "reimportBulkComplete": "Переимпорт завершён: {completed} переимпортировано, {failed} ошибок (из {total})",
"reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты", "reimportBulkFailed": "Не удалось переимпортировать некоторые рецепты",
"noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs", "noMissingLorasInSelection": "В выбранных рецептах не найдены отсутствующие LoRAs",
"noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках." "noLoraRootConfigured": "Корневой каталог LoRA не настроен. Пожалуйста, установите корневой каталог LoRA по умолчанию в настройках.",
"workflowSent": "Workflow отправлен в ComfyUI",
"workflowSendFailed": "Не удалось отправить workflow в ComfyUI: {error}",
"workflowNoWorkflow": "В этом рецепте не найден встроенный workflow"
}, },
"models": { "models": {
"noModelsSelected": "Модели не выбраны", "noModelsSelected": "Модели не выбраны",
+69 -15
View File
@@ -222,6 +222,7 @@
"modelname": "模型名称", "modelname": "模型名称",
"tags": "标签", "tags": "标签",
"creator": "创作者", "creator": "创作者",
"hash": "哈希",
"title": "配方标题", "title": "配方标题",
"loraName": "LoRA 文件名", "loraName": "LoRA 文件名",
"loraModel": "LoRA 模型名称", "loraModel": "LoRA 模型名称",
@@ -259,7 +260,11 @@
"any": "任一", "any": "任一",
"all": "全部", "all": "全部",
"tagLogicAny": "匹配任一标签 (或)", "tagLogicAny": "匹配任一标签 (或)",
"tagLogicAll": "匹配所有标签 (与)" "tagLogicAll": "匹配所有标签 (与)",
"loraAvailability": "LoRA 可用性",
"availabilityReady": "可直接使用",
"availabilityMissing": "包含缺失 LoRA",
"availabilityDeleted": "包含已删除 LoRA"
}, },
"theme": { "theme": {
"toggle": "切换主题", "toggle": "切换主题",
@@ -623,8 +628,8 @@
"help": "抢先体验更新" "help": "抢先体验更新"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "隐藏付费更新",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "启用后,仅有付费更新的模型将不显示“有可用更新”徽标"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "使用新版许可协议图标", "useNewStyle": "使用新版许可协议图标",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRA 配方", "title": "LoRA 配方",
"actions": { "actions": {
"sendCheckpoint": "发送到 ComfyUI" "sendCheckpoint": "发送到 ComfyUI",
"sendRecipe": "发送到 ComfyUI",
"deleteRecipeWithShortcut": "删除配方(Del"
},
"navigation": {
"label": "配方导航",
"previousWithShortcut": "上一个配方(←)",
"nextWithShortcut": "下一个配方(→)"
},
"workflow": {
"sendWorkflow": "发送工作流到 ComfyUI",
"sent": "工作流已发送到 ComfyUI",
"sendFailed": "发送工作流到 ComfyUI 失败",
"noWorkflow": "此配方中未找到内嵌工作流"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "导入", "action": "导入",
"title": "从图片或 URL 导入配方", "title": "从图片或 URL 导入配方",
"urlLocalPath": "URL / 本地路径", "dropZoneLabel": "上传图片",
"uploadImage": "上传图片", "dropZoneHint": "将图片拖拽到此处、从剪贴板粘贴,或点击浏览",
"urlSectionDescription": "输入来自 civitai.com 或 civitai.red 的 Civitai 图片 URL,或本地文件路径以导入为配方。", "orDivider": "或拖拽 / 粘贴图片",
"imageUrlOrPath": "图片 URL 或文件路径:", "imageUrlOrPath": "图片 URL 或文件路径:",
"urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... 或 https://civitai.red/images/... 或 C:/path/to/image.png",
"fetchImage": "获取图片", "fetchImage": "获取图片",
"uploadSectionDescription": "上传带有 LoRA 元数据的图片以导入为配方。",
"selectImage": "选择图片",
"recipeName": "配方名称", "recipeName": "配方名称",
"recipeNamePlaceholder": "输入配方名称", "recipeNamePlaceholder": "输入配方名称",
"tagsOptional": "标签(可选)", "tagsOptional": "标签(可选)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "请选择一个图像文件", "selectImageFile": "请选择一个图像文件",
"enterUrlOrPath": "请输入 URL 或文件路径", "enterUrlOrPath": "请输入 URL 或文件路径",
"invalidUrl": "请输入有效的 URL",
"invalidInputFormat": "请输入图片 URL 或本地图片文件路径",
"selectLoraRoot": "请选择 LoRA 根目录" "selectLoraRoot": "请选择 LoRA 根目录"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "已下载", "downloaded": "已下载",
"downloadedTooltip": "之前已下载,但当前不在你的库中。", "downloadedTooltip": "之前已下载,但当前不在你的库中。",
"alreadyInLibrary": "已存在于库中", "alreadyInLibrary": "已存在于库中",
"partiallyDownloaded": "部分已下载",
"autoOrganizedPath": "【已按路径模板自动整理】", "autoOrganizedPath": "【已按路径模板自动整理】",
"fileSelection": { "fileSelection": {
"title": "选择文件格式", "title": "选择文件格式",
"files": "个文件", "files": "个文件",
"select": "选择文件" "select": "选择文件",
"inLibrary": "已在库中"
}, },
"errors": { "errors": {
"invalidUrl": "无效的 Civitai URL 格式", "invalidUrl": "无效的 Civitai URL 格式",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "查看创作者主页", "viewCreatorProfile": "查看创作者主页",
"openFileLocation": "打开文件位置", "openFileLocation": "打开文件位置",
"sendToWorkflow": "发送到 ComfyUI", "sendToWorkflow": "发送到 ComfyUI",
"sendToWorkflowText": "发送到 ComfyUI" "sendToWorkflowText": "发送到 ComfyUI",
"copyHash": "复制哈希值",
"deleteModelWithShortcut": "删除模型(Del"
}, },
"openFileLocation": { "openFileLocation": {
"success": "文件位置已成功打开", "success": "文件位置已成功打开",
@@ -1441,6 +1463,7 @@
"location": "位置", "location": "位置",
"baseModel": "基础模型", "baseModel": "基础模型",
"size": "大小", "size": "大小",
"hashes": "哈希值",
"unknown": "未知", "unknown": "未知",
"usageTips": "使用提示", "usageTips": "使用提示",
"additionalNotes": "附加备注", "additionalNotes": "附加备注",
@@ -1532,6 +1555,30 @@
"examples": "正在加载示例...", "examples": "正在加载示例...",
"versions": "正在加载版本..." "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": { "versions": {
"heading": "模型版本", "heading": "模型版本",
"copy": "在一个位置管理该模型的所有版本。", "copy": "在一个位置管理该模型的所有版本。",
@@ -1559,8 +1606,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验", "earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限", "earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "[TODO: Translate] Paid", "paid": "付费",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "此版本需要付费后才能下载",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知", "ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成", "onSiteOnly": "仅站内生成",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "下载", "download": "下载",
"downloadTooltip": "下载此版本", "downloadTooltip": "下载此版本",
"downloadChooseFilesTooltip": "选择要下载的文件",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本", "downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "从 Civitai 下载此付费版本",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载", "downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除", "delete": "删除",
"deleteTooltip": "删除此本地版本", "deleteTooltip": "删除此本地版本",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA", "downloadPartialSuccess": "已下载 {completed}/{total} 个 LoRA",
"downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。", "downloadPartialWithAccess": "已下载 {completed}/{total} 个 LoRA。{accessFailures} 个因访问限制失败。请检查设置中的 API 密钥或早期访问状态。",
"pleaseSelectVersion": "请选择版本", "pleaseSelectVersion": "请选择版本",
"pleaseSelectFile": "请至少选择一个文件",
"versionExists": "该版本已存在于你的库中", "versionExists": "该版本已存在于你的库中",
"downloadCompleted": "下载成功完成", "downloadCompleted": "下载成功完成",
"downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载", "downloadSkippedByBaseModel": "由于基础模型 {baseModel} 已被排除,已跳过下载",
@@ -1950,6 +1999,8 @@
"createMissingData": "缺少创建配方所需的数据", "createMissingData": "缺少创建配方所需的数据",
"created": "配方创建成功", "created": "配方创建成功",
"noMissingLoras": "没有缺失的 LoRA 可下载", "noMissingLoras": "没有缺失的 LoRA 可下载",
"noPreviousRecipe": "没有上一个配方",
"noNextRecipe": "没有下一个配方",
"missingLorasInfoFailed": "获取缺失 LoRA 信息失败", "missingLorasInfoFailed": "获取缺失 LoRA 信息失败",
"preparingForDownloadFailed": "准备下载 LoRA 时出错", "preparingForDownloadFailed": "准备下载 LoRA 时出错",
"enterLoraName": "请输入 LoRA 名称或语法", "enterLoraName": "请输入 LoRA 名称或语法",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)", "reimportBulkComplete": "重新导入完成:{completed} 个已导入,{failed} 个失败(共 {total} 个)",
"reimportBulkFailed": "重新导入某些配方失败", "reimportBulkFailed": "重新导入某些配方失败",
"noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs", "noMissingLorasInSelection": "在选定的配方中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。" "noLoraRootConfigured": "未配置 LoRA 根目录。请在设置中设置默认的 LoRA 根目录。",
"workflowSent": "工作流已发送到 ComfyUI",
"workflowSendFailed": "发送工作流到 ComfyUI 失败: {error}",
"workflowNoWorkflow": "此配方中未找到内嵌工作流"
}, },
"models": { "models": {
"noModelsSelected": "未选中模型", "noModelsSelected": "未选中模型",
+69 -15
View File
@@ -222,6 +222,7 @@
"modelname": "模型名稱", "modelname": "模型名稱",
"tags": "標籤", "tags": "標籤",
"creator": "創作者", "creator": "創作者",
"hash": "雜湊",
"title": "配方標題", "title": "配方標題",
"loraName": "LoRA 檔案名稱", "loraName": "LoRA 檔案名稱",
"loraModel": "LoRA 模型名稱", "loraModel": "LoRA 模型名稱",
@@ -259,7 +260,11 @@
"any": "任一", "any": "任一",
"all": "全部", "all": "全部",
"tagLogicAny": "符合任一票籤 (或)", "tagLogicAny": "符合任一票籤 (或)",
"tagLogicAll": "符合所有標籤 (與)" "tagLogicAll": "符合所有標籤 (與)",
"loraAvailability": "LoRA 可用性",
"availabilityReady": "可直接使用",
"availabilityMissing": "包含缺少的 LoRA",
"availabilityDeleted": "包含已刪除的 LoRA"
}, },
"theme": { "theme": {
"toggle": "切換主題", "toggle": "切換主題",
@@ -623,8 +628,8 @@
"help": "搶先體驗更新" "help": "搶先體驗更新"
}, },
"hidePaidUpdates": { "hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates", "label": "隱藏付費更新",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge" "help": "啟用後,只有付費更新的模型將不會顯示「有可用更新」徽章"
}, },
"licenseIcons": { "licenseIcons": {
"useNewStyle": "使用新版許可協議圖標", "useNewStyle": "使用新版許可協議圖標",
@@ -853,20 +858,31 @@
"recipes": { "recipes": {
"title": "LoRA 配方", "title": "LoRA 配方",
"actions": { "actions": {
"sendCheckpoint": "傳送到 ComfyUI" "sendCheckpoint": "傳送到 ComfyUI",
"sendRecipe": "傳送到 ComfyUI",
"deleteRecipeWithShortcut": "刪除配方(Del"
},
"navigation": {
"label": "配方導覽",
"previousWithShortcut": "上一個配方(←)",
"nextWithShortcut": "下一個配方(→)"
},
"workflow": {
"sendWorkflow": "傳送工作流到 ComfyUI",
"sent": "工作流已傳送到 ComfyUI",
"sendFailed": "傳送工作流到 ComfyUI 失敗",
"noWorkflow": "此配方中未找到內嵌工作流"
}, },
"controls": { "controls": {
"import": { "import": {
"action": "匯入", "action": "匯入",
"title": "從圖片或網址匯入配方", "title": "從圖片或網址匯入配方",
"urlLocalPath": "網址 / 本機路徑", "dropZoneLabel": "上傳圖片",
"uploadImage": "上傳圖片", "dropZoneHint": "將圖片拖曳至此處、從剪貼簿貼上,或點擊瀏覽",
"urlSectionDescription": "輸入 Civitai 圖片網址或本機檔案路徑以匯入配方。", "orDivider": "或拖曳 / 貼上圖片",
"imageUrlOrPath": "圖片網址或檔案路徑:", "imageUrlOrPath": "圖片網址或檔案路徑:",
"urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png", "urlPlaceholder": "https://civitai.com/images/... 或 C:/path/to/image.png",
"fetchImage": "取得圖片", "fetchImage": "取得圖片",
"uploadSectionDescription": "上傳含 LoRA metadata 的圖片以匯入配方。",
"selectImage": "選擇圖片",
"recipeName": "配方名稱", "recipeName": "配方名稱",
"recipeNamePlaceholder": "輸入配方名稱", "recipeNamePlaceholder": "輸入配方名稱",
"tagsOptional": "標籤(選填)", "tagsOptional": "標籤(選填)",
@@ -911,6 +927,8 @@
"errors": { "errors": {
"selectImageFile": "請選擇圖片檔案", "selectImageFile": "請選擇圖片檔案",
"enterUrlOrPath": "請輸入網址或檔案路徑", "enterUrlOrPath": "請輸入網址或檔案路徑",
"invalidUrl": "請輸入有效的 URL",
"invalidInputFormat": "請輸入圖片 URL 或本機圖片檔案路徑",
"selectLoraRoot": "請選擇 LoRA 根目錄" "selectLoraRoot": "請選擇 LoRA 根目錄"
} }
}, },
@@ -1243,11 +1261,13 @@
"downloaded": "已下載", "downloaded": "已下載",
"downloadedTooltip": "先前已下載,但目前不在你的庫中。", "downloadedTooltip": "先前已下載,但目前不在你的庫中。",
"alreadyInLibrary": "已在庫存", "alreadyInLibrary": "已在庫存",
"partiallyDownloaded": "部分已下載",
"autoOrganizedPath": "[依路徑範本自動整理]", "autoOrganizedPath": "[依路徑範本自動整理]",
"fileSelection": { "fileSelection": {
"title": "選擇檔案格式", "title": "選擇檔案格式",
"files": "個檔案", "files": "個檔案",
"select": "選擇檔案" "select": "選擇檔案",
"inLibrary": "已在庫中"
}, },
"errors": { "errors": {
"invalidUrl": "Civitai 網址格式無效", "invalidUrl": "Civitai 網址格式無效",
@@ -1424,7 +1444,9 @@
"viewCreatorProfile": "查看創作者個人檔案", "viewCreatorProfile": "查看創作者個人檔案",
"openFileLocation": "開啟檔案位置", "openFileLocation": "開啟檔案位置",
"sendToWorkflow": "傳送到 ComfyUI", "sendToWorkflow": "傳送到 ComfyUI",
"sendToWorkflowText": "傳送到 ComfyUI" "sendToWorkflowText": "傳送到 ComfyUI",
"copyHash": "複製雜湊值",
"deleteModelWithShortcut": "刪除模型(Del"
}, },
"openFileLocation": { "openFileLocation": {
"success": "檔案位置已成功開啟", "success": "檔案位置已成功開啟",
@@ -1441,6 +1463,7 @@
"location": "位置", "location": "位置",
"baseModel": "基礎模型", "baseModel": "基礎模型",
"size": "大小", "size": "大小",
"hashes": "雜湊值",
"unknown": "未知", "unknown": "未知",
"usageTips": "使用提示", "usageTips": "使用提示",
"additionalNotes": "附加備註", "additionalNotes": "附加備註",
@@ -1532,6 +1555,30 @@
"examples": "載入範例中...", "examples": "載入範例中...",
"versions": "載入版本中..." "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": { "versions": {
"heading": "模型版本", "heading": "模型版本",
"copy": "在同一位置追蹤並管理此模型的所有版本。", "copy": "在同一位置追蹤並管理此模型的所有版本。",
@@ -1559,8 +1606,8 @@
"newerTooltip": "此版本比你本地的最新版本更新", "newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗", "earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限", "earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "[TODO: Translate] Paid", "paid": "付費",
"paidTooltip": "[TODO: Translate] This version requires payment to download", "paidTooltip": "此版本需要付費才能下載",
"ignored": "已忽略", "ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知", "ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成", "onSiteOnly": "僅站內生成",
@@ -1569,8 +1616,9 @@
"actions": { "actions": {
"download": "下載", "download": "下載",
"downloadTooltip": "下載此版本", "downloadTooltip": "下載此版本",
"downloadChooseFilesTooltip": "選擇要下載的檔案",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本", "downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai", "downloadPaidTooltip": "從 Civitai 下載此付費版本",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載", "downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除", "delete": "刪除",
"deleteTooltip": "刪除此本地版本", "deleteTooltip": "刪除此本地版本",
@@ -1917,6 +1965,7 @@
"downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個", "downloadPartialSuccess": "已下載 {completed} 個 LoRA,共 {total} 個",
"downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。", "downloadPartialWithAccess": "已下載 {completed} 個 LoRA,共 {total} 個。{accessFailures} 個因訪問限制而失敗。請檢查您的 API 密鑰或提前訪問狀態。",
"pleaseSelectVersion": "請選擇一個版本", "pleaseSelectVersion": "請選擇一個版本",
"pleaseSelectFile": "請至少選擇一個檔案",
"versionExists": "此版本已存在於您的庫中", "versionExists": "此版本已存在於您的庫中",
"downloadCompleted": "下載成功完成", "downloadCompleted": "下載成功完成",
"downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載", "downloadSkippedByBaseModel": "由於基礎模型 {baseModel} 已被排除,已跳過下載",
@@ -1950,6 +1999,8 @@
"createMissingData": "缺少建立配方所需的資料", "createMissingData": "缺少建立配方所需的資料",
"created": "配方建立成功", "created": "配方建立成功",
"noMissingLoras": "無缺少的 LoRA 可下載", "noMissingLoras": "無缺少的 LoRA 可下載",
"noPreviousRecipe": "沒有上一個配方",
"noNextRecipe": "沒有下一個配方",
"missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗", "missingLorasInfoFailed": "取得缺少 LoRA 資訊失敗",
"preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤", "preparingForDownloadFailed": "準備下載 LoRA 時發生錯誤",
"enterLoraName": "請輸入 LoRA 名稱或語法", "enterLoraName": "請輸入 LoRA 名稱或語法",
@@ -2002,7 +2053,10 @@
"reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)", "reimportBulkComplete": "重新匯入完成:{completed} 個已匯入,{failed} 個失敗(共 {total} 個)",
"reimportBulkFailed": "重新匯入某些配方失敗", "reimportBulkFailed": "重新匯入某些配方失敗",
"noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs", "noMissingLorasInSelection": "在選取的食譜中未找到缺失的 LoRAs",
"noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。" "noLoraRootConfigured": "未配置 LoRA 根目錄。請在設定中設定預設的 LoRA 根目錄。",
"workflowSent": "工作流已傳送到 ComfyUI",
"workflowSendFailed": "傳送工作流到 ComfyUI 失敗: {error}",
"workflowNoWorkflow": "此配方中未找到內嵌工作流"
}, },
"models": { "models": {
"noModelsSelected": "未選擇模型", "noModelsSelected": "未選擇模型",
+10
View File
@@ -46,6 +46,16 @@ async def api_json_error(
if request.path.startswith("/api/lm/previews") and exc.status == 404: if request.path.startswith("/api/lm/previews") and exc.status == 404:
logger_method = logger.debug logger_method = logger.debug
# Download-progress 404 is routine too: in-memory tracking is removed
# once a download finishes/fails, so the extension's final polls 404.
# The extension relies on the 404 status itself (failure detection),
# so only the log level is lowered.
if (
request.path.startswith("/api/lm/download-progress/")
and exc.status == 404
):
logger_method = logger.debug
logger_method( logger_method(
"API %s %s returned HTTP %d: %s", "API %s %s returned HTTP %d: %s",
request.method, request.method,
+77 -2
View File
@@ -13,6 +13,10 @@ class CheckpointLoaderLM:
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for checkpoint loading. 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)" NAME = "Checkpoint Loader (LoraManager)"
@@ -22,11 +26,29 @@ class CheckpointLoaderLM:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths) # Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = cls._get_checkpoint_names() checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return { return {
"required": { "required": {
"ckpt_name": ( "ckpt_name": (
checkpoint_names, 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}") logger.error(f"Error getting checkpoint names: {e}")
return [] 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 """Load a checkpoint by name, supporting extra folder paths
Args: Args:
ckpt_name: The name of the checkpoint to load (relative path with extension) 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: Returns:
Tuple of (MODEL, CLIP, VAE) Tuple of (MODEL, CLIP, VAE)
""" """
del base_model
# Get absolute path from cache using ComfyUI-style name # Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_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), "optional": FlexibleOptionalInputType(any_type),
} }
@@ -52,7 +53,7 @@ class CreateHookLoraLM:
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras") RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
FUNCTION = "create_hook" 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. """Create a HookGroup from the selected LoRAs, chained with prev_hooks.
Each active LoRA from the widget is loaded and wrapped in a WeightHook Each active LoRA from the widget is loaded and wrapped in a WeightHook
@@ -73,7 +74,7 @@ class CreateHookLoraLM:
all_trigger_words: list[str] = [] all_trigger_words: list[str] = []
active_loras: list[tuple[str, float, float]] = [] 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): if not lora.get("active", False):
continue continue
+6 -5
View File
@@ -49,9 +49,9 @@ def _collect_stack_entries(lora_stack):
return entries return entries
def _collect_widget_entries(kwargs): def _collect_widget_entries(loras):
entries = [] entries = []
for lora in get_loras_list(kwargs): for lora in get_loras_list({"loras": loras}):
if not lora.get("active", False): if not lora.get("active", False):
continue continue
lora_name = apply_lora_syntax_format(lora["name"]) lora_name = apply_lora_syntax_format(lora["name"])
@@ -139,6 +139,7 @@ class LoraLoaderLM:
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@@ -152,12 +153,12 @@ class LoraLoaderLM:
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras") RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
FUNCTION = "load_loras" FUNCTION = "load_loras"
def load_loras(self, model, text, **kwargs): def load_loras(self, model, text, loras, **kwargs):
"""Loads multiple LoRAs based on the kwargs input and lora_stack.""" """Loads multiple LoRAs based on the widget input and lora_stack."""
del text del text
clip = kwargs.get("clip", None) clip = kwargs.get("clip", None)
lora_entries = _collect_stack_entries(kwargs.get("lora_stack", 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) nunchaku_model_kind = detect_nunchaku_model_kind(model)
if nunchaku_model_kind == "flux": if nunchaku_model_kind == "flux":
+5 -4
View File
@@ -18,6 +18,7 @@ class LoraStackerLM:
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@@ -31,8 +32,8 @@ class LoraStackerLM:
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras") RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
FUNCTION = "stack_loras" FUNCTION = "stack_loras"
def stack_loras(self, text, **kwargs): def stack_loras(self, text, loras, **kwargs):
"""Stacks multiple LoRAs based on the kwargs input without loading them.""" """Stacks multiple LoRAs based on the widget input without loading them."""
stack = [] stack = []
active_loras = [] active_loras = []
all_trigger_words = [] all_trigger_words = []
@@ -47,8 +48,8 @@ class LoraStackerLM:
_, trigger_words = get_lora_info(lora_name) _, trigger_words = get_lora_info(lora_name)
all_trigger_words.extend(trigger_words) all_trigger_words.extend(trigger_words)
# Process loras from kwargs with support for both old and new formats # Process loras from the widget with support for both old and new formats
loras_list = get_loras_list(kwargs) loras_list = get_loras_list({"loras": loras})
for lora in loras_list: for lora in loras_list:
if not lora.get('active', False): if not lora.get('active', False):
continue continue
+8
View File
@@ -778,6 +778,14 @@ class SaveImageLM:
if checkpoint_entry: if checkpoint_entry:
recipe_data["checkpoint"] = 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( json_path = os.path.normpath(
os.path.join(recipes_dir, f"{recipe_id}.recipe.json") 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 Loads diffusion models/UNets from both standard ComfyUI folders and LoRA Manager's
extra folder paths, providing a unified interface for UNET loading. extra folder paths, providing a unified interface for UNET loading.
Supports both regular diffusion models and GGUF format models. 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)" NAME = "Unet Loader (LoraManager)"
@@ -37,16 +41,34 @@ class UNETLoaderLM:
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths) # Get list of unet names from scanner (includes extra folder paths)
unet_names = cls._get_unet_names() unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return { return {
"required": { "required": {
"unet_name": ( "unet_name": (
unet_names, 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": ( "weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"], ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."}, {"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}") logger.error(f"Error getting unet names: {e}")
return [] 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 """Load a diffusion model by name, supporting extra folder paths
Args: Args:
unet_name: The name of the diffusion model to load (relative path with extension) unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights weight_dtype: The dtype to use for model weights
base_model: Only used by the front-end to filter the random pool
Returns: Returns:
Tuple of (MODEL,) Tuple of (MODEL,)
""" """
del base_model
import torch import torch
# Get absolute path from cache using ComfyUI-style name # Get absolute path from cache using ComfyUI-style name
+4 -3
View File
@@ -31,6 +31,7 @@ class WanVideoLoraSelectLM:
"placeholder": "Search LoRAs to add...", "placeholder": "Search LoRAs to add...",
"tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation", "tooltip": "Format: <lora:lora_name:strength> separated by spaces or punctuation",
}), }),
"loras": ("LORAS", {}),
}, },
"optional": FlexibleOptionalInputType(any_type), "optional": FlexibleOptionalInputType(any_type),
} }
@@ -44,7 +45,7 @@ class WanVideoLoraSelectLM:
RETURN_NAMES = ("lora", "trigger_words", "active_loras") RETURN_NAMES = ("lora", "trigger_words", "active_loras")
FUNCTION = "process_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 = [] loras_list = []
all_trigger_words = [] all_trigger_words = []
active_loras = [] active_loras = []
@@ -62,8 +63,8 @@ class WanVideoLoraSelectLM:
selected_blocks = blocks.get("selected_blocks", {}) selected_blocks = blocks.get("selected_blocks", {})
layer_filter = blocks.get("layer_filter", "") layer_filter = blocks.get("layer_filter", "")
# Process loras from kwargs with support for both old and new formats # Process loras from the widget with support for both old and new formats
loras_from_widget = get_loras_list(kwargs) loras_from_widget = get_loras_list({"loras": loras})
for lora in loras_from_widget: for lora in loras_from_widget:
if not lora.get('active', False): if not lora.get('active', False):
continue continue
+34
View File
@@ -41,6 +41,40 @@ class RecipeMetadataParser(ABC):
""" """
pass 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 @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], 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]]: 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 checkpoint = checkpoint_entry
# If no LoRAs from Civitai resources or to supplement, extract from metadata["hashes"] def normalize_lora_name(name, basename=False):
if not loras or len(loras) == 0: normalized = str(name or '').replace('\\', '/')
# Extract lora weights from extranet tags in prompt (for later use) if normalized.casefold().endswith('.safetensors'):
lora_weights = {} normalized = normalized[:-12]
lora_matches = re.findall(self.EXTRANETS_REGEX, prompt) if basename:
for lora_type, lora_name, lora_weight in lora_matches: normalized = normalized.rsplit('/', 1)[-1]
key = f"{lora_type}:{lora_name}" return normalized.casefold()
lora_weights[key] = round(float(lora_weight), 2)
def get_version_id(lora):
# Use hashes from metadata as the primary source version_id = lora.get('id')
if metadata.get("hashes"): if version_id in (None, '', 0, '0'):
for hash_key, lora_hash in metadata.get("hashes", {}).items(): version_id = lora.get('modelVersionId')
# Only process lora or hypernet types if version_id in (None, '', 0, '0'):
if not hash_key.startswith(("lora:", "hypernet:")): 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 continue
lora_entry = populated_entry
# Skip entries without a hash value — they can't be hash_resolved = not lora_entry.get('isDeleted')
# resolved via CivitAI and would only produce a except Exception as e:
# useless "Deleted" entry in the recipe. logger.error(f"Error fetching Civitai info for LoRA {lora_name}: {e}")
if not lora_hash:
continue if hash_resolved:
merge_or_append_civitai(lora_entry, preserve_existing_weight=not prompt_entries)
lora_type, lora_name = hash_key.split(':', 1) continue
# Get weight from extranet tags if available, else default to 1.0 if recipe_scanner and lora_type == 'lora' and basename_key not in queried_local_basenames:
weight = lora_weights.get(hash_key, 1.0) local_lora = await recipe_scanner.get_local_lora(lora_name, recipe_base_model)
if local_lora:
# Initialize lora entry local_entry = self.populate_lora_from_local(lora_entry, local_lora)
lora_entry = { merge_or_append_local(local_entry)
'name': lora_name, continue
'type': lora_type, # 'lora' or 'hypernet'
'weight': weight, if lora_hash and not resource_lora_count:
'hash': lora_hash, loras.append(lora_entry)
'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)
# Try to get base model from resources or make educated guess # Try to get base model from resources or make educated guess
base_model = None base_model = None
+95 -68
View File
@@ -31,79 +31,15 @@ class ComfyMetadataParser(RecipeMetadataParser):
metadata_provider = await get_default_metadata_provider() metadata_provider = await get_default_metadata_provider()
data = json.loads(user_comment) 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_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'CheckpointLoaderSimple'}
checkpoint = None checkpoint = None
checkpoint_id = None checkpoint_id = None
checkpoint_version_id = None checkpoint_version_id = None
if checkpoint_nodes: if checkpoint_nodes:
# Get the first checkpoint node
checkpoint_node = next(iter(checkpoint_nodes.values())) checkpoint_node = next(iter(checkpoint_nodes.values()))
if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']: if 'inputs' in checkpoint_node and 'ckpt_name' in checkpoint_node['inputs']:
checkpoint_name = checkpoint_node['inputs']['ckpt_name'] checkpoint_name = checkpoint_node['inputs']['ckpt_name']
# Parse checkpoint URN
checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name) checkpoint_match = re.search(r'civitai:(\d+)@(\d+)', checkpoint_name)
if checkpoint_match: if checkpoint_match:
checkpoint_id = checkpoint_match.group(1) checkpoint_id = checkpoint_match.group(1)
@@ -115,16 +51,107 @@ class ComfyMetadataParser(RecipeMetadataParser):
'version': '', 'version': '',
'type': 'checkpoint' 'type': 'checkpoint'
} }
# Get additional checkpoint info from Civitai
if metadata_provider: if metadata_provider:
try: try:
civitai_info_tuple = await metadata_provider.get_model_version_info(checkpoint_version_id) 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) 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) checkpoint = await self.populate_checkpoint_from_civitai(checkpoint, civitai_info)
except Exception as e: except Exception as e:
logger.error(f"Error fetching Civitai info for checkpoint: {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 # Extract generation parameters
gen_params = {} gen_params = {}
+14
View File
@@ -32,6 +32,7 @@ from .handlers.recipe_handlers import (
RecipePageView, RecipePageView,
RecipeQueryHandler, RecipeQueryHandler,
RecipeSharingHandler, RecipeSharingHandler,
RecipeWorkflowHandler,
) )
from .recipe_route_registrar import ROUTE_DEFINITIONS from .recipe_route_registrar import ROUTE_DEFINITIONS
@@ -200,6 +201,18 @@ class BaseRecipeRoutes:
sharing_service=sharing_service, 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 from ..services.websocket_manager import ws_manager
batch_import_service = BatchImportService( batch_import_service = BatchImportService(
@@ -224,4 +237,5 @@ class BaseRecipeRoutes:
analysis=analysis, analysis=analysis,
sharing=sharing, sharing=sharing,
batch_import=batch_import, batch_import=batch_import,
workflow=workflow,
) )
+40
View File
@@ -1,4 +1,5 @@
import logging import logging
import os
from typing import Any, Dict, List, Set from typing import Any, Dict, List, Set
from aiohttp import web from aiohttp import web
@@ -7,6 +8,7 @@ from .model_route_registrar import ModelRouteRegistrar
from ..services.checkpoint_service import CheckpointService from ..services.checkpoint_service import CheckpointService
from ..services.service_registry import ServiceRegistry from ..services.service_registry import ServiceRegistry
from ..config import config from ..config import config
from ..utils.utils import _format_model_name_for_comfyui
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,7 +46,45 @@ class CheckpointRoutes(BaseModelRoutes):
# Checkpoint roots and Unet roots # 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}/checkpoints_roots', prefix, self.get_checkpoints_roots)
registrar.add_prefixed_route('GET', '/api/lm/{prefix}/unet_roots', prefix, self.get_unet_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: def _validate_civitai_model_type(self, model_type: str) -> bool:
"""Validate CivitAI model type for Checkpoint""" """Validate CivitAI model type for Checkpoint"""
return model_type.lower() == 'checkpoint' return model_type.lower() == 'checkpoint'
+144 -9
View File
@@ -56,6 +56,7 @@ from ...utils.constants import (
) )
from .hf_handlers import HfHandler from .hf_handlers import HfHandler
from .agent_handlers import AgentHandler from .agent_handlers import AgentHandler
from .model_handlers import ModelCivitaiHandler
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.example_images_paths import ( from ...utils.example_images_paths import (
find_non_compliant_items_in_example_images_root, find_non_compliant_items_in_example_images_root,
@@ -648,9 +649,60 @@ class NodeRegistry:
class HealthCheckHandler: class HealthCheckHandler:
def __init__(
self,
scanner_getters: Mapping[str, Callable[[], Awaitable[Any]]] | None = None,
) -> None:
self._scanner_getters = scanner_getters or {
"lora": ServiceRegistry.get_lora_scanner,
"checkpoint": ServiceRegistry.get_checkpoint_scanner,
"embedding": ServiceRegistry.get_embedding_scanner,
"recipe": ServiceRegistry.get_recipe_scanner,
}
async def health_check(self, request: web.Request) -> web.Response: async def health_check(self, request: web.Request) -> web.Response:
return web.json_response({"status": "ok"}) return web.json_response({"status": "ok"})
async def get_init_status(self, request: web.Request) -> web.Response:
"""Report aggregate scanner initialization status.
Used by the initialization page's polling fallback when the
/ws/init-progress WebSocket is unavailable. Omits pageType so every
page accepts the update and only reloads once all scanners are done.
"""
pending: list[str] = []
for name, getter in self._scanner_getters.items():
try:
scanner = await getter()
except Exception:
pending.append(name)
continue
cache_ready = getattr(scanner, "_cache", None) is not None
is_initializing = getattr(scanner, "is_initializing", None)
busy = (
is_initializing()
if callable(is_initializing)
else bool(getattr(scanner, "_is_initializing", False))
)
if busy or not cache_ready:
pending.append(name)
if pending:
return web.json_response(
{
"status": "initializing",
"stage": "processing",
"details": "Initializing: " + ", ".join(pending),
}
)
return web.json_response(
{
"status": "complete",
"progress": 100,
"details": "Initialization complete",
}
)
class SupportersHandler: class SupportersHandler:
"""Handler for supporters data.""" """Handler for supporters data."""
@@ -2061,6 +2113,63 @@ class ModelLibraryHandler:
enriched.append(entry) enriched.append(entry)
return enriched 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: async def check_model_exists(self, request: web.Request) -> web.Response:
try: try:
model_id_str = request.query.get("modelId") model_id_str = request.query.get("modelId")
@@ -2096,9 +2205,11 @@ class ModelLibraryHandler:
exists = False exists = False
model_type = None model_type = None
matched_scanner = None
if await lora_scanner.check_model_version_exists(model_version_id): if await lora_scanner.check_model_version_exists(model_version_id):
exists = True exists = True
model_type = "lora" model_type = "lora"
matched_scanner = lora_scanner
elif ( elif (
checkpoint_scanner checkpoint_scanner
and await checkpoint_scanner.check_model_version_exists( and await checkpoint_scanner.check_model_version_exists(
@@ -2107,6 +2218,7 @@ class ModelLibraryHandler:
): ):
exists = True exists = True
model_type = "checkpoint" model_type = "checkpoint"
matched_scanner = checkpoint_scanner
elif ( elif (
embedding_scanner embedding_scanner
and await embedding_scanner.check_model_version_exists( and await embedding_scanner.check_model_version_exists(
@@ -2115,6 +2227,7 @@ class ModelLibraryHandler:
): ):
exists = True exists = True
model_type = "embedding" model_type = "embedding"
matched_scanner = embedding_scanner
if exists: if exists:
return web.json_response( return web.json_response(
@@ -2123,6 +2236,9 @@ class ModelLibraryHandler:
"exists": True, "exists": True,
"modelType": model_type, "modelType": model_type,
"hasBeenDownloaded": False, "hasBeenDownloaded": False,
"downloadedFiles": await self._get_downloaded_files(
matched_scanner, model_version_id
),
} }
) )
@@ -2144,6 +2260,7 @@ class ModelLibraryHandler:
"exists": False, "exists": False,
"modelType": history_type, "modelType": history_type,
"hasBeenDownloaded": has_been_downloaded, "hasBeenDownloaded": has_been_downloaded,
"downloadedFiles": [],
} }
) )
@@ -2428,8 +2545,8 @@ class ModelLibraryHandler:
embedding_scanner = await self._service_registry.get_embedding_scanner() embedding_scanner = await self._service_registry.get_embedding_scanner()
found_type = None found_type = None
file_path = None
found_cache = None found_cache = None
entries: list = []
for model_type, scanner in ( for model_type, scanner in (
("lora", lora_scanner), ("lora", lora_scanner),
@@ -2440,27 +2557,43 @@ class ModelLibraryHandler:
if cache and model_version_id in cache.version_index: if cache and model_version_id in cache.version_index:
found_type = model_type found_type = model_type
found_cache = cache found_cache = cache
entry = cache.version_index[model_version_id] # A version can have several local files (#1058); collect
file_path = entry.get("file_path") # 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 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( return web.json_response(
{"success": False, "error": "Model version not found in any scanner cache"}, {"success": False, "error": "Model version not found in any scanner cache"},
status=404, status=404,
) )
target_dir = os.path.dirname(file_path) for file_path in file_paths:
base_name = os.path.basename(file_path) target_dir = os.path.dirname(file_path)
file_name, extension = os.path.splitext(base_name) base_name = os.path.basename(file_path)
await delete_model_artifacts(target_dir, file_name, main_extension=extension) file_name, extension = os.path.splitext(base_name)
await delete_model_artifacts(target_dir, file_name, main_extension=extension)
if found_cache: if found_cache:
removed_paths = set(file_paths)
found_cache.raw_data = [ found_cache.raw_data = [
item item
for item in found_cache.raw_data 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() await found_cache.resort()
scanner_map = { scanner_map = {
@@ -2483,6 +2616,7 @@ class ModelLibraryHandler:
"success": True, "success": True,
"modelType": found_type, "modelType": found_type,
"modelVersionId": model_version_id, "modelVersionId": model_version_id,
"deletedFiles": len(file_paths),
} }
) )
except Exception as exc: except Exception as exc:
@@ -3776,6 +3910,7 @@ class MiscHandlerSet:
) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]: ) -> Mapping[str, Callable[[web.Request], Awaitable[web.StreamResponse]]]:
return { return {
"health_check": self.health.health_check, "health_check": self.health.health_check,
"get_init_status": self.health.get_init_status,
"get_settings": self.settings.get_settings, "get_settings": self.settings.get_settings,
"update_settings": self.settings.update_settings, "update_settings": self.settings.update_settings,
"get_doctor_diagnostics": self.doctor.get_doctor_diagnostics, "get_doctor_diagnostics": self.doctor.get_doctor_diagnostics,
+153 -14
View File
@@ -364,6 +364,7 @@ class ModelListingHandler:
== "true", == "true",
"tags": request.query.get("search_tags", "false").lower() == "true", "tags": request.query.get("search_tags", "false").lower() == "true",
"creator": request.query.get("search_creator", "false").lower() == "true", "creator": request.query.get("search_creator", "false").lower() == "true",
"hash": request.query.get("search_hash", "false").lower() == "true",
"recursive": request.query.get("recursive", "true").lower() == "true", "recursive": request.query.get("recursive", "true").lower() == "true",
} }
@@ -1029,6 +1030,11 @@ class ModelQueryHandler:
self._service = service self._service = service
self._logger = logger self._logger = logger
@staticmethod
def _parse_include_empty(request: web.Request) -> bool:
"""Parse the include_empty query flag (``1``/``true``)."""
return request.query.get("include_empty", "").lower() in ("1", "true")
async def get_top_tags(self, request: web.Request) -> web.Response: async def get_top_tags(self, request: web.Request) -> web.Response:
try: try:
limit = int(request.query.get("limit", "20")) limit = int(request.query.get("limit", "20"))
@@ -1123,8 +1129,14 @@ class ModelQueryHandler:
async def get_folders(self, request: web.Request) -> web.Response: async def get_folders(self, request: web.Request) -> web.Response:
try: try:
cache = await self._service.scanner.get_cached_data() include_empty = self._parse_include_empty(request)
return web.json_response({"folders": cache.folders}) if include_empty:
# Live enumeration includes empty OS-created directories.
folders = await self._service.scanner.get_all_folders()
else:
cache = await self._service.scanner.get_cached_data()
folders = cache.folders
return web.json_response({"folders": folders})
except Exception as exc: except Exception as exc:
self._logger.error("Error getting folders: %s", exc) self._logger.error("Error getting folders: %s", exc)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
@@ -1149,7 +1161,9 @@ class ModelQueryHandler:
{"success": False, "error": "model_root parameter is required"}, {"success": False, "error": "model_root parameter is required"},
status=400, status=400,
) )
folder_tree = await self._service.get_folder_tree(model_root) folder_tree = await self._service.get_folder_tree(
model_root, include_empty=self._parse_include_empty(request)
)
return web.json_response({"success": True, "tree": folder_tree}) return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc: except Exception as exc:
self._logger.error("Error getting folder tree: %s", exc) self._logger.error("Error getting folder tree: %s", exc)
@@ -1157,7 +1171,9 @@ class ModelQueryHandler:
async def get_unified_folder_tree(self, request: web.Request) -> web.Response: async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
try: try:
unified_tree = await self._service.get_unified_folder_tree() unified_tree = await self._service.get_unified_folder_tree(
include_empty=self._parse_include_empty(request)
)
return web.json_response({"success": True, "tree": unified_tree}) return web.json_response({"success": True, "tree": unified_tree})
except Exception as exc: except Exception as exc:
self._logger.error("Error getting unified folder tree: %s", exc) self._logger.error("Error getting unified folder tree: %s", exc)
@@ -1659,7 +1675,8 @@ class ModelDownloadHandler:
import json import json
try: 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: except json.JSONDecodeError:
self._logger.warning( self._logger.warning(
"Invalid file_params JSON: %s", file_params_json "Invalid file_params JSON: %s", file_params_json
@@ -1811,7 +1828,8 @@ class ModelDownloadHandler:
model_id = int(model_id_str) if model_id_str else None 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 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() service = await DownloadQueueService.get_instance()
item = await service.add_to_queue( item = await service.add_to_queue(
@@ -1886,8 +1904,18 @@ class ModelDownloadHandler:
try: try:
status_filter = request.query.get("status") or None status_filter = request.query.get("status") or None
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
cleared = await service.clear_queue(status_filter=status_filter) cleared_ids = await service.clear_queue(status_filter=status_filter)
return web.json_response({"success": True, "cleared": cleared}) # Clearing the queue rows alone would orphan any in-memory tasks
# and persisted aria2 state for those downloads, leaving them
# polling the daemon invisibly. Tear that tracking down too.
try:
await self._download_coordinator.discard_cleared_downloads(cleared_ids)
except Exception:
self._logger.warning(
"Failed to discard in-memory state for cleared downloads",
exc_info=True,
)
return web.json_response({"success": True, "cleared": len(cleared_ids)})
except Exception as exc: except Exception as exc:
self._logger.error( self._logger.error(
"Error clearing download queue: %s", exc, exc_info=True "Error clearing download queue: %s", exc, exc_info=True
@@ -1970,9 +1998,11 @@ class ModelDownloadHandler:
item_id=item_id, download_id=download_id item_id=item_id, download_id=download_id
) )
if item is None: if item is None:
# Missing or non-retryable history entry is a business
# outcome, not a routing error: 200 lets the extension's
# apiFetch 404-fallback and error middleware stay quiet.
return web.json_response( return web.json_response(
{"success": False, "error": "History item not found or not retryable"}, {"success": False, "error": "History item not found or not retryable"}
status=404,
) )
return web.json_response({"success": True, "item": item}) return web.json_response({"success": True, "item": item})
except Exception as exc: except Exception as exc:
@@ -2023,8 +2053,12 @@ class ModelDownloadHandler:
completed_at=completed_at, completed_at=completed_at,
) )
if item is None: if item is None:
# A missing queue item (already completed, or never queued) is
# a normal business outcome, not a routing error. Return 200
# so the browser extension's apiFetch 404-fallback and the
# error middleware stay quiet.
return web.json_response( return web.json_response(
{"success": False, "error": "Download not found in queue"}, status=404 {"success": False, "error": "Download not found in queue"}
) )
return web.json_response({"success": True, "item": item}) return web.json_response({"success": True, "item": item})
except Exception as exc: except Exception as exc:
@@ -2066,9 +2100,10 @@ class ModelDownloadHandler:
service = await DownloadQueueService.get_instance() service = await DownloadQueueService.get_instance()
updated = await service.update_status(download_id, status) updated = await service.update_status(download_id, status)
if not updated: if not updated:
# Same rationale as complete_download_in_queue: a missing
# queue item is a business outcome, not a routing error.
return web.json_response( return web.json_response(
{"success": False, "error": "Download not found in queue"}, {"success": False, "error": "Download not found in queue"}
status=404,
) )
return web.json_response({"success": True}) return web.json_response({"success": True})
except Exception as exc: except Exception as exc:
@@ -2187,6 +2222,19 @@ class ModelCivitaiHandler:
else: else:
version.pop("localPath", None) 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 = ( model_file = (
self._find_model_file(version.get("files", [])) self._find_model_file(version.get("files", []))
if isinstance(version.get("files"), Iterable) if isinstance(version.get("files"), Iterable)
@@ -2201,6 +2249,64 @@ class ModelCivitaiHandler:
) )
return web.Response(status=500, text=str(exc)) 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: async def get_civitai_model_by_version(self, request: web.Request) -> web.Response:
try: try:
model_version_id = request.match_info.get("modelVersionId") model_version_id = request.match_info.get("modelVersionId")
@@ -2548,10 +2654,20 @@ class ModelUpdateHandler:
except Exception: except Exception:
pass pass
same_base_scope = self._uses_same_base_update_scope()
serialized_records = [] serialized_records = []
for record in records.values(): for record in records.values():
has_update_fn = getattr(record, "has_update", None) has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn( if not callable(has_update_fn):
continue
scoped_fn = (
getattr(record, "has_update_for_local_bases", None)
if same_base_scope
else None
)
qualifies_fn = scoped_fn if callable(scoped_fn) else has_update_fn
if qualifies_fn(
hide_early_access=hide_early_access, hide_early_access=hide_early_access,
hide_paid=hide_paid, hide_paid=hide_paid,
): ):
@@ -2564,6 +2680,26 @@ class ModelUpdateHandler:
} }
) )
def _uses_same_base_update_scope(self) -> bool:
"""Return True when update reporting must honor same-base scoping.
Mirrors ``BaseModelService._annotate_update_flags``: the Updates filter
evaluates updates per local base model when ``version_grouping`` is
``same_base`` (its default). The refresh summary counts with the same
scope so the "Found N update(s)" toast matches what the filter
displays. See issue #1083.
"""
if self._settings is None:
return True
try:
strategy_value = self._settings.get("version_grouping")
except Exception:
return True
if isinstance(strategy_value, str) and strategy_value.strip():
return strategy_value.strip().lower() == "same_base"
return True
async def set_model_update_ignore(self, request: web.Request) -> web.Response: async def set_model_update_ignore(self, request: web.Request) -> web.Response:
payload = await self._read_json(request) payload = await self._read_json(request)
model_id = self._normalize_model_id(payload.get("modelId")) model_id = self._normalize_model_id(payload.get("modelId"))
@@ -3031,6 +3167,9 @@ class ModelUpdateHandler:
"paidAccess": paid_access_payload, "paidAccess": paid_access_payload,
"filePath": context.get("file_path"), "filePath": context.get("file_path"),
"fileName": context.get("file_name"), "fileName": context.get("file_name"),
# Weight-file variant count (None when unknown); lets the UI hide
# the download affordance for single-file in-library versions.
"fileCount": getattr(version, "file_count", None),
} }
async def _build_version_context( async def _build_version_context(
+117 -3
View File
@@ -10,7 +10,7 @@ import asyncio
import tempfile import tempfile
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path 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 from aiohttp import web
@@ -45,6 +45,17 @@ EnsureDependenciesCallable = Callable[[], Awaitable[None]]
RecipeScannerGetter = Callable[[], Any] RecipeScannerGetter = Callable[[], Any]
CivitaiClientGetter = 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 # 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 # cache one page can touch up to page_size image files; 16 balances SSD and
# HDD throughput without starving the event loop. # HDD throughput without starving the event loop.
@@ -73,6 +84,7 @@ class RecipeHandlerSet:
analysis: "RecipeAnalysisHandler" analysis: "RecipeAnalysisHandler"
sharing: "RecipeSharingHandler" sharing: "RecipeSharingHandler"
batch_import: "BatchImportHandler" batch_import: "BatchImportHandler"
workflow: "RecipeWorkflowHandler"
def to_route_mapping( def to_route_mapping(
self, self,
@@ -128,6 +140,7 @@ class RecipeHandlerSet:
"import_from_url": self.management.import_from_url, "import_from_url": self.management.import_from_url,
"create_from_example": self.management.create_from_example, "create_from_example": self.management.create_from_example,
"reimport_recipe": self.management.reimport_recipe, "reimport_recipe": self.management.reimport_recipe,
"send_recipe_workflow": self.workflow.send_recipe_workflow,
} }
@@ -163,11 +176,19 @@ class RecipePageView:
user_language = self._settings.get("language", "en") user_language = self._settings.get("language", "en")
self._server_i18n.set_locale(user_language) self._server_i18n.set_locale(user_language)
# While the initial scan is running, show the initialization
# screen (same as the model pages) instead of an empty grid; the
# page reloads itself when the scanner broadcasts completion.
is_initializing = (
recipe_scanner._cache is None or recipe_scanner.is_initializing()
)
try: try:
await recipe_scanner.get_cached_data(force_refresh=False) if not is_initializing:
await recipe_scanner.get_cached_data(force_refresh=False)
rendered = self._template_env.get_template(self._template_name).render( rendered = self._template_env.get_template(self._template_name).render(
recipes=[], recipes=[],
is_initializing=False, is_initializing=is_initializing,
settings=self._settings, settings=self._settings,
request=request, request=request,
t=self._server_i18n.get_translation, t=self._server_i18n.get_translation,
@@ -253,6 +274,14 @@ class RecipeListingHandler:
if tag_filters: if tag_filters:
filters["tags"] = tag_filters filters["tags"] = tag_filters
lora_availability = {
status.strip()
for status in request.query.get("lora_availability", "").split(",")
if status.strip() in ("ready", "missing", "deleted")
}
if lora_availability:
filters["lora_availability"] = lora_availability
lora_hash = request.query.get("lora_hash") lora_hash = request.query.get("lora_hash")
checkpoint_hash = request.query.get("checkpoint_hash") checkpoint_hash = request.query.get("checkpoint_hash")
@@ -2755,6 +2784,91 @@ class RecipeSharingHandler:
return web.json_response({"error": str(exc)}, status=500) 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: class BatchImportHandler:
"""Handle batch import operations for recipes.""" """Handle batch import operations for recipes."""
+1
View File
@@ -32,6 +32,7 @@ MISC_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"), RouteDefinition("GET", "/api/lm/settings/libraries", "get_settings_libraries"),
RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"), RouteDefinition("POST", "/api/lm/settings/libraries/activate", "activate_library"),
RouteDefinition("GET", "/api/lm/health-check", "health_check"), RouteDefinition("GET", "/api/lm/health-check", "health_check"),
RouteDefinition("GET", "/api/lm/init-status", "get_init_status"),
RouteDefinition("GET", "/api/lm/supporters", "get_supporters"), RouteDefinition("GET", "/api/lm/supporters", "get_supporters"),
RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"), RouteDefinition("GET", "/api/lm/wildcards/search", "search_wildcards"),
RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"), RouteDefinition("POST", "/api/lm/wildcards/open-location", "open_wildcards_location"),
+3
View File
@@ -90,6 +90,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe" "POST", "/api/lm/recipe/{recipe_id}/reimport", "reimport_recipe"
), ),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/send-workflow", "send_recipe_workflow"
),
) )
+43 -9
View File
@@ -217,8 +217,9 @@ class Aria2Downloader:
"""Call get_status with retry for transient RPC failures. """Call get_status with retry for transient RPC failures.
Only retries on :exc:`Aria2Error` (RPC-level failure). Returns Only retries on :exc:`Aria2Error` (RPC-level failure). Returns
``None`` immediately when the download_id is not tracked (a missing ``None`` immediately when the transfer is not tracked or its GID is
transfer is not a transient condition, so retrying is pointless). gone from the daemon (a missing transfer is not a transient
condition, so retrying is pointless).
A single failed RPC call should not immediately fail the download, A single failed RPC call should not immediately fail the download,
because aria2 may be temporarily busy (e.g. finalizing multiple because aria2 may be temporarily busy (e.g. finalizing multiple
@@ -332,7 +333,13 @@ class Aria2Downloader:
return transfer return transfer
async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]: async def get_status(self, download_id: str) -> Optional[Dict[str, Any]]:
"""Return the raw aria2 status payload for a known download.""" """Return the raw aria2 status payload for a known download.
Returns ``None`` when the download_id is not tracked or the daemon no
longer knows the transfer's GID (daemon restart / forceRemove). A
forgotten GID is permanent, not transient, so the caller's recovery
path handles it instead of burning retry attempts on a dead GID.
"""
transfer = self._transfers.get(download_id) transfer = self._transfers.get(download_id)
if transfer is None: if transfer is None:
@@ -348,8 +355,17 @@ class Aria2Downloader:
"files", "files",
] ]
try: try:
status = await self._rpc_call("aria2.tellStatus", [transfer.gid, keys]) status = await self._rpc_call(
"aria2.tellStatus", [transfer.gid, keys], log_errors=False
)
except Exception as exc: except Exception as exc:
if "not found" in str(exc).lower():
logger.debug(
"aria2 GID %s for download %s is gone; treating as lost transfer",
transfer.gid,
download_id,
)
return None
raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc raise Aria2Error(f"Failed to query aria2 download status: {exc}") from exc
if isinstance(status, dict): if isinstance(status, dict):
@@ -367,7 +383,9 @@ class Aria2Downloader:
"files", "files",
] ]
try: try:
status = await self._rpc_call("aria2.tellStatus", [gid, keys]) status = await self._rpc_call(
"aria2.tellStatus", [gid, keys], log_errors=False
)
except Exception as exc: except Exception as exc:
message = str(exc) message = str(exc)
if "cannot be found" in message.lower() or "not found" in message.lower(): if "cannot be found" in message.lower() or "not found" in message.lower():
@@ -434,8 +452,19 @@ class Aria2Downloader:
try: try:
await self._rpc_call("aria2.forceRemove", [transfer.gid]) await self._rpc_call("aria2.forceRemove", [transfer.gid])
except Exception as exc: except Exception as exc:
return {"success": False, "error": str(exc)} if "not found" not in str(exc).lower():
return {"success": False, "error": str(exc)}
# The daemon already forgot this GID (restart / prior removal),
# so the transfer is effectively cancelled.
logger.debug(
"aria2 GID %s for download %s already gone during cancel",
transfer.gid,
download_id,
)
# Drop the in-memory entry as well so a concurrent poll loop does
# not mistake the removal for a lost transfer and re-register it.
self._transfers.pop(download_id, None)
await self._state_store.remove(download_id) await self._state_store.remove(download_id)
return {"success": True, "message": "Download cancelled successfully"} return {"success": True, "message": "Download cancelled successfully"}
@@ -725,7 +754,9 @@ class Aria2Downloader:
return isinstance(result, dict) return isinstance(result, dict)
async def _rpc_call(self, method: str, params: list[Any]) -> Any: async def _rpc_call(
self, method: str, params: list[Any], *, log_errors: bool = True
) -> Any:
if not self._rpc_url: if not self._rpc_url:
raise Aria2Error("aria2 RPC endpoint is not initialized") raise Aria2Error("aria2 RPC endpoint is not initialized")
@@ -756,7 +787,10 @@ class Aria2Downloader:
error = body["error"] or {} error = body["error"] or {}
code = error.get("code") if isinstance(error, dict) else None code = error.get("code") if isinstance(error, dict) else None
message = error.get("message") if isinstance(error, dict) else str(error) message = error.get("message") if isinstance(error, dict) else str(error)
logger.error( # Probing calls (e.g. tellStatus for a GID the daemon may have
# forgotten) pass log_errors=False: an expected "not found" must
# not spam the log at ERROR level.
(logger.error if log_errors else logger.debug)(
"aria2 RPC %s failed with HTTP %s, code=%s, message=%s", "aria2 RPC %s failed with HTTP %s, code=%s, message=%s",
method, method,
response.status, response.status,
@@ -771,7 +805,7 @@ class Aria2Downloader:
raise Aria2Error(status_message or "Unknown aria2 RPC error") raise Aria2Error(status_message or "Unknown aria2 RPC error")
if response.status != 200: if response.status != 200:
logger.error( (logger.error if log_errors else logger.debug)(
"aria2 RPC %s returned unexpected HTTP status %s without error payload: %s", "aria2 RPC %s returned unexpected HTTP status %s without error payload: %s",
method, method,
response.status, response.status,
+15 -4
View File
@@ -972,14 +972,25 @@ class BaseModelService(ABC):
) )
return {k: data[k] for k in fields if k in data} return {k: data[k] for k in fields if k in data}
async def get_folder_tree(self, model_root: str) -> Dict[str, Any]: async def _get_tree_folders(self, cache, include_empty: bool) -> List[str]:
"""Return the folder list backing folder tree responses.
With ``include_empty`` the directories are enumerated live from the
filesystem (including empty ones) via the scanner; otherwise the
models-only ``cache.folders`` list is used unchanged.
"""
if include_empty:
return await self.scanner.get_all_folders()
return cache.folders
async def get_folder_tree(self, model_root: str, include_empty: bool = False) -> Dict[str, Any]:
"""Get hierarchical folder tree for a specific model root""" """Get hierarchical folder tree for a specific model root"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
# Build tree structure from folders # Build tree structure from folders
tree = {} tree = {}
for folder in cache.folders: for folder in await self._get_tree_folders(cache, include_empty):
# Check if this folder belongs to the specified model root # Check if this folder belongs to the specified model root
folder_belongs_to_root = False folder_belongs_to_root = False
for root in self.scanner.get_model_roots(): for root in self.scanner.get_model_roots():
@@ -1001,7 +1012,7 @@ class BaseModelService(ABC):
return tree return tree
async def get_unified_folder_tree(self) -> Dict[str, Any]: async def get_unified_folder_tree(self, include_empty: bool = False) -> Dict[str, Any]:
"""Get unified folder tree across all model roots""" """Get unified folder tree across all model roots"""
cache = await self.scanner.get_cached_data() cache = await self.scanner.get_cached_data()
@@ -1011,7 +1022,7 @@ class BaseModelService(ABC):
# Get all model roots for path normalization # Get all model roots for path normalization
model_roots = self.scanner.get_model_roots() model_roots = self.scanner.get_model_roots()
for folder in cache.folders: for folder in await self._get_tree_folders(cache, include_empty):
if not folder: # Skip empty folders if not folder: # Skip empty folders
continue continue
+1
View File
@@ -51,6 +51,7 @@ class CheckpointService(BaseModelService):
"base_model": model_data.get("base_model", ""), "base_model": model_data.get("base_model", ""),
"folder": folder, "folder": folder,
"sha256": model_data.get("sha256", ""), "sha256": model_data.get("sha256", ""),
"autov3": model_data.get("autov3"),
"file_path": file_path.replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": model_data.get("size", 0), "file_size": model_data.get("size", 0),
"modified": model_data.get("modified", ""), "modified": model_data.get("modified", ""),
+12 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any, Awaitable, Callable, Dict, Optional from typing import Any, Awaitable, Callable, Dict, Iterable, Optional
from .downloader import DownloadProgress from .downloader import DownloadProgress
@@ -87,7 +87,9 @@ class DownloadCoordinator:
progress_callback=progress_callback, progress_callback=progress_callback,
download_id=download_id, download_id=download_id,
source=payload.get("source"), 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 result["download_id"] = download_id
@@ -184,6 +186,14 @@ class DownloadCoordinator:
download_manager = await self._download_manager_factory() download_manager = await self._download_manager_factory()
return await download_manager.get_active_downloads() return await download_manager.get_active_downloads()
async def discard_cleared_downloads(self, download_ids: Iterable[str]) -> int:
"""Tear down in-memory/aria2 tracking for queue-cleared downloads."""
if not download_ids:
return 0
download_manager = await self._download_manager_factory()
return await download_manager.discard_cleared_downloads(download_ids)
def _parse_optional_int(self, value: Any, field: str) -> Optional[int]: def _parse_optional_int(self, value: Any, field: str) -> Optional[int]:
"""Parse an optional integer from user input.""" """Parse an optional integer from user input."""
+301 -70
View File
@@ -13,7 +13,7 @@ import zipfile
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from collections import OrderedDict from collections import OrderedDict
import uuid import uuid
from typing import Any, Dict, List, Optional, Set, Tuple, cast from typing import Any, Dict, Iterable, List, Optional, Set, Tuple, cast
from urllib.parse import urlparse from urllib.parse import urlparse
from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import ( from ..utils.constants import (
@@ -213,6 +213,162 @@ class DownloadManager:
) )
return False 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( async def download_from_civitai(
self, self,
model_id: int | None = None, model_id: int | None = None,
@@ -242,6 +398,10 @@ class DownloadManager:
Returns: Returns:
Dict with download result 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( logger.debug(
"[download] download_from_civitai called: model_id=%s, model_version_id=%s, " "[download] download_from_civitai called: model_id=%s, model_version_id=%s, "
"source=%s, file_params=%s", "source=%s, file_params=%s",
@@ -816,6 +976,7 @@ class DownloadManager:
version_info, version_info,
record.get("model_version_id"), record.get("model_version_id"),
record.get("save_path") or record.get("file_path"), record.get("save_path") or record.get("file_path"),
file_info=file_info,
) )
await self._sync_downloaded_version( await self._sync_downloaded_version(
model_type, model_type,
@@ -939,6 +1100,11 @@ class DownloadManager:
save_path = self._resolve_save_path_from_persisted_record(record) save_path = self._resolve_save_path_from_persisted_record(record)
if save_path is None: if save_path is None:
# No resolvable target path (e.g. a queued download whose
# paths were never resolved before shutdown): the record
# can never be restored, so drop it instead of letting it
# accumulate in the state store forever.
await self._aria2_state_store.remove(download_id)
continue continue
if ( if (
@@ -1152,9 +1318,13 @@ class DownloadManager:
use_save_dir_as_root: bool = False, use_save_dir_as_root: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Wrapper for original download_from_civitai implementation""" """Wrapper for original download_from_civitai implementation"""
file_params = file_params or None
try: try:
# Check if model version already exists in library # Check if model version already exists in library.
if model_version_id is not None: # 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 # Check both scanners
lora_scanner = await self._get_lora_scanner() lora_scanner = await self._get_lora_scanner()
checkpoint_scanner = await self._get_checkpoint_scanner() checkpoint_scanner = await self._get_checkpoint_scanner()
@@ -1235,8 +1405,26 @@ class DownloadManager:
except (TypeError, ValueError): except (TypeError, ValueError):
resolved_version_id = None 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 ( 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 resolved_version_id is not None
and await self._has_been_downloaded(model_type, resolved_version_id) and await self._has_been_downloaded(model_type, resolved_version_id)
): ):
@@ -1346,9 +1534,38 @@ class DownloadManager:
f"baseModel '{base_model_value}' is a known diffusion model, routing to unet folder" 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 # Existence check after the metadata fetch (#1058):
if model_version_id is None: # - An explicit file selection only blocks when THIS file is
version_id = version_info.get("id") # 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": if model_type == "lora":
# Check lora scanner # Check lora scanner
@@ -1495,73 +1712,16 @@ class DownloadManager:
files = version_info.get("files", []) files = version_info.get("files", [])
file_info = None file_info = None
# If file_params is provided, try to find matching file # If file_params is provided, reuse the file resolved right after
if file_params and model_version_id: # the metadata fetch so the existence gate and this selection
target_file_id = file_params.get("id") # always agree on the target file (#1058).
target_type = file_params.get("type", "Model") if file_params is not None:
target_format = file_params.get("format") file_info = target_file
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 not file_info: if not file_info:
logger.debug( logger.debug(
"[download] No match found via file_params — falling back to primary file lookup", "[download] No match found via file_params — falling back to primary file lookup",
) )
elif not file_params: else:
logger.debug( logger.debug(
"[download] No file_params provided (null/None) — will use primary file lookup. " "[download] No file_params provided (null/None) — will use primary file lookup. "
"model_version_id=%s, total_files=%d", "model_version_id=%s, total_files=%d",
@@ -1706,6 +1866,7 @@ class DownloadManager:
version_info, version_info,
model_version_id, model_version_id,
save_path, save_path,
file_info=file_info,
) )
await self._sync_downloaded_version( await self._sync_downloaded_version(
model_type, model_type,
@@ -1748,6 +1909,7 @@ class DownloadManager:
version_info: Dict[str, Any], version_info: Dict[str, Any],
fallback_version_id=None, fallback_version_id=None,
file_path: str | None = None, file_path: str | None = None,
file_info: Dict[str, Any] | None = None,
) -> None: ) -> None:
try: try:
history_service = await ServiceRegistry.get_downloaded_version_history_service() history_service = await ServiceRegistry.get_downloaded_version_history_service()
@@ -1773,6 +1935,15 @@ class DownloadManager:
if version_id is None: if version_id is None:
version_id = fallback_version_id 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: try:
await history_service.mark_downloaded( await history_service.mark_downloaded(
model_type, model_type,
@@ -1780,6 +1951,8 @@ class DownloadManager:
model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None, model_id=int(cast(Any, resolved_model_id)) if resolved_model_id is not None else None,
source="download", source="download",
file_path=file_path, file_path=file_path,
file_id=file_id,
file_name=file_name,
) )
except (TypeError, ValueError): except (TypeError, ValueError):
logger.debug( logger.debug(
@@ -2729,6 +2902,64 @@ class DownloadManager:
# Preserve aria2 state store entry so the partial download # Preserve aria2 state store entry so the partial download
# info survives restarts and can be resumed later # info survives restarts and can be resumed later
async def discard_cleared_downloads(self, download_ids: Iterable[str]) -> int:
"""Stop in-memory tracking for downloads cleared from the queue.
Cancels asyncio tasks, removes live aria2 transfers and drops the
persisted aria2 state so cleared downloads cannot keep polling the
daemon or be resurrected as ghost entries on the next restart.
Partial files on disk are preserved; unlike ``cancel_download`` no
files are deleted.
Returns the number of downloads that had any in-memory or persisted
tracking removed.
"""
discarded = 0
aria2_downloader = None
for download_id in download_ids:
task = self._download_tasks.get(download_id)
info = self._active_downloads.get(download_id)
persisted = await self._aria2_state_store.get(download_id)
if task is None and info is None and persisted is None:
continue
discarded += 1
if task is not None:
task.cancel()
pause_control = self._pause_events.pop(download_id, None)
if pause_control is not None:
pause_control.resume()
if task is not None:
try:
await asyncio.wait_for(asyncio.shield(task), timeout=2.0)
except (asyncio.CancelledError, asyncio.TimeoutError):
pass
self._download_tasks.pop(download_id, None)
self._active_downloads.pop(download_id, None)
backend = (info or persisted or {}).get("transfer_backend") or "python"
if backend == "aria2":
if aria2_downloader is None:
aria2_downloader = await get_aria2_downloader()
if await aria2_downloader.has_transfer(download_id):
try:
await aria2_downloader.cancel_download(download_id)
except Exception as exc:
logger.warning(
"Failed to remove aria2 transfer for cleared download %s: %s",
download_id,
exc,
)
await self._aria2_state_store.remove(download_id)
return discarded
async def pause_download(self, download_id: str) -> Dict[str, Any]: async def pause_download(self, download_id: str) -> Dict[str, Any]:
"""Pause an active download without losing progress.""" """Pause an active download without losing progress."""
+88 -31
View File
@@ -6,12 +6,21 @@ import logging
import os import os
import sqlite3 import sqlite3
import time import time
from typing import Any, Optional from typing import Any, List, Optional
from ..utils.cache_paths import get_cache_base_dir from ..utils.cache_paths import get_cache_base_dir
logger = logging.getLogger(__name__) 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: def _resolve_database_path() -> str:
base_dir = get_cache_base_dir(create=True) base_dir = get_cache_base_dir(create=True)
@@ -64,6 +73,7 @@ class DownloadQueueService:
model_name TEXT NOT NULL DEFAULT '', model_name TEXT NOT NULL DEFAULT '',
version_name TEXT DEFAULT '', version_name TEXT DEFAULT '',
thumbnail_url TEXT DEFAULT '', thumbnail_url TEXT DEFAULT '',
file_params TEXT,
status TEXT NOT NULL, status TEXT NOT NULL,
error TEXT, error TEXT,
file_path TEXT, file_path TEXT,
@@ -120,6 +130,18 @@ class DownloadQueueService:
with self._connect() as conn: with self._connect() as conn:
conn.executescript(self._SCHEMA_TABLES) 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 # Creating the unique index on download_history.download_id can
# fail if pre-existing rows have duplicate values (e.g. from a # fail if pre-existing rows have duplicate values (e.g. from a
# previous version that lacked the index). Deduplicate first so # previous version that lacked the index). Deduplicate first so
@@ -368,23 +390,31 @@ class DownloadQueueService:
conn.commit() conn.commit()
return True return True
async def clear_queue(self, status_filter: Optional[str] = None) -> int: async def clear_queue(self, status_filter: Optional[str] = None) -> List[str]:
"""Remove items from the queue. """Remove items from the queue.
When *status_filter* is provided only items with that status are When *status_filter* is provided only items with that status are
deleted. Returns the number of deleted rows. deleted. Returns the ``download_id`` values of the deleted rows so
callers can also tear down any in-memory tracking for them.
""" """
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
if status_filter is not None: if status_filter is not None:
cursor = conn.execute( rows = conn.execute(
"SELECT download_id FROM download_queue WHERE status = ?",
(status_filter,),
).fetchall()
conn.execute(
"DELETE FROM download_queue WHERE status = ?", "DELETE FROM download_queue WHERE status = ?",
(status_filter,), (status_filter,),
) )
else: else:
cursor = conn.execute("DELETE FROM download_queue") rows = conn.execute(
"SELECT download_id FROM download_queue"
).fetchall()
conn.execute("DELETE FROM download_queue")
conn.commit() conn.commit()
return cursor.rowcount return [row["download_id"] for row in rows]
async def complete_download( async def complete_download(
self, self,
@@ -418,6 +448,12 @@ class DownloadQueueService:
return None return None
now = completed_at if completed_at is not None else time.time() 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( conn.execute(
"DELETE FROM download_queue WHERE download_id = ?", "DELETE FROM download_queue WHERE download_id = ?",
(download_id,), (download_id,),
@@ -426,9 +462,9 @@ class DownloadQueueService:
""" """
INSERT OR IGNORE INTO download_history ( INSERT OR IGNORE INTO download_history (
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path, version_name, thumbnail_url, file_params, status, error,
bytes_downloaded, total_bytes, completed_at file_path, bytes_downloaded, total_bytes, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
row["download_id"], row["download_id"],
@@ -437,6 +473,7 @@ class DownloadQueueService:
row["model_name"], row["model_name"],
row["version_name"], row["version_name"],
row["thumbnail_url"], row["thumbnail_url"],
file_params_json,
status, status,
error, error,
file_path, file_path,
@@ -503,6 +540,7 @@ class DownloadQueueService:
bytes_downloaded: int = 0, bytes_downloaded: int = 0,
total_bytes: Optional[int] = None, total_bytes: Optional[int] = None,
is_already_exists: int = 0, is_already_exists: int = 0,
file_params: Optional[dict[str, Any]] = None,
) -> int: ) -> int:
"""Insert a record into the download history. """Insert a record into the download history.
@@ -510,6 +548,7 @@ class DownloadQueueService:
inserted row. inserted row.
""" """
now = time.time() now = time.time()
file_params_json = json.dumps(file_params) if file_params is not None else None
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
@@ -517,9 +556,10 @@ class DownloadQueueService:
""" """
INSERT INTO download_history ( INSERT INTO download_history (
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, status, error, file_path, version_name, thumbnail_url, file_params, status, error,
bytes_downloaded, total_bytes, completed_at, is_already_exists file_path, bytes_downloaded, total_bytes, completed_at,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) is_already_exists
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
download_id, download_id,
@@ -528,6 +568,7 @@ class DownloadQueueService:
model_name, model_name,
version_name, version_name,
thumbnail_url, thumbnail_url,
file_params_json,
status, status,
error, error,
file_path, file_path,
@@ -702,7 +743,7 @@ class DownloadQueueService:
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params, version_name, thumbnail_url, source, file_params,
status, priority, added_at status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""", """,
( (
new_id, new_id,
@@ -712,6 +753,7 @@ class DownloadQueueService:
row["version_name"], row["version_name"],
row["thumbnail_url"], row["thumbnail_url"],
"retry", "retry",
row["file_params"],
now, now,
), ),
) )
@@ -755,7 +797,7 @@ class DownloadQueueService:
download_id, model_id, model_version_id, model_name, download_id, model_id, model_version_id, model_name,
version_name, thumbnail_url, source, file_params, version_name, thumbnail_url, source, file_params,
status, priority, added_at status, priority, added_at
) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 'queued', 0, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', 0, ?)
""", """,
( (
new_id, new_id,
@@ -765,6 +807,7 @@ class DownloadQueueService:
row["version_name"], row["version_name"],
row["thumbnail_url"], row["thumbnail_url"],
"retry", "retry",
row["file_params"],
now, now,
), ),
) )
@@ -840,33 +883,44 @@ class DownloadQueueService:
async with self._lock: async with self._lock:
conn = self._get_conn() conn = self._get_conn()
# 1. History: for each (model_id, model_version_id, status) triplet # 1. History: for each (model_id, model_version_id, file_id,
# keep only the row with the highest id (most recently inserted). # status) group keep only the row with the highest id (most
conn.execute(""" # 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 DELETE FROM download_history
WHERE id NOT IN ( WHERE id NOT IN (
SELECT MAX(id) SELECT MAX(id)
FROM download_history 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( result["removed_history"] = conn.execute(
"SELECT changes()" "SELECT changes()"
).fetchone()[0] ).fetchone()[0]
# 2. Cross-status dedup: for each (model_id, model_version_id), # 2. Cross-status dedup: for each (model_id, model_version_id,
# keep only the entry with the highest-priority terminal status. # file_id), keep only the entry with the highest-priority
# terminal status.
# Priority: completed (3) > failed (2) > canceled (1). # Priority: completed (3) > failed (2) > canceled (1).
# This prevents the same model version from having both a # This prevents the same file of a model version from having
# 'failed' and a 'canceled' entry (or a 'completed' alongside # both a 'failed' and a 'canceled' entry (or a 'completed'
# either) after the bug-created duplicates are removed. # alongside either) after the bug-created duplicates are
conn.execute(""" # 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 DELETE FROM download_history
WHERE id NOT IN ( WHERE id NOT IN (
SELECT dh.id 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 ( INNER JOIN (
SELECT model_id, model_version_id, SELECT model_id, model_version_id,
{_FILE_ID_SQL} AS file_id,
MAX(CASE status MAX(CASE status
WHEN 'completed' THEN 3 WHEN 'completed' THEN 3
WHEN 'failed' THEN 2 WHEN 'failed' THEN 2
@@ -874,17 +928,18 @@ class DownloadQueueService:
ELSE 0 ELSE 0
END) AS best_prio END) AS best_prio
FROM download_history FROM download_history
GROUP BY model_id, model_version_id GROUP BY model_id, model_version_id, {_FILE_ID_SQL}
) best ) best
ON dh.model_id = best.model_id ON dh.model_id = best.model_id
AND dh.model_version_id = best.model_version_id AND dh.model_version_id = best.model_version_id
AND dh.file_id IS best.file_id
AND CASE dh.status AND CASE dh.status
WHEN 'completed' THEN 3 WHEN 'completed' THEN 3
WHEN 'failed' THEN 2 WHEN 'failed' THEN 2
WHEN 'canceled' THEN 1 WHEN 'canceled' THEN 1
ELSE 0 ELSE 0
END = best.best_prio 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) HAVING dh.id = MAX(dh.id)
) )
""") """)
@@ -892,15 +947,17 @@ class DownloadQueueService:
"SELECT changes()" "SELECT changes()"
).fetchone()[0] ).fetchone()[0]
# 3. Queue: for each (model_id, model_version_id) keep only the # 3. Queue: for each (model_id, model_version_id, file_id) keep
# row with the latest added_at (most recently enqueued). # only the row with the latest added_at (most recently
conn.execute(""" # enqueued). file_id comes from file_params (#1058) so
# distinct files of the same version never collapse.
conn.execute(f"""
DELETE FROM download_queue DELETE FROM download_queue
WHERE rowid NOT IN ( WHERE rowid NOT IN (
SELECT MAX(rowid) SELECT MAX(rowid)
FROM download_queue FROM download_queue
WHERE status IN ('queued', 'downloading', 'paused', 'waiting') 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') AND status IN ('queued', 'downloading', 'paused', 'waiting')
""") """)
+112 -18
View File
@@ -62,6 +62,14 @@ class DownloadedVersionHistoryService:
); );
CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model CREATE INDEX IF NOT EXISTS idx_downloaded_model_versions_model
ON downloaded_model_versions(model_type, model_id); 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: def __init__(self, db_path: str | None = None, *, settings_manager=None) -> None:
@@ -131,10 +139,13 @@ class DownloadedVersionHistoryService:
source: str = "manual", source: str = "manual",
file_path: str | None = None, file_path: str | None = None,
library_name: str | None = None, library_name: str | None = None,
file_id: int | None = None,
file_name: str | None = None,
) -> None: ) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
normalized_model_id = _normalize_int(model_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: if normalized_type is None or normalized_version_id is None:
return return
@@ -168,6 +179,25 @@ class DownloadedVersionHistoryService:
active_library_name, 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() conn.commit()
async def mark_downloaded_bulk( async def mark_downloaded_bulk(
@@ -206,24 +236,33 @@ class DownloadedVersionHistoryService:
return return
async with self._lock: async with self._lock:
conn = self._get_conn() # The connection is created with check_same_thread=False and all
conn.executemany( # access is serialized by self._lock, so the executemany upsert +
""" # commit can run in the default executor without blocking the
INSERT INTO downloaded_model_versions ( # event loop on large hydration payloads.
model_type, version_id, model_id, first_seen_at, last_seen_at, loop = asyncio.get_running_loop()
source, last_file_path, last_library_name, is_deleted_override await loop.run_in_executor(None, self._mark_downloaded_bulk_sync, payload)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
ON CONFLICT(model_type, version_id) DO UPDATE SET def _mark_downloaded_bulk_sync(self, payload: Sequence[tuple[object, ...]]) -> None:
model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id), """Synchronous executemany upsert + commit; runs in a worker thread."""
last_seen_at = excluded.last_seen_at, conn = self._get_conn()
source = excluded.source, conn.executemany(
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path), """
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name), INSERT INTO downloaded_model_versions (
is_deleted_override = 0 model_type, version_id, model_id, first_seen_at, last_seen_at,
""", source, last_file_path, last_library_name, is_deleted_override
payload, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)
) ON CONFLICT(model_type, version_id) DO UPDATE SET
conn.commit() model_id = COALESCE(excluded.model_id, downloaded_model_versions.model_id),
last_seen_at = excluded.last_seen_at,
source = excluded.source,
last_file_path = COALESCE(excluded.last_file_path, downloaded_model_versions.last_file_path),
last_library_name = COALESCE(excluded.last_library_name, downloaded_model_versions.last_library_name),
is_deleted_override = 0
""",
payload,
)
conn.commit()
async def mark_as_deleted(self, model_type: str, version_id: int) -> None: async def mark_as_deleted(self, model_type: str, version_id: int) -> None:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
@@ -255,8 +294,63 @@ class DownloadedVersionHistoryService:
self._get_active_library_name(), 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() 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: async def has_been_downloaded(self, model_type: str, version_id: int) -> bool:
normalized_type = _normalize_model_type(model_type) normalized_type = _normalize_model_type(model_type)
normalized_version_id = _normalize_int(version_id) normalized_version_id = _normalize_int(version_id)
+20
View File
@@ -156,6 +156,25 @@ class DownloadStalledError(Exception):
"""Raised when download progress stalls beyond the configured timeout.""" """Raised when download progress stalls beyond the configured timeout."""
def _disable_netrc_auth(session: aiohttp.ClientSession) -> None:
"""Prevent the session from loading credentials from netrc files.
``trust_env=True`` is kept so system-level proxies still work, but aiohttp
would also auto-apply netrc entries (e.g. ``machine civitai.red``) as
BasicAuth. aiohttp refuses to combine those with the explicit
``Authorization: Bearer`` header set for CivitAI requests, raising
"Cannot combine AUTHORIZATION header with AUTH argument or credentials
encoded in URL" before the request is even sent. Subclassing ClientSession
is discouraged by aiohttp (emits a DeprecationWarning), so the private
hook is patched on the instance instead.
"""
def _no_netrc_auth(*args: Any, **kwargs: Any) -> Optional[aiohttp.BasicAuth]:
return None
setattr(session, "_get_netrc_auth", _no_netrc_auth)
class Downloader: class Downloader:
"""Unified downloader for all HTTP/HTTPS downloads in the application.""" """Unified downloader for all HTTP/HTTPS downloads in the application."""
@@ -370,6 +389,7 @@ class Downloader:
trust_env=not app_proxy_active, trust_env=not app_proxy_active,
timeout=timeout, timeout=timeout,
) )
_disable_netrc_auth(self._session)
# Store proxy URL for per-request use. Stays None for SOCKS because the # Store proxy URL for per-request use. Stays None for SOCKS because the
# ProxyConnector already tunnels everything; passing proxy= for SOCKS # ProxyConnector already tunnels everything; passing proxy= for SOCKS
+1
View File
@@ -51,6 +51,7 @@ class EmbeddingService(BaseModelService):
"base_model": model_data.get("base_model", ""), "base_model": model_data.get("base_model", ""),
"folder": folder, "folder": folder,
"sha256": model_data.get("sha256", ""), "sha256": model_data.get("sha256", ""),
"autov3": model_data.get("autov3"),
"file_path": file_path.replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": model_data.get("size", 0), "file_size": model_data.get("size", 0),
"modified": model_data.get("modified", ""), "modified": model_data.get("modified", ""),
+1
View File
@@ -58,6 +58,7 @@ class LoraService(BaseModelService):
"base_model": model_data.get("base_model", ""), "base_model": model_data.get("base_model", ""),
"folder": folder, "folder": folder,
"sha256": model_data.get("sha256", ""), "sha256": model_data.get("sha256", ""),
"autov3": model_data.get("autov3"),
"file_path": file_path.replace(os.sep, "/"), "file_path": file_path.replace(os.sep, "/"),
"file_size": model_data.get("size", 0), "file_size": model_data.get("size", 0),
"modified": model_data.get("modified", ""), "modified": model_data.get("modified", ""),
+65 -1
View File
@@ -35,6 +35,10 @@ class ModelCache:
folders: List[str] folders: List[str]
version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict) version_index: Dict[int, Dict[str, Any]] = field(default_factory=dict)
model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict) model_id_index: Dict[int, List[Dict[str, Any]]] = field(default_factory=dict)
# Multi-valued companion to version_index: every local file entry of a
# 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" name_display_mode: str = "model_name"
_lock: Any = field(init=False, repr=False, default=None) _lock: Any = field(init=False, repr=False, default=None)
# Cache for last sort: (sort_key, order, seed) -> sorted list # Cache for last sort: (sort_key, order, seed) -> sorted list
@@ -116,6 +120,7 @@ class ModelCache:
self.version_index = {} self.version_index = {}
self.model_id_index = {} self.model_id_index = {}
self.version_files_index = {}
for item in self.raw_data: for item in self.raw_data:
self.add_to_version_index(item) self.add_to_version_index(item)
@@ -132,6 +137,17 @@ class ModelCache:
self.version_index[version_id] = item 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')) model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None: if model_id is None:
return return
@@ -159,12 +175,37 @@ class ModelCache:
if version_id is None: if version_id is None:
return 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) existing = self.version_index.get(version_id)
if existing is item or ( if existing is item or (
isinstance(existing, dict) isinstance(existing, dict)
and existing.get('file_path') == item.get('file_path') 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')) model_id = self._normalize_version_id(civitai_data.get('modelId'))
if model_id is None: if model_id is None:
@@ -174,6 +215,20 @@ class ModelCache:
if not versions: if not versions:
return 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] filtered = [v for v in versions if v.get('versionId') != version_id]
if filtered: if filtered:
self.model_id_index[model_id] = filtered self.model_id_index[model_id] = filtered
@@ -206,6 +261,15 @@ class ModelCache:
versions = self.model_id_index.get(normalized_id, []) versions = self.model_id_index.get(normalized_id, [])
return [dict(version) for version in versions] 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): async def resort(self):
"""Resort cached data according to last sort mode if set""" """Resort cached data according to last sort mode if set"""
async with self._lock: async with self._lock:
+21
View File
@@ -432,6 +432,7 @@ class SearchStrategy:
"tags": False, "tags": False,
"recursive": True, "recursive": True,
"creator": False, "creator": False,
"hash": False,
} }
def __init__( def __init__(
@@ -494,8 +495,28 @@ class SearchStrategy:
results.append(item) results.append(item)
continue continue
# Hash search is always exact (never fuzzy): match the full
# sha256, its autov2 prefix (first 10 chars), or the autov3 hash.
if options.get("hash", False):
hash_query = search_lower.strip()
if hash_query and self._matches_hash(item, hash_query):
results.append(item)
continue
return results return results
def _matches_hash(self, item: Dict[str, Any], hash_query: str) -> bool:
"""Exact-match the normalized query against the item's known hashes."""
sha256 = item.get("sha256")
sha256_lower = sha256.lower() if isinstance(sha256, str) else ""
if sha256_lower and hash_query in (sha256_lower, sha256_lower[:10]):
return True
# autov3 is None when unchecked and "" when checked but unavailable
autov3 = item.get("autov3")
if isinstance(autov3, str) and autov3 and hash_query == autov3.lower():
return True
return False
def _matches( def _matches(
self, candidate: str, search_term: str, search_lower: str, fuzzy: bool self, candidate: str, search_term: str, search_lower: str, fuzzy: bool
) -> bool: ) -> bool:
+294 -62
View File
@@ -5,7 +5,7 @@ import asyncio
import time import time
import shutil import shutil
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Type, Union, cast from typing import Any, Awaitable, Callable, Dict, List, Mapping, Optional, Sequence, Set, Tuple, Type, Union, cast
from ..utils.models import BaseModelMetadata, autov3_from_civitai_files from ..utils.models import BaseModelMetadata, autov3_from_civitai_files
from ..config import config from ..config import config
@@ -25,6 +25,28 @@ from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
logger = logging.getLogger(__name__) 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: def _is_excluded_dir(name: str) -> bool:
"""Return True when a directory entry must be skipped during model walks. """Return True when a directory entry must be skipped during model walks.
@@ -35,6 +57,16 @@ def _is_excluded_dir(name: str) -> bool:
return name == PENDING_DELETE_DIR_NAME return name == PENDING_DELETE_DIR_NAME
def _is_hidden_relative_path(rel_path: str) -> bool:
"""Return True when any segment of a relative path is a hidden directory."""
return any(part.startswith(".") for part in rel_path.replace(os.sep, "/").split("/"))
# TTL (seconds) for the get_all_folders() live-walk cache, so rapid repeated
# requests (modal open + autocomplete) do not re-walk the model roots.
ALL_FOLDERS_CACHE_TTL_SECONDS = 5.0
def _is_pending_delete_path(path: str) -> bool: def _is_pending_delete_path(path: str) -> bool:
"""Return True when any path component is the pending-delete staging dir.""" """Return True when any path component is the pending-delete staging dir."""
normalized = str(path).replace(os.sep, "/") normalized = str(path).replace(os.sep, "/")
@@ -104,6 +136,8 @@ class ModelScanner:
self._name_display_mode = self._resolve_name_display_mode() self._name_display_mode = self._resolve_name_display_mode()
self._cancel_requested = False # Flag for cancellation self._cancel_requested = False # Flag for cancellation
self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process self._autov3_backfill_scheduled = False # One-time AutoV3 backfill trigger per process
# Short-lived cache for get_all_folders(): (timestamp, folders) or None
self._all_folders_ttl_cache: Optional[Tuple[float, List[str]]] = None
try: try:
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
except RuntimeError: except RuntimeError:
@@ -143,6 +177,7 @@ class ModelScanner:
self._excluded_models = [] self._excluded_models = []
self._is_initializing = False self._is_initializing = False
self._name_display_mode = self._resolve_name_display_mode() self._name_display_mode = self._resolve_name_display_mode()
self.invalidate_all_folders_cache()
self.bump_cache_version() self.bump_cache_version()
try: try:
@@ -500,16 +535,21 @@ class ModelScanner:
self._is_initializing = False self._is_initializing = False
async def _load_persisted_cache(self, page_type: str) -> bool: async def _load_persisted_cache(self, page_type: str) -> bool:
"""Attempt to hydrate the in-memory cache from the SQLite snapshot.""" """Attempt to hydrate the in-memory cache from the SQLite snapshot.
The SQLite read and the per-model rebuild (entry adjustment, tag
counting, validation/repair, hash index reconstruction) run in the
default executor so the event loop stays responsive; only applying
the result to shared cache state happens on the loop.
"""
if not getattr(self, '_persistent_cache', None): if not getattr(self, '_persistent_cache', None):
return False return False
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
try: try:
persisted = await loop.run_in_executor( rebuilt = await loop.run_in_executor(
None, None,
self._persistent_cache.load_cache, self._rebuild_persisted_cache
self.model_type
) )
except FileNotFoundError: except FileNotFoundError:
return False return False
@@ -517,47 +557,14 @@ class ModelScanner:
logger.debug("%s Scanner: Could not load persisted cache: %s", self.model_type.capitalize(), exc) logger.debug("%s Scanner: Could not load persisted cache: %s", self.model_type.capitalize(), exc)
return False return False
if not persisted or not persisted.raw_data: if rebuilt is None:
return False return False
hash_index = ModelHashIndex() scan_result, invalid_entries = rebuilt
for sha_value, path in persisted.hash_rows:
if sha_value and path:
hash_index.add_entry(sha_value.lower(), path)
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
# cover every known autov3 -> path mapping regardless of whether a
# sha256 row also exists for the same file.
for autov3_value, path in persisted.autov3_hash_rows:
if autov3_value and path:
hash_index.add_autov3(autov3_value.lower(), path)
tags_count: Dict[str, int] = {}
adjusted_raw_data: List[Dict[str, Any]] = []
for item in persisted.raw_data:
adjusted_item = self.adjust_cached_entry(dict(item))
adjusted_raw_data.append(adjusted_item)
for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
# Validate cache entries and check health.
# Always use the validated/repaired entries — even when there are no
# invalid entries, auto_repair may have filled in missing optional
# fields (model_name, file_name, folder) with safe defaults on a copied
# working_entry. Without this unconditional replacement the repaired
# copies are discarded and None values propagate to format_response.
# See issue #730.
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
adjusted_raw_data, auto_repair=True
)
# Always use the validated entries (repaired copies)
adjusted_raw_data = valid_entries
if invalid_entries: if invalid_entries:
monitor = CacheHealthMonitor() monitor = CacheHealthMonitor()
report = monitor.check_health(adjusted_raw_data, auto_repair=True) report = monitor.check_health(scan_result.raw_data, auto_repair=True)
if report.status != CacheHealthStatus.HEALTHY: if report.status != CacheHealthStatus.HEALTHY:
# Broadcast health warning to frontend # Broadcast health warning to frontend
@@ -567,31 +574,22 @@ class ModelScanner:
f"{report.invalid_entries} invalid entries, {report.repaired_entries} repaired" f"{report.invalid_entries} invalid entries, {report.repaired_entries} repaired"
) )
# Use only valid entries
adjusted_raw_data = valid_entries
# Rebuild tags count from valid entries only # Rebuild tags count from valid entries only
tags_count = {} tags_count = {}
for item in adjusted_raw_data: for item in scan_result.raw_data:
for tag in item.get('tags') or []: for tag in item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1 tags_count[tag] = tags_count.get(tag, 0) + 1
scan_result.tags_count = tags_count
# Remove invalid entries from hash index # Remove invalid entries from hash index
for invalid_entry in invalid_entries: for invalid_entry in invalid_entries:
file_path = CacheEntryValidator.get_file_path_safe(invalid_entry) file_path = CacheEntryValidator.get_file_path_safe(invalid_entry)
sha256 = CacheEntryValidator.get_sha256_safe(invalid_entry) sha256 = CacheEntryValidator.get_sha256_safe(invalid_entry)
if file_path: if file_path:
hash_index.remove_by_path(file_path, sha256) scan_result.hash_index.remove_by_path(file_path, sha256)
scan_result = CacheBuildResult(
raw_data=adjusted_raw_data,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=list(persisted.excluded_models)
)
await self._apply_scan_result(scan_result) await self._apply_scan_result(scan_result)
await self._sync_download_history(adjusted_raw_data, source='scan') await self._sync_download_history(scan_result.raw_data, source='scan')
await ws_manager.broadcast_init_progress({ await ws_manager.broadcast_init_progress({
'stage': 'loading_cache', 'stage': 'loading_cache',
@@ -616,6 +614,63 @@ class ModelScanner:
return True return True
def _rebuild_persisted_cache(self) -> Optional[Tuple[CacheBuildResult, List[Dict[str, Any]]]]:
"""Load the SQLite snapshot and rebuild a ready-to-apply scan result.
Runs entirely in a worker thread: it must not touch ``self._cache``,
the websocket manager, or any asyncio primitives. Returns ``None``
when no usable snapshot exists, otherwise a tuple of the scan result
(built from validated/repaired entries) and the invalid entries.
"""
persisted = self._persistent_cache.load_cache(self.model_type)
if not persisted or not persisted.raw_data:
return None
hash_index = ModelHashIndex()
for sha_value, path in persisted.hash_rows:
if sha_value and path:
hash_index.add_entry(sha_value.lower(), path)
# Rebuild the AutoV3 index from the persisted autov3_index rows. These
# cover every known autov3 -> path mapping regardless of whether a
# sha256 row also exists for the same file.
for autov3_value, path in persisted.autov3_hash_rows:
if autov3_value and path:
hash_index.add_autov3(autov3_value.lower(), path)
tags_count: Dict[str, int] = {}
adjusted_raw_data: List[Dict[str, Any]] = []
for item in persisted.raw_data:
# load_cache builds a fresh dict per row, and validate_batch below
# works on its own per-entry copy when auto_repair=True, so no
# additional dict copy is needed here.
adjusted_item = self.adjust_cached_entry(item)
adjusted_raw_data.append(adjusted_item)
for tag in adjusted_item.get('tags') or []:
tags_count[tag] = tags_count.get(tag, 0) + 1
# Validate cache entries and check health.
# Always use the validated/repaired entries — even when there are no
# invalid entries, auto_repair may have filled in missing optional
# fields (model_name, file_name, folder) with safe defaults on a copied
# working_entry. Without this unconditional replacement the repaired
# copies are discarded and None values propagate to format_response.
# See issue #730.
valid_entries, invalid_entries = CacheEntryValidator.validate_batch(
adjusted_raw_data, auto_repair=True
)
# Always use the validated entries (repaired copies)
scan_result = CacheBuildResult(
raw_data=valid_entries,
hash_index=hash_index,
tags_count=tags_count,
excluded_models=list(persisted.excluded_models)
)
return scan_result, invalid_entries
async def _run_autov3_backfill(self) -> None: async def _run_autov3_backfill(self) -> None:
"""Backfill autov3 for entries loaded from the persisted cache that lack it.""" """Backfill autov3 for entries loaded from the persisted cache that lack it."""
try: try:
@@ -875,12 +930,12 @@ class ModelScanner:
new_files = [] new_files = []
visited_real_paths = set() visited_real_paths = set()
discovered_real_files = set() discovered_real_files = set()
# Scan all model roots # Scan all model roots
for root_path in self.get_model_roots(): for root_path in self.get_model_roots():
if not os.path.exists(root_path): if not os.path.exists(root_path):
continue continue
# Recursively scan directory # Recursively scan directory
for root, dirnames, files in os.walk(root_path, followlinks=True): for root, dirnames, files in os.walk(root_path, followlinks=True):
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)] dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
@@ -888,7 +943,7 @@ class ModelScanner:
if real_root in visited_real_paths: if real_root in visited_real_paths:
continue continue
visited_real_paths.add(real_root) visited_real_paths.add(real_root)
for file in files: for file in files:
ext = os.path.splitext(file)[1].lower() ext = os.path.splitext(file)[1].lower()
if ext in self.file_extensions: if ext in self.file_extensions:
@@ -933,7 +988,7 @@ class ModelScanner:
if self.is_cancelled(): if self.is_cancelled():
logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled") logger.info(f"{self.model_type.capitalize()} Scanner: Reconcile scan cancelled")
return return
# Process new files in batches # Process new files in batches
total_added = 0 total_added = 0
if new_files: if new_files:
@@ -1092,6 +1147,56 @@ class ModelScanner:
def get_model_roots(self) -> List[str]: def get_model_roots(self) -> List[str]:
"""Get model root directories""" """Get model root directories"""
raise NotImplementedError("Subclasses must implement get_model_roots") raise NotImplementedError("Subclasses must implement get_model_roots")
async def get_all_folders(self) -> List[str]:
"""Enumerate every directory under the model roots, live from disk.
Unlike the models-only ``cache.folders``, this includes empty
directories, so it stays accurate even when the in-memory cache was
hydrated from a persisted snapshot without a filesystem walk. Hidden
directories (any segment starting with '.') and the pending-delete
staging dir are excluded. The result is unioned with the model-derived
folders so it is always a superset of ``cache.folders``, and cached
for ``ALL_FOLDERS_CACHE_TTL_SECONDS`` to avoid repeated walks.
"""
now = time.monotonic()
if self._all_folders_ttl_cache is not None:
cached_at, cached_folders = self._all_folders_ttl_cache
if now - cached_at < ALL_FOLDERS_CACHE_TTL_SECONDS:
return cached_folders
discovered: Set[str] = set()
visited_real_paths: Set[str] = set()
for root_path in self.get_model_roots():
if not os.path.exists(root_path):
continue
for root, dirnames, _files in os.walk(root_path, followlinks=True):
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
# realpath is used only for symlink dedup, never for the
# recorded path (business paths stay unresolved).
real_root = os.path.realpath(root)
if real_root in visited_real_paths:
continue
visited_real_paths.add(real_root)
rel_dir = os.path.relpath(os.path.abspath(root), os.path.abspath(root_path))
rel_dir = rel_dir.replace(os.path.sep, "/")
if rel_dir != "." and not _is_hidden_relative_path(rel_dir):
discovered.add(rel_dir)
folders = set(discovered)
if self._cache is not None:
folders |= {item.get('folder', '') for item in self._cache.raw_data}
result = sorted(folders, key=lambda x: x.lower())
self._all_folders_ttl_cache = (now, result)
return result
def invalidate_all_folders_cache(self) -> None:
"""Drop the cached get_all_folders() result (e.g. after a move)."""
self._all_folders_ttl_cache = None
async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]: async def _create_default_metadata(self, file_path: str) -> Optional[BaseModelMetadata]:
"""Get model file info and metadata (extensible for different model types)""" """Get model file info and metadata (extensible for different model types)"""
@@ -1307,8 +1412,8 @@ class ModelScanner:
else: else:
self._cache.raw_data = list(scan_result.raw_data) self._cache.raw_data = list(scan_result.raw_data)
self._cache.rebuild_version_index() # resort() rebuilds folders and the version index on every path, so a
# separate rebuild_version_index() call here would be redundant.
await self._cache.resort() await self._cache.resort()
self._log_duplicate_filename_summary() self._log_duplicate_filename_summary()
@@ -1751,6 +1856,10 @@ class ModelScanner:
await cache.resort() await cache.resort()
# A move may have created new directories; drop the cached live-walk
# result so the next include_empty request sees them.
self.invalidate_all_folders_cache()
if cache_modified: if cache_modified:
await self._persist_current_cache() await self._persist_current_cache()
self.bump_cache_version() self.bump_cache_version()
@@ -2140,8 +2249,98 @@ class ModelScanner:
return sorted_models return sorted_models
return sorted_models[:limit] return sorted_models[:limit]
async def get_model_info_by_name(self, name): @staticmethod
"""Get model information by name""" 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: try:
cache = await self.get_cached_data() cache = await self.get_cached_data()
@@ -2446,6 +2645,39 @@ class ModelScanner:
logger.error(f"Error checking model version existence: {e}") logger.error(f"Error checking model version existence: {e}")
return False 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]]: async def get_model_versions_by_id(self, model_id: int) -> List[Dict[str, Any]]:
"""Get all versions of a model by its ID """Get all versions of a model by its ID
+178 -24
View File
@@ -13,11 +13,12 @@ import sqlite3
import time import time
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence from typing import Any, Dict, Iterable, Iterator, List, Mapping, Optional, Sequence
from .errors import RateLimitError, ResourceNotFoundError from .errors import RateLimitError, ResourceNotFoundError
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.civitai_utils import rewrite_preview_url from ..utils.civitai_utils import rewrite_preview_url
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
@@ -77,6 +78,10 @@ class ModelVersionRecord:
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration" usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO
is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate) is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate)
# Number of downloadable weight files for the version (None when unknown,
# e.g. records persisted before this field existed or locally-synthesized
# entries). Mirrors the frontend isModelWeightFile() filter.
file_count: Optional[int] = None
@dataclass @dataclass
@@ -245,6 +250,51 @@ class ModelUpdateRecord:
return False return False
def has_update_for_local_bases(
self,
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool:
"""Return True when any locally-held base model scope has an update.
Aggregates :meth:`has_update_for_base` across every distinct base model
present among in-library versions. This mirrors the per-item evaluation
performed by ``BaseModelService._annotate_update_flags`` when the
``version_grouping`` setting is ``same_base``, so callers reporting
"how many models have updates" stay aligned with what the Updates
filter displays. Use this instead of :meth:`has_update` for such
summaries; see issue #1083.
When no local base model is known (nothing held locally, or versions
never seen in any remote listing), falls back to :meth:`has_update` so
a model the item-level filter may still flag is not silently dropped
from summaries.
"""
bases = {
_normalize_base_model(version.base_model)
for version in self.versions
if version.is_in_library
}
bases.discard(None)
if not bases:
return self.has_update(
hide_early_access=hide_early_access,
hide_non_downloadable=hide_non_downloadable,
hide_paid=hide_paid,
)
return any(
self.has_update_for_base(
None,
base,
hide_early_access=hide_early_access,
hide_non_downloadable=hide_non_downloadable,
hide_paid=hide_paid,
)
for base in bases
)
class ModelUpdateService: class ModelUpdateService:
"""Persist and query remote model version metadata.""" """Persist and query remote model version metadata."""
@@ -273,6 +323,7 @@ class ModelUpdateService:
usage_control TEXT, usage_control TEXT,
paid_access TEXT, paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0, is_paid INTEGER NOT NULL DEFAULT 0,
file_count INTEGER,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
); );
@@ -520,6 +571,10 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions " "ALTER TABLE model_update_versions "
"ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0" "ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0"
), ),
"file_count": (
"ALTER TABLE model_update_versions "
"ADD COLUMN file_count INTEGER"
),
} }
for column, statement in migrations.items(): for column, statement in migrations.items():
@@ -623,6 +678,7 @@ class ModelUpdateService:
is_early_access INTEGER NOT NULL DEFAULT 0, is_early_access INTEGER NOT NULL DEFAULT 0,
paid_access TEXT, paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0, is_paid INTEGER NOT NULL DEFAULT 0,
file_count INTEGER,
PRIMARY KEY (model_id, version_id), PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
) )
@@ -644,6 +700,7 @@ class ModelUpdateService:
"is_early_access", "is_early_access",
"paid_access", "paid_access",
"is_paid", "is_paid",
"file_count",
] ]
defaults = { defaults = {
"sort_index": "0", "sort_index": "0",
@@ -658,6 +715,7 @@ class ModelUpdateService:
"is_early_access": "0", "is_early_access": "0",
"paid_access": "NULL", "paid_access": "NULL",
"is_paid": "0", "is_paid": "0",
"file_count": "NULL",
} }
select_parts = [] select_parts = []
@@ -773,6 +831,11 @@ class ModelUpdateService:
target_model_ids=target_filter, target_model_ids=target_filter,
) )
local_base_models = await self._collect_local_version_bases(
scanner,
target_model_ids=target_filter,
)
results: Dict[int, ModelUpdateRecord] = {} results: Dict[int, ModelUpdateRecord] = {}
prefetched: Dict[int, Mapping[Any, Any]] = {} prefetched: Dict[int, Mapping[Any, Any]] = {}
@@ -825,6 +888,7 @@ class ModelUpdateService:
force_refresh=force_refresh, force_refresh=force_refresh,
prefetched_response=prefetched.get(model_id), prefetched_response=prefetched.get(model_id),
all_local_version_ids=all_vids, all_local_version_ids=all_vids,
local_base_models=local_base_models,
) )
if scanner.is_cancelled(): if scanner.is_cancelled():
logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user") logger.info(f"{model_type.capitalize()} Update Service: Refresh cancelled by user")
@@ -859,12 +923,14 @@ class ModelUpdateService:
local_versions = await self._collect_local_versions(scanner) local_versions = await self._collect_local_versions(scanner)
version_ids = local_versions.get(model_id, []) version_ids = local_versions.get(model_id, [])
local_base_models = await self._collect_local_version_bases(scanner)
return await self._refresh_single_model( return await self._refresh_single_model(
model_type, model_type,
model_id, model_id,
version_ids, version_ids,
metadata_provider, metadata_provider,
force_refresh=force_refresh, force_refresh=force_refresh,
local_base_models=local_base_models,
) )
async def update_in_library_versions( async def update_in_library_versions(
@@ -1040,6 +1106,7 @@ class ModelUpdateService:
force_refresh: bool = False, force_refresh: bool = False,
prefetched_response: Optional[Mapping[str, Any]] = None, prefetched_response: Optional[Mapping[str, Any]] = None,
all_local_version_ids: Optional[Sequence[int]] = None, all_local_version_ids: Optional[Sequence[int]] = None,
local_base_models: Optional[Mapping[int, str]] = None,
) -> Optional[ModelUpdateRecord]: ) -> Optional[ModelUpdateRecord]:
normalized_local = self._normalize_sequence(local_versions) normalized_local = self._normalize_sequence(local_versions)
# When folder-filtering, this carries the cross-folder version set # When folder-filtering, this carries the cross-folder version set
@@ -1164,6 +1231,7 @@ class ModelUpdateService:
existing, existing,
now, now,
all_local_version_ids=normalized_all, all_local_version_ids=normalized_all,
local_base_models=local_base_models,
) )
else: else:
record = self._merge_with_local_versions( record = self._merge_with_local_versions(
@@ -1370,27 +1438,17 @@ class ModelUpdateService:
await self._enrich_version_entries(metadata_provider, aggregated) await self._enrich_version_entries(metadata_provider, aggregated)
return aggregated return aggregated
async def _collect_local_versions( @staticmethod
self, def _iter_local_civitai_items(
scanner, cache,
*, *,
target_model_ids: Optional[Sequence[int]] = None, target_set: Optional[set[int]] = None,
folder_path: Optional[str] = None, normalized_folder: Optional[str] = None,
) -> Dict[int, List[int]]: ) -> Iterator[tuple[int, int, Any]]:
cache = await scanner.get_cached_data() """Yield ``(modelId, versionId, base_model)`` for each scannable item."""
mapping: Dict[int, set[int]] = {}
if not cache or not getattr(cache, "raw_data", None): if not cache or not getattr(cache, "raw_data", None):
return {} return
target_set = None
if target_model_ids:
target_set = set(target_model_ids)
if not target_set:
return {}
normalized_folder = None
if folder_path is not None:
normalized_folder = folder_path.replace("\\", "/").strip("/")
for item in cache.raw_data: for item in cache.raw_data:
# Apply folder filter first (cheapest check) # Apply folder filter first (cheapest check)
@@ -1410,10 +1468,75 @@ class ModelUpdateService:
continue continue
if target_set is not None and model_id not in target_set: if target_set is not None and model_id not in target_set:
continue continue
yield model_id, version_id, item.get("base_model")
def _prepare_collection_filters(
self,
target_model_ids: Optional[Sequence[int]],
folder_path: Optional[str],
) -> tuple[Optional[set[int]], Optional[str]]:
target_set: Optional[set[int]] = None
if target_model_ids:
target_set = set(target_model_ids)
normalized_folder = None
if folder_path is not None:
normalized_folder = folder_path.replace("\\", "/").strip("/")
return target_set, normalized_folder
async def _collect_local_versions(
self,
scanner,
*,
target_model_ids: Optional[Sequence[int]] = None,
folder_path: Optional[str] = None,
) -> Dict[int, List[int]]:
cache = await scanner.get_cached_data()
mapping: Dict[int, set[int]] = {}
target_set, normalized_folder = self._prepare_collection_filters(
target_model_ids, folder_path
)
if target_model_ids and not target_set:
return {}
for model_id, version_id, _base_model in self._iter_local_civitai_items(
cache, target_set=target_set, normalized_folder=normalized_folder
):
mapping.setdefault(model_id, set()).add(version_id) mapping.setdefault(model_id, set()).add(version_id)
return {model_id: sorted(ids) for model_id, ids in mapping.items()} return {model_id: sorted(ids) for model_id, ids in mapping.items()}
async def _collect_local_version_bases(
self,
scanner,
*,
target_model_ids: Optional[Sequence[int]] = None,
) -> Dict[int, str]:
"""Map version id -> base model from cache items.
Deliberately unfiltered by folder: synthesized in-library entries must
carry a base regardless of which folder triggered the refresh.
"""
cache = await scanner.get_cached_data()
bases: Dict[int, str] = {}
target_set, _normalized_folder = self._prepare_collection_filters(
target_model_ids, None
)
if target_model_ids and not target_set:
return {}
for _model_id, version_id, base_model in self._iter_local_civitai_items(
cache, target_set=target_set
):
normalized_base = _normalize_string(base_model)
if normalized_base:
bases[version_id] = normalized_base
return bases
def _merge_with_local_versions( def _merge_with_local_versions(
self, self,
existing: Optional[ModelUpdateRecord], existing: Optional[ModelUpdateRecord],
@@ -1493,6 +1616,7 @@ class ModelUpdateService:
timestamp: float, timestamp: float,
*, *,
all_local_version_ids: Optional[Sequence[int]] = None, all_local_version_ids: Optional[Sequence[int]] = None,
local_base_models: Optional[Mapping[int, str]] = None,
) -> ModelUpdateRecord: ) -> ModelUpdateRecord:
local_set = set(local_versions) local_set = set(local_versions)
# When folder-filtering, also consider versions in other folders # When folder-filtering, also consider versions in other folders
@@ -1504,6 +1628,7 @@ class ModelUpdateService:
) )
ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {} ignore_map = {version.version_id: version.should_ignore for version in existing.versions} if existing else {}
preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {} preview_map = {version.version_id: version.preview_url for version in existing.versions} if existing else {}
file_count_map = {version.version_id: version.file_count for version in existing.versions} if existing else {}
sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {} sort_map = {version.version_id: version.sort_index for version in existing.versions} if existing else {}
existing_map = {version.version_id: version for version in existing.versions} if existing else {} existing_map = {version.version_id: version for version in existing.versions} if existing else {}
@@ -1528,11 +1653,17 @@ class ModelUpdateService:
usage_control=remote_version.usage_control, usage_control=remote_version.usage_control,
paid_access=remote_version.paid_access, paid_access=remote_version.paid_access,
is_paid=remote_version.is_paid, is_paid=remote_version.is_paid,
file_count=(
remote_version.file_count
if remote_version.file_count is not None
else file_count_map.get(version_id)
),
) )
) )
missing_local = local_set - seen_ids missing_local = local_set - seen_ids
if missing_local: if missing_local:
item_base_models = local_base_models or {}
for version_id in sorted(missing_local): for version_id in sorted(missing_local):
existing_version = existing_map.get(version_id) existing_version = existing_map.get(version_id)
if existing_version: if existing_version:
@@ -1547,7 +1678,7 @@ class ModelUpdateService:
ModelVersionRecord( ModelVersionRecord(
version_id=version_id, version_id=version_id,
name=None, name=None,
base_model=None, base_model=item_base_models.get(version_id),
released_at=None, released_at=None,
size_bytes=None, size_bytes=None,
preview_url=None, preview_url=None,
@@ -1620,6 +1751,7 @@ class ModelUpdateService:
base_model = _normalize_string(entry.get("baseModel")) base_model = _normalize_string(entry.get("baseModel"))
released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt")) released_at = _normalize_string(entry.get("publishedAt") or entry.get("createdAt"))
size_bytes = self._extract_size_bytes(entry.get("files")) size_bytes = self._extract_size_bytes(entry.get("files"))
file_count = self._extract_file_count(entry.get("files"))
preview_url = self._extract_preview_url(entry.get("images")) preview_url = self._extract_preview_url(entry.get("images"))
early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt")) early_access_ends_at = _normalize_string(entry.get("earlyAccessEndsAt"))
@@ -1655,6 +1787,7 @@ class ModelUpdateService:
usage_control=usage_control, usage_control=usage_control,
paid_access=paid_access_json, paid_access=paid_access_json,
is_paid=is_paid, is_paid=is_paid,
file_count=file_count,
) )
@staticmethod @staticmethod
@@ -1683,6 +1816,25 @@ class ModelUpdateService:
return None return None
return {"permanent": permanent, "endsAt": ends_at} return {"permanent": permanent, "endsAt": ends_at}
@staticmethod
def _extract_file_count(files) -> Optional[int]:
"""Count downloadable weight files in a version entry's ``files`` list.
Returns None when the payload carries no files array (unknown), so
callers can distinguish "no weight files" from "no data".
"""
if not isinstance(files, list):
return None
count = 0
for entry in files:
if not isinstance(entry, Mapping):
continue
entry_type = entry.get("type")
if isinstance(entry_type, str) and entry_type in MODEL_WEIGHT_FILE_TYPES:
count += 1
return count
def _extract_size_bytes(self, files) -> Optional[int]: def _extract_size_bytes(self, files) -> Optional[int]:
if not isinstance(files, Iterable): if not isinstance(files, Iterable):
return None return None
@@ -1795,7 +1947,7 @@ class ModelUpdateService:
f""" f"""
SELECT model_id, version_id, sort_index, name, base_model, released_at, SELECT model_id, version_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid is_early_access, usage_control, paid_access, is_paid, file_count
FROM model_update_versions FROM model_update_versions
WHERE model_id IN ({placeholders}) WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1826,6 +1978,7 @@ class ModelUpdateService:
usage_control=row["usage_control"], usage_control=row["usage_control"],
paid_access=row["paid_access"], paid_access=row["paid_access"],
is_paid=bool(row["is_paid"]), is_paid=bool(row["is_paid"]),
file_count=_normalize_int(row["file_count"]),
) )
) )
@@ -1888,8 +2041,8 @@ class ModelUpdateService:
INSERT INTO model_update_versions ( INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at, version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at, size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control, paid_access, is_paid is_early_access, usage_control, paid_access, is_paid, file_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
( (
version.version_id, version.version_id,
@@ -1907,6 +2060,7 @@ class ModelUpdateService:
version.usage_control, version.usage_control,
paid_access_value, paid_access_value,
1 if version.is_paid else 0, 1 if version.is_paid else 0,
version.file_count,
), ),
) )
conn.commit() conn.commit()
+31 -6
View File
@@ -500,10 +500,36 @@ class PendingDeleteService:
QUARANTINE them (preserving the pre-registry sweep semantics). The QUARANTINE them (preserving the pre-registry sweep semantics). The
walk only descends into dirs literally named ``.lm-pending-delete``, walk only descends into dirs literally named ``.lm-pending-delete``,
so false positives are structurally limited. so false positives are structurally limited.
The filesystem walk itself runs in a worker thread so a large or slow
library cannot block the event loop at startup; only the (rare) batch
registration awaits run on the loop.
"""
roots = await self._get_all_model_roots()
loop = asyncio.get_event_loop()
staging_parents = await loop.run_in_executor(
None, # Use default thread pool
self._collect_staging_parents, # Run the tree walk off the loop
roots,
)
for staging_parent in staging_parents:
await self._register_batch_candidates(staging_parent)
def _collect_staging_parents(self, roots: Sequence[str]) -> List[str]:
"""Walk every model root and return its staging-parent dirs.
Pure synchronous filesystem discovery with no awaits: walks with
``followlinks=True, topdown=True``, prunes symlink cycles via a
per-root ``visited`` realpath set (realpath is used ONLY for this
dedup set - the returned paths are the unresolved business paths),
filters out :func:`_is_excluded_dir` dirs, and collects every dir
named ``.lm-pending-delete`` (including the case where a model root
itself is one). Results are returned in walk order.
""" """
from .model_scanner import _is_excluded_dir from .model_scanner import _is_excluded_dir
for root in await self._get_all_model_roots(): staging_parents: List[str] = []
for root in roots:
if not os.path.isdir(root): if not os.path.isdir(root):
continue continue
visited: Set[str] = set() visited: Set[str] = set()
@@ -518,21 +544,20 @@ class PendingDeleteService:
visited.add(real_dir) visited.add(real_dir)
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME: if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
# The current dir IS a staging parent (reachable only when # The current dir IS a staging parent (reachable only when
# a model root itself is one): register its batches. # a model root itself is one): collect its batches.
await self._register_batch_candidates(dirpath) staging_parents.append(dirpath)
dirnames[:] = [] dirnames[:] = []
continue continue
next_dirs: List[str] = [] next_dirs: List[str] = []
for name in dirnames: for name in dirnames:
if name == PENDING_DELETE_DIR_NAME: if name == PENDING_DELETE_DIR_NAME:
await self._register_batch_candidates( staging_parents.append(os.path.join(dirpath, name))
os.path.join(dirpath, name)
)
elif _is_excluded_dir(name): elif _is_excluded_dir(name):
continue continue
else: else:
next_dirs.append(name) next_dirs.append(name)
dirnames[:] = next_dirs dirnames[:] = next_dirs
return staging_parents
async def _register_batch_candidates(self, staging_parent: str) -> None: async def _register_batch_candidates(self, staging_parent: str) -> None:
"""Register every non-orphaned batch subdir of a staging parent.""" """Register every non-orphaned batch subdir of a staging parent."""
+50 -1
View File
@@ -58,6 +58,7 @@ class PersistentRecipeCache:
"checkpoint_json", "checkpoint_json",
"gen_params_json", "gen_params_json",
"tags_json", "tags_json",
"has_workflow",
) )
_instances: Dict[str, "PersistentRecipeCache"] = {} _instances: Dict[str, "PersistentRecipeCache"] = {}
_instance_lock = threading.Lock() _instance_lock = threading.Lock()
@@ -332,6 +333,44 @@ class PersistentRecipeCache:
except Exception as exc: except Exception as exc:
logger.debug("Failed to persist image_id_map: %s", exc) logger.debug("Failed to persist image_id_map: %s", exc)
def get_metadata_value(self, key: str) -> Optional[str]:
"""Return a value from cache_metadata, or None if missing."""
if not self.is_enabled() or not self._schema_initialized:
return None
try:
with self._db_lock:
conn = self._connect(readonly=True)
try:
row = conn.execute(
"SELECT value FROM cache_metadata WHERE key = ?",
(key,),
).fetchone()
return row["value"] if row else None
finally:
conn.close()
except Exception:
return None
def set_metadata_value(self, key: str, value: str) -> None:
"""Store a value in cache_metadata without rewriting the full cache."""
if not self.is_enabled() or not self._schema_initialized:
return
try:
with self._db_lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO cache_metadata (key, value) VALUES (?, ?)",
(key, value),
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to persist cache metadata %s: %s", key, exc)
def get_indexed_recipe_ids(self) -> Set[str]: def get_indexed_recipe_ids(self) -> Set[str]:
"""Return all recipe IDs in the cache. """Return all recipe IDs in the cache.
@@ -407,7 +446,8 @@ class PersistentRecipeCache:
loras_json TEXT, loras_json TEXT,
checkpoint_json TEXT, checkpoint_json TEXT,
gen_params_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); CREATE INDEX IF NOT EXISTS idx_recipes_json_path ON recipes(json_path);
@@ -426,6 +466,13 @@ class PersistentRecipeCache:
) )
except Exception: except Exception:
pass # column already exists 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() conn.commit()
self._schema_initialized = True self._schema_initialized = True
except Exception as exc: except Exception as exc:
@@ -488,6 +535,7 @@ class PersistentRecipeCache:
checkpoint_json, checkpoint_json,
gen_params_json, gen_params_json,
tags_json, tags_json,
1 if recipe.get("has_workflow") else 0,
) )
def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]: def _row_to_recipe(self, row: sqlite3.Row) -> Dict[str, Any]:
@@ -533,6 +581,7 @@ class PersistentRecipeCache:
"favorite": bool(row["favorite"]), "favorite": bool(row["favorite"]),
"repair_version": row["repair_version"] or 0, "repair_version": row["repair_version"] or 0,
"preview_nsfw_level": row["preview_nsfw_level"] or 0, "preview_nsfw_level": row["preview_nsfw_level"] or 0,
"has_workflow": bool(row["has_workflow"]),
"loras": loras, "loras": loras,
"gen_params": gen_params, "gen_params": gen_params,
} }
+167 -7
View File
@@ -7,13 +7,14 @@ enabling sub-100ms search times even with 20k+ recipes.
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import hashlib
import logging import logging
import os import os
import re import re
import sqlite3 import sqlite3
import threading import threading
import time import time
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set, Tuple
from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration from ..utils.cache_paths import CacheType, resolve_cache_path_with_migration
@@ -165,6 +166,7 @@ class RecipeFTSIndex:
batch_size = 500 batch_size = 500
total = len(recipes) total = len(recipes)
inserted = 0 inserted = 0
indexed_ids: Set[str] = set()
for i in range(0, total, batch_size): for i in range(0, total, batch_size):
batch = recipes[i:i + batch_size] batch = recipes[i:i + batch_size]
@@ -179,6 +181,7 @@ class RecipeFTSIndex:
row = self._prepare_fts_row(recipe) row = self._prepare_fts_row(recipe)
rows.append(row) rows.append(row)
inserted += 1 inserted += 1
indexed_ids.add(recipe_id)
if rows: if rows:
# Insert into FTS table # Insert into FTS table
@@ -213,7 +216,11 @@ class RecipeFTSIndex:
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)", "INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
('recipe_count', str(inserted)) (self._COUNT_METADATA_KEY, str(inserted))
)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(indexed_ids))
) )
conn.commit() conn.commit()
@@ -288,6 +295,12 @@ class RecipeFTSIndex:
with self._lock: with self._lock:
conn = self._connect() conn = self._connect()
try: try:
# Check existence via the rowid mapping (fast PK lookup)
existed = conn.execute(
"SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
(recipe_id,)
).fetchone() is not None
# Remove existing entry if present # Remove existing entry if present
self._remove_recipe_locked(conn, recipe_id) self._remove_recipe_locked(conn, recipe_id)
@@ -312,6 +325,10 @@ class RecipeFTSIndex:
(recipe_id, result[0]) (recipe_id, result[0])
) )
# Keep validation metadata in sync (only a new id changes it)
if not existed:
self._update_mutation_metadata_locked(conn, recipe_id, delta=1)
conn.commit() conn.commit()
return True return True
finally: finally:
@@ -339,7 +356,13 @@ class RecipeFTSIndex:
with self._lock: with self._lock:
conn = self._connect() conn = self._connect()
try: try:
existed = conn.execute(
"SELECT 1 FROM recipe_rowid WHERE recipe_id = ?",
(recipe_id,)
).fetchone() is not None
self._remove_recipe_locked(conn, recipe_id) self._remove_recipe_locked(conn, recipe_id)
if existed:
self._update_mutation_metadata_locked(conn, recipe_id, delta=-1)
conn.commit() conn.commit()
return True return True
finally: finally:
@@ -371,6 +394,15 @@ class RecipeFTSIndex:
try: try:
conn.execute("DELETE FROM recipe_fts") conn.execute("DELETE FROM recipe_fts")
conn.execute("DELETE FROM recipe_rowid") conn.execute("DELETE FROM recipe_rowid")
# Reset validation metadata to the empty index state
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._COUNT_METADATA_KEY, '0')
)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(set()))
)
conn.commit() conn.commit()
self._ready.clear() self._ready.clear()
return True return True
@@ -427,10 +459,12 @@ class RecipeFTSIndex:
"""Check if the FTS index matches the expected recipes. """Check if the FTS index matches the expected recipes.
This method validates whether the existing FTS index can be reused This method validates whether the existing FTS index can be reused
without a full rebuild. It checks: without a full rebuild. It compares the expected count and recipe ID
1. The index has been initialized fingerprint against metadata recorded when the index was (re)built,
2. The count matches so it does not scan the FTS content table. Indexes built by older
3. The recipe IDs match versions lack this metadata; for those the validation falls back to
a one-time scan of the content table and records the metadata so
subsequent startups are cheap.
Args: Args:
recipe_count: Expected number of recipes. recipe_count: Expected number of recipes.
@@ -446,7 +480,28 @@ class RecipeFTSIndex:
return False return False
try: try:
metadata = self._read_validation_metadata()
if metadata is not None:
stored_count, stored_fingerprint = metadata
if stored_count != recipe_count:
logger.debug(
"FTS index count mismatch: indexed=%d, expected=%d",
stored_count, recipe_count
)
return False
if stored_fingerprint != self._compute_ids_fingerprint(recipe_ids):
logger.debug("FTS index recipe ID fingerprint mismatch")
return False
return True
# Legacy fallback: no stored metadata, scan the content table once
# and persist the metadata so later validations are cheap.
indexed_count = self.get_indexed_count() indexed_count = self.get_indexed_count()
indexed_ids = self.get_indexed_recipe_ids()
self._store_validation_metadata(indexed_count, indexed_ids)
if indexed_count != recipe_count: if indexed_count != recipe_count:
logger.debug( logger.debug(
"FTS index count mismatch: indexed=%d, expected=%d", "FTS index count mismatch: indexed=%d, expected=%d",
@@ -454,7 +509,6 @@ class RecipeFTSIndex:
) )
return False return False
indexed_ids = self.get_indexed_recipe_ids()
if indexed_ids != recipe_ids: if indexed_ids != recipe_ids:
missing = recipe_ids - indexed_ids missing = recipe_ids - indexed_ids
extra = indexed_ids - recipe_ids extra = indexed_ids - recipe_ids
@@ -471,6 +525,112 @@ class RecipeFTSIndex:
# Internal helpers # Internal helpers
_FINGERPRINT_METADATA_KEY = 'recipe_ids_fingerprint'
_COUNT_METADATA_KEY = 'recipe_count'
@staticmethod
def _fingerprint_recipe_id(recipe_id: str) -> int:
"""Return a stable 64-bit fingerprint contribution for a recipe ID."""
digest = hashlib.sha256(recipe_id.encode("utf-8")).digest()
return int.from_bytes(digest[:8], "big")
@classmethod
def _compute_ids_fingerprint(cls, recipe_ids: Set[str]) -> str:
"""Order-independent fingerprint of a recipe ID set (XOR of per-id hashes)."""
fingerprint = 0
for recipe_id in recipe_ids:
fingerprint ^= cls._fingerprint_recipe_id(str(recipe_id))
return f"{fingerprint:016x}"
def _read_validation_metadata(self) -> Optional[Tuple[int, str]]:
"""Return stored (recipe count, ID fingerprint), or None if absent."""
try:
with self._lock:
conn = self._connect(readonly=True)
try:
rows = conn.execute(
"SELECT key, value FROM fts_metadata WHERE key IN (?, ?)",
(self._COUNT_METADATA_KEY, self._FINGERPRINT_METADATA_KEY)
).fetchall()
values = {row[0]: row[1] for row in rows}
fingerprint = values.get(self._FINGERPRINT_METADATA_KEY)
if fingerprint is None:
return None
try:
count = int(values.get(self._COUNT_METADATA_KEY) or 0)
except (TypeError, ValueError):
return None
return count, fingerprint
finally:
conn.close()
except FileNotFoundError:
return None
except Exception as exc:
logger.debug("Failed to read FTS validation metadata: %s", exc)
return None
def _store_validation_metadata(self, recipe_count: int, recipe_ids: Set[str]) -> None:
"""Persist recipe count and ID fingerprint for cheap future validation."""
try:
with self._lock:
conn = self._connect()
try:
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._COUNT_METADATA_KEY, str(recipe_count))
)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._FINGERPRINT_METADATA_KEY, self._compute_ids_fingerprint(recipe_ids))
)
conn.commit()
finally:
conn.close()
except Exception as exc:
logger.debug("Failed to store FTS validation metadata: %s", exc)
def _update_mutation_metadata_locked(
self,
conn: sqlite3.Connection,
recipe_id: str,
delta: int,
) -> None:
"""Incrementally maintain validation metadata after add/remove.
Caller must hold the lock. The fingerprint is only updated when it
already exists; without it, validation falls back to a one-time scan
that records fresh metadata.
"""
fingerprint_row = conn.execute(
"SELECT value FROM fts_metadata WHERE key = ?",
(self._FINGERPRINT_METADATA_KEY,)
).fetchone()
if fingerprint_row and fingerprint_row[0]:
try:
fingerprint = int(fingerprint_row[0], 16)
except ValueError:
fingerprint = None
if fingerprint is not None:
fingerprint ^= self._fingerprint_recipe_id(recipe_id)
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._FINGERPRINT_METADATA_KEY, f"{fingerprint & 0xFFFFFFFFFFFFFFFF:016x}")
)
count_row = conn.execute(
"SELECT value FROM fts_metadata WHERE key = ?",
(self._COUNT_METADATA_KEY,)
).fetchone()
if count_row:
try:
count = max(0, int(count_row[0] or 0) + delta)
except (TypeError, ValueError):
return
conn.execute(
"INSERT OR REPLACE INTO fts_metadata (key, value) VALUES (?, ?)",
(self._COUNT_METADATA_KEY, str(count))
)
def _connect(self, readonly: bool = False) -> sqlite3.Connection: def _connect(self, readonly: bool = False) -> sqlite3.Connection:
"""Create a database connection.""" """Create a database connection."""
uri = False uri = False
+248 -53
View File
@@ -13,10 +13,13 @@ import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES 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.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats from ..utils.recipe_open_stats import RecipeOpenStats
from .model_scanner import WEIGHT_FILE_EXTENSIONS
from .recipe_cache import RecipeCache from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from .websocket_manager import ws_manager
from natsort import natsorted from natsort import natsorted
import sys import sys
import re import re
@@ -36,10 +39,8 @@ logger = logging.getLogger(__name__)
# explicitly to "diffusion_model" (mirrors Oracle R2-F1). # explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"} _CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
# Known weight-file extensions stripped by _normalize_filename_key. Names are # Valid LoRA availability statuses for the recipe listing filter.
# stored extensionless on both sides, so splitext would misread dotted stems _VALID_LORA_AVAILABILITY_STATUSES = frozenset({"ready", "missing", "deleted"})
# ("my.mix" -> "my") and silently collide distinct models.
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
class RecipeScanner: class RecipeScanner:
@@ -179,13 +180,15 @@ class RecipeScanner:
Only known weight-file extensions are stripped names are stored Only known weight-file extensions are stripped names are stored
extensionless on both sides, so splitext would misread dotted stems 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: if not name:
return "" return ""
basename = os.path.basename(name.replace("\\", "/")) basename = os.path.basename(name.replace("\\", "/"))
lower = basename.lower() lower = basename.lower()
for ext in _WEIGHT_FILE_EXTS: for ext in sorted(WEIGHT_FILE_EXTENSIONS, key=len, reverse=True):
if lower.endswith(ext): if lower.endswith(ext):
basename = basename[: -len(ext)] basename = basename[: -len(ext)]
break break
@@ -482,6 +485,10 @@ class RecipeScanner:
return str(value) return str(value)
return "unknown" return "unknown"
def is_initializing(self) -> bool:
"""Check if the scanner is currently initializing"""
return self._is_initializing
def on_library_changed(self) -> None: def on_library_changed(self) -> None:
"""Reset cached state when the active library changes.""" """Reset cached state when the active library changes."""
@@ -1405,7 +1412,20 @@ class RecipeScanner:
async def initialize_in_background(self) -> None: async def initialize_in_background(self) -> None:
"""Initialize cache in background using thread pool""" """Initialize cache in background using thread pool"""
# Mark as initializing before any await so concurrent callers can
# wait on this task instead of observing the placeholder empty cache
# (the LoRA scanner wait below can take a while at startup).
self._is_initializing = True
self._initialization_task = asyncio.current_task()
try: try:
await ws_manager.broadcast_init_progress({
'stage': 'loading_cache',
'progress': 0,
'details': 'Loading recipe cache...',
'scanner_type': 'recipe',
'pageType': 'recipes',
})
await self._wait_for_lora_scanner() await self._wait_for_lora_scanner()
# Set initial empty cache to avoid None reference errors # Set initial empty cache to avoid None reference errors
@@ -1418,39 +1438,61 @@ class RecipeScanner:
folder_tree={}, folder_tree={},
) )
# Mark as initializing to prevent concurrent initializations # Start timer
self._is_initializing = True start_time = time.time()
self._initialization_task = asyncio.current_task()
try: # Use thread pool to execute CPU-intensive operations
# Start timer loop = asyncio.get_event_loop()
start_time = time.time() cache = await loop.run_in_executor(
None, # Use default thread pool
self._initialize_recipe_cache_sync, # Run synchronous version in thread
)
if cache is not None:
self._cache = cache
# Use thread pool to execute CPU-intensive operations # Calculate elapsed time and log it
loop = asyncio.get_event_loop() elapsed_time = time.time() - start_time
cache = await loop.run_in_executor( recipe_count = (
None, # Use default thread pool len(cache.raw_data) if cache and hasattr(cache, "raw_data") else 0
self._initialize_recipe_cache_sync, # Run synchronous version in thread )
) logger.info(
if cache is not None: f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes"
self._cache = cache )
await ws_manager.broadcast_init_progress({
# Calculate elapsed time and log it 'stage': 'finalizing',
elapsed_time = time.time() - start_time 'progress': 100,
recipe_count = ( 'status': 'complete',
len(cache.raw_data) if cache and hasattr(cache, "raw_data") else 0 'details': f'Found {recipe_count} recipes.',
) 'scanner_type': 'recipe',
logger.info( 'pageType': 'recipes',
f"Recipe cache initialized in {elapsed_time:.2f} seconds. Found {recipe_count} recipes" })
) self._schedule_post_scan_enrichment()
self._schedule_post_scan_enrichment() # Schedule FTS index build in background (non-blocking)
# Schedule FTS index build in background (non-blocking) self._schedule_fts_index_build()
self._schedule_fts_index_build()
finally:
# Mark initialization as complete regardless of outcome
self._is_initializing = False
except Exception as e: except Exception as e:
logger.error(f"Recipe Scanner: Error initializing cache in background: {e}") logger.error(f"Recipe Scanner: Error initializing cache in background: {e}")
# Ensure the cache is never None so the page stops showing the
# initialization screen, and let waiting clients reload into the
# regular (possibly empty) view instead of stalling.
if self._cache is None:
self._cache = RecipeCache(
raw_data=[],
sorted_by_name=[],
sorted_by_date=[],
folders=[],
folder_tree={},
)
await ws_manager.broadcast_init_progress({
'stage': 'finalizing',
'progress': 100,
'status': 'complete',
'details': 'Recipe cache initialization failed.',
'scanner_type': 'recipe',
'pageType': 'recipes',
})
finally:
# Mark initialization as complete regardless of outcome
self._is_initializing = False
def _initialize_recipe_cache_sync(self): def _initialize_recipe_cache_sync(self):
"""Synchronous version of recipe cache initialization for thread pool execution. """Synchronous version of recipe cache initialization for thread pool execution.
@@ -1505,7 +1547,7 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration) # Backfill source_path from JSON files if missing (one-shot schema migration)
if self._backfill_source_path_if_needed(recipes, json_paths): if self._backfill_source_path_if_needed(recipes, json_paths):
self._cache.image_id_map = self._build_image_id_map() self._cache.image_id_map = self._build_image_id_map()
self._persistent_cache.save_cache( self._persistent_cache.save_cache(
@@ -1532,7 +1574,7 @@ class RecipeScanner:
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache) self._update_folder_metadata(self._cache)
self._sort_cache_sync() self._sort_cache_sync()
# Backfill source_path from JSON files if missing (schema migration) # Backfill source_path from JSON files if missing (one-shot schema migration)
self._backfill_source_path_if_needed(recipes, json_paths) self._backfill_source_path_if_needed(recipes, json_paths)
self._cache.image_id_map = self._build_image_id_map() self._cache.image_id_map = self._build_image_id_map()
# Persist updated cache # Persist updated cache
@@ -1669,6 +1711,9 @@ class RecipeScanner:
return recipes, changed, json_paths return recipes, changed, json_paths
# Metadata key recording that the one-shot source_path backfill has run.
_SOURCE_PATH_BACKFILL_MARKER = "source_path_backfilled"
def _backfill_source_path_if_needed( def _backfill_source_path_if_needed(
self, self,
recipes: List[Dict[str, Any]], recipes: List[Dict[str, Any]],
@@ -1676,8 +1721,21 @@ class RecipeScanner:
) -> bool: ) -> bool:
"""Backfill source_path from recipe JSON files if missing from cache. """Backfill source_path from recipe JSON files if missing from cache.
This is a one-shot schema migration: once it has run, a completion
marker is stored in the persistent cache metadata and later startups
skip it entirely. Recipes without a source_path in their JSON file
would otherwise be re-read and re-parsed on every startup. New or
changed recipe files still get source_path from the normal parse path
during reconciliation.
Returns True if any recipes were updated (caller should persist cache). Returns True if any recipes were updated (caller should persist cache).
""" """
cache = self._persistent_cache
if (
cache is not None
and cache.get_metadata_value(self._SOURCE_PATH_BACKFILL_MARKER) == "1"
):
return False
updated = False updated = False
for recipe in recipes: for recipe in recipes:
if recipe.get("source_path"): if recipe.get("source_path"):
@@ -1695,6 +1753,8 @@ class RecipeScanner:
updated = True updated = True
except Exception: except Exception:
pass pass
if cache is not None:
cache.set_metadata_value(self._SOURCE_PATH_BACKFILL_MARKER, "1")
return updated return updated
def _full_directory_scan_sync( def _full_directory_scan_sync(
@@ -1731,6 +1791,23 @@ class RecipeScanner:
return recipes, json_paths 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]]: def _load_recipe_file_sync(self, recipe_path: str) -> Optional[Dict[str, Any]]:
"""Load a single recipe file synchronously. """Load a single recipe file synchronously.
@@ -1787,6 +1864,19 @@ class RecipeScanner:
except Exception as e: except Exception as e:
logger.warning(f"Failed to persist repair for {recipe_path}: {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 # Track folder placement relative to recipes directory
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder( recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
recipe_path recipe_path
@@ -2225,21 +2315,28 @@ class RecipeScanner:
async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache: async def get_cached_data(self, force_refresh: bool = False) -> RecipeCache:
"""Get cached recipe data, refresh if needed""" """Get cached recipe data, refresh if needed"""
# If a background initialization is in progress, wait for it to
# complete so callers never observe the placeholder empty cache.
initialization_task = self._initialization_task
if (
self._is_initializing
and not force_refresh
and initialization_task is not None
and initialization_task is not asyncio.current_task()
and not initialization_task.done()
):
try:
await initialization_task
except Exception:
# Initialization failures are logged by the task itself; fall
# through and return whatever cache state we have.
pass
# If cache is already initialized and no refresh is needed, return it immediately # If cache is already initialized and no refresh is needed, return it immediately
if self._cache is not None and not force_refresh: if self._cache is not None and not force_refresh:
self._update_folder_metadata() self._update_folder_metadata()
return cast(RecipeCache, self._cache) return cast(RecipeCache, self._cache)
# If another initialization is already in progress, wait for it to complete
if self._is_initializing and not force_refresh:
return self._cache or RecipeCache(
raw_data=[],
sorted_by_name=[],
sorted_by_date=[],
folders=[],
folder_tree={},
)
# If force refresh is requested, re-scan in a thread pool to avoid # If force refresh is requested, re-scan in a thread pool to avoid
# blocking the event loop (which is shared with ComfyUI). # blocking the event loop (which is shared with ComfyUI).
if force_refresh: if force_refresh:
@@ -2472,6 +2569,13 @@ class RecipeScanner:
if path_updated: if path_updated:
self._write_recipe_file(recipe_path, recipe_data) 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 # Track folder placement relative to recipes directory
recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder( recipe_data["folder"] = recipe_data.get("folder") or self._calculate_folder(
recipe_path recipe_path
@@ -2911,6 +3015,43 @@ class RecipeScanner:
return lora return lora
def _compute_availability_statuses(self, recipe: Dict[str, Any]) -> Set[str]:
"""Compute the LoRA availability status set for a recipe.
Returns ``{"ready"}`` when every non-excluded LoRA resolves to the
local library (recipes without LoRAs count as ready); otherwise a
subset of ``{"missing", "deleted"}``. Uses the same inLibrary
resolution as ``_enrich_lora_entry`` (hash index with modelVersionId
fallback) but performs only in-memory lookups.
"""
statuses: Set[str] = set()
for lora in recipe.get("loras") or []:
if not isinstance(lora, dict) or lora.get("exclude"):
continue
in_library = False
if self._lora_scanner:
hash_value = (lora.get("hash") or "").lower()
if hash_value:
in_library = self._lora_scanner.has_hash(hash_value)
elif lora.get("modelVersionId") is not None:
in_library = (
self._get_lora_from_version_index(lora.get("modelVersionId"))
is not None
)
if in_library:
continue
if lora.get("isDeleted"):
statuses.add("deleted")
else:
statuses.add("missing")
if not statuses:
statuses.add("ready")
return statuses
def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]: def _normalize_preview_url(self, preview_url: Optional[str]) -> Optional[str]:
"""Return a preview URL that is reachable from the browser.""" """Return a preview URL that is reachable from the browser."""
@@ -2926,13 +3067,45 @@ class RecipeScanner:
return normalized return normalized
async def get_local_lora(self, name: str) -> Optional[Dict[str, Any]]: async def get_local_lora(
"""Lookup a local LoRA model by name.""" 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: if not self._lora_scanner or not name:
return None 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]]: async def get_local_checkpoint(self, name: str) -> Optional[Dict[str, Any]]:
"""Lookup a local checkpoint model by name.""" """Lookup a local checkpoint model by name."""
@@ -3146,6 +3319,22 @@ class RecipeScanner:
if not matches_exclude(item.get("tags")) if not matches_exclude(item.get("tags"))
] ]
# Filter by LoRA availability status
availability = filters.get("lora_availability")
if availability:
selected = {
status
for status in availability
if status in _VALID_LORA_AVAILABILITY_STATUSES
}
# Selecting every status (or none) means no filtering.
if 0 < len(selected) < len(_VALID_LORA_AVAILABILITY_STATUSES):
filtered_data = [
item
for item in filtered_data
if self._compute_availability_statuses(item) & selected
]
# Apply sorting if not already handled by pre-sorted cache # Apply sorting if not already handled by pre-sorted cache
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"): if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
field, order = (sort_by.split(":") + ["desc"])[:2] field, order = (sort_by.split(":") + ["desc"])[:2]
@@ -3272,6 +3461,13 @@ class RecipeScanner:
# Format the recipe with all needed information # Format the recipe with all needed information
formatted_recipe = {**merged_recipe} 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 # Format file path to URL
if "file_path" in formatted_recipe: if "file_path" in formatted_recipe:
formatted_recipe["file_url"] = self._format_file_url( formatted_recipe["file_url"] = self._format_file_url(
@@ -3590,9 +3786,6 @@ class RecipeScanner:
syntax_parts: List[str] = [] syntax_parts: List[str] = []
for lora in loras: for lora in loras:
if lora.get("isDeleted", False):
continue
file_name = None file_name = None
folder = "" folder = ""
hash_value = (lora.get("hash") or "").lower() hash_value = (lora.get("hash") or "").lower()
@@ -3627,6 +3820,8 @@ class RecipeScanner:
break break
if not file_name: if not file_name:
if lora.get("isDeleted", False):
continue
file_name = lora.get("file_name", "unknown-lora") file_name = lora.get("file_name", "unknown-lora")
folder = lora.get("folder", "") folder = lora.get("folder", "")
+32 -1
View File
@@ -117,6 +117,7 @@ class RecipePersistenceService:
"loras": loras_data, "loras": loras_data,
"gen_params": gen_params, "gen_params": gen_params,
"fingerprint": fingerprint, "fingerprint": fingerprint,
"has_workflow": self._detect_has_workflow(normalized_image_path),
} }
if checkpoint_entry: if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry recipe_data["checkpoint"] = checkpoint_entry
@@ -426,8 +427,21 @@ class RecipePersistenceService:
if not recipe_path or not os.path.exists(recipe_path): if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found") 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: 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}") raise RecipeNotFoundError(f"Local LoRA not found with name: {target_name}")
recipe_data, updated_lora = await recipe_scanner.update_lora_entry( recipe_data, updated_lora = await recipe_scanner.update_lora_entry(
@@ -602,6 +616,9 @@ class RecipePersistenceService:
if key not in ["checkpoint", "loras"] if key not in ["checkpoint", "loras"]
}, },
"loras_stack": lora_stack, "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: if checkpoint_entry:
recipe_data["checkpoint"] = checkpoint_entry recipe_data["checkpoint"] = checkpoint_entry
@@ -626,6 +643,20 @@ class RecipePersistenceService:
# Helper methods --------------------------------------------------- # 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( async def _build_widget_checkpoint_entry(
self, self,
recipe_scanner, recipe_scanner,
+1 -1
View File
@@ -4,7 +4,7 @@
position: fixed; position: fixed;
top: 0; top: 0;
z-index: var(--z-header); z-index: var(--z-header);
height: 48px; height: var(--header-height, 48px);
/* Reduced height */ /* Reduced height */
width: 100%; width: 100%;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
+68 -25
View File
@@ -77,41 +77,84 @@
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
/* File Input Styles */ .import-description {
.file-input-wrapper { margin-top: 0;
position: relative;
margin-bottom: var(--space-1);
} }
.file-input-wrapper input[type="file"] { /* Unified Drop Zone */
position: absolute; .import-drop-zone {
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
z-index: 2;
}
.file-input-button {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: var(--space-1);
padding: 10px 16px; padding: var(--space-4) var(--space-3);
background: var(--lora-accent); border: 2px dashed var(--border-color);
color: var(--lora-text); border-radius: var(--border-radius-sm);
border-radius: var(--border-radius-xs); background: var(--bg-color);
font-weight: 500; color: var(--text-color);
text-align: center;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; transition: border-color 0.2s, background-color 0.2s;
} }
.file-input-button:hover { .import-drop-zone:hover,
background: oklch(from var(--lora-accent) l c h / 0.9); .import-drop-zone:focus-visible {
border-color: var(--lora-accent);
outline: none;
} }
.file-input-wrapper:hover .file-input-button { .import-drop-zone.drag-over {
background: oklch(from var(--lora-accent) l c h / 0.9); 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 */ /* Recipe Details Layout */
@@ -68,6 +68,39 @@
font-size: 14px; font-size: 14px;
} }
/* Destructive modal action: ghost icon button right-anchored by its own auto
margin, revealing the danger color only on hover/focus. Shared by the model
modal and the recipe modal. */
.modal-delete-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
margin-left: auto;
background: transparent;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
.modal-delete-btn:hover,
.modal-delete-btn:focus-visible {
color: var(--lora-error);
border-color: var(--lora-error);
background: oklch(from var(--lora-error) l c h / 0.08);
}
.modal-delete-btn i {
font-size: 14px;
}
/* When license icons directly precede the delete button, they carry the auto
margin instead, so the [license][delete] cluster stays right-anchored as
one group with the delete button flush at the right edge and no split gap. */
.modal-header-actions .license-restrictions { .modal-header-actions .license-restrictions {
margin-left: auto; margin-left: auto;
} }
@@ -76,6 +109,11 @@
margin-left: auto; margin-left: auto;
} }
.modal-header-actions .license-restrictions + .modal-delete-btn,
.modal-header-actions .license-permissions + .modal-delete-btn {
margin-left: 0;
}
.license-restrictions { .license-restrictions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -216,6 +254,62 @@
justify-content: space-between; justify-content: space-between;
} }
/* Hashes footnote borderless full-width muted line; reads as a footnote
to the file info grid rather than a peer field */
.hash-footnote {
grid-column: 1 / -1;
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 8px;
padding: 0 var(--space-1);
color: var(--text-color);
}
.hash-footnote .hash-entry {
display: inline-flex;
align-items: baseline;
gap: 6px;
}
.hash-footnote .hash-kind {
font-size: 0.7em;
opacity: 0.5;
text-transform: uppercase;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.hash-footnote .model-hash-value {
font-family: monospace;
font-size: 0.8em;
opacity: 0.6;
white-space: nowrap;
}
.hash-footnote .hash-sep {
opacity: 0.3;
font-size: 0.8em;
}
.hash-footnote .hash-copy-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 2px;
border: none;
background: none;
color: var(--text-color);
opacity: 0.35;
font-size: 0.7em;
cursor: pointer;
flex-shrink: 0;
}
.hash-footnote .hash-copy-btn:hover {
opacity: 0.9;
}
/* Toggle button — icon only, inline with the label */ /* Toggle button — icon only, inline with the label */
.notes-toggle-btn { .notes-toggle-btn {
display: none; /* shown by JS when content exceeds threshold */ display: none; /* shown by JS when content exceeds threshold */
+274 -47
View File
@@ -4,19 +4,268 @@
margin-top: var(--space-4); margin-top: var(--space-4);
} }
.carousel { /* Gallery: collapsed indicator bar + expanded main viewer with thumbnail strip */
transition: max-height 0.3s ease-in-out;
/* 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; overflow: hidden;
} }
.carousel.collapsed { .main-media-container .media-wrapper {
max-height: 0; 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; display: flex;
flex-direction: column; gap: var(--space-1);
gap: var(--space-2); 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 { .media-wrapper {
@@ -31,16 +280,6 @@
margin-bottom: 0; margin-bottom: 0;
} }
.media-wrapper img,
.media-wrapper video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: contain;
}
.no-examples { .no-examples {
text-align: center; text-align: center;
padding: var(--space-3); padding: var(--space-3);
@@ -48,11 +287,6 @@
opacity: 0.7; 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 */ /* Add styles for blurred showcase content */
.nsfw-media-wrapper { .nsfw-media-wrapper {
position: relative; position: relative;
@@ -217,6 +451,24 @@
pointer-events: auto; 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 */ /* Adjust to dark theme */
[data-theme="dark"] .image-metadata-panel { [data-theme="dark"] .image-metadata-panel {
background: var(--card-bg); background: var(--card-bg);
@@ -388,31 +640,6 @@
opacity: 0.8; 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 { .lazy {
opacity: 0; opacity: 0;
transition: opacity 0.3s; transition: opacity 0.3s;
+3 -1
View File
@@ -6,7 +6,9 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: calc(100% - var(--header-height, 48px)); /* Adjust height to exclude header */ 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); z-index: var(--z-modal);
overflow: auto; /* Change from hidden to auto to allow scrolling */ overflow: auto; /* Change from hidden to auto to allow scrolling */
} }
@@ -13,7 +13,10 @@
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
/* Darker than --modal-backdrop-bg to stress destructive actions, but keeps the shared blur */
background: rgba(0, 0, 0, 0.8); 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); z-index: var(--z-overlay);
} }
@@ -514,6 +514,7 @@
background: oklch(var(--lora-accent) / 0.18); background: oklch(var(--lora-accent) / 0.18);
color: var(--lora-accent); color: var(--lora-accent);
font-size: inherit; font-size: inherit;
font-family: inherit;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
transition: var(--transition-base); transition: var(--transition-base);
@@ -603,6 +604,51 @@
cursor: pointer; 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 { .file-option-info {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
+80 -47
View File
@@ -9,6 +9,21 @@
position: relative; position: relative;
} }
/* Header row: title + nav controls. Padding reserves space for the
absolutely positioned nav buttons (see .modal-nav-controls in lora-modal.css). */
.recipe-modal-header-row {
box-sizing: border-box;
width: 100%;
position: relative;
padding-right: 152px;
}
/* 56px right offset keeps the nav buttons clear of the close (x) button,
which is absolutely positioned at the modal-content top-right corner. */
.recipe-modal-header-row .modal-nav-controls {
right: 56px;
}
#recipeTagsContainer { #recipeTagsContainer {
width: 100%; width: 100%;
} }
@@ -107,12 +122,19 @@
#recipeModal .modal-content { #recipeModal .modal-content {
display: flex; display: flex;
flex-direction: column; 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 { #recipeModal .modal-body {
display: flex; display: grid;
flex-direction: column; grid-template-columns: 320px minmax(0, 1fr) 420px;
gap: var(--space-2); gap: var(--space-3);
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
@@ -174,19 +196,22 @@
} }
} }
/* Top Section: Preview and Gen Params */ /* Left Column: Preview */
.recipe-top-section { .recipe-media-column {
display: grid; display: flex;
grid-template-columns: 280px 1fr; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
flex-shrink: 0; min-height: 0;
margin-bottom: var(--space-2); overflow-y: auto;
overflow-x: hidden; /* Guard against sub-pixel overflow from bordered children */
} }
/* Recipe Preview */ /* Recipe Preview */
.recipe-preview-container { .recipe-preview-container {
width: 100%; 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); border-radius: var(--border-radius-sm);
overflow: hidden; overflow: hidden;
background: var(--lora-surface); background: var(--lora-surface);
@@ -196,18 +221,19 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
position: relative; position: relative;
flex-shrink: 0;
} }
.recipe-preview-container img, .recipe-preview-container img,
.recipe-preview-container video { .recipe-preview-container video {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 42vh;
object-fit: contain; object-fit: contain;
} }
.recipe-preview-media { .recipe-preview-media {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 42vh;
object-fit: contain; object-fit: contain;
} }
@@ -340,9 +366,10 @@
/* Generation Parameters */ /* Generation Parameters */
.recipe-gen-params { .recipe-gen-params {
height: 360px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
overflow-y: auto;
} }
.gen-params-header-row { .gen-params-header-row {
@@ -399,8 +426,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--space-2); gap: var(--space-2);
overflow-y: auto;
flex: 1;
} }
.param-group { .param-group {
@@ -453,8 +478,6 @@
color: var(--text-color); color: var(--text-color);
font-size: 0.9em; font-size: 0.9em;
line-height: 1.5; line-height: 1.5;
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
} }
@@ -526,14 +549,12 @@
opacity: 0.8; opacity: 0.8;
} }
/* Bottom Section: Resources */ /* Right Column: Resources */
.recipe-bottom-section { .recipe-bottom-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
} }
.recipe-section-header { .recipe-section-header {
@@ -1010,18 +1031,43 @@
} }
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 768px) { @media (max-width: 1500px) {
.recipe-top-section { #recipeModal .modal-body {
grid-template-columns: 1fr; 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 { .recipe-gen-params {
height: auto; overflow-y: visible;
max-height: 300px; flex-shrink: 0;
}
.recipe-bottom-section {
flex: none;
}
.recipe-loras-list {
max-height: 45vh;
} }
} }
@@ -1045,19 +1091,11 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.recipe-top-section { .recipe-preview-container,
grid-template-columns: 1fr; .recipe-preview-container img,
gap: var(--space-1); .recipe-preview-container video,
margin-bottom: var(--space-1); .recipe-preview-media {
} max-height: 32vh;
.recipe-preview-container {
display: none;
}
.recipe-gen-params {
height: auto;
max-height: 210px;
} }
.recipe-gen-params h3 { .recipe-gen-params h3 {
@@ -1070,7 +1108,6 @@
} }
.param-content { .param-content {
max-height: 90px;
padding: 10px; padding: 10px;
} }
@@ -1083,10 +1120,6 @@
gap: 6px; gap: 6px;
} }
.recipe-bottom-section {
padding-top: var(--space-1);
}
.recipe-section-header { .recipe-section-header {
margin-bottom: var(--space-1); margin-bottom: var(--space-1);
} }
+11 -11
View File
@@ -5,11 +5,11 @@
border: none; border: none;
padding: 8px 16px; padding: 8px 16px;
font-size: 0.9em; font-size: 0.9em;
transform: translateX(-50%) translateY(20px); transform: translateY(20px);
} }
.toast.toast-copy.show { .toast.toast-copy.show {
transform: translateX(-50%) translateY(0); transform: translateY(0);
} }
/* Toast Notifications */ /* Toast Notifications */
@@ -19,14 +19,15 @@
right: 20px; right: 20px;
left: auto; left: auto;
transform: translateX(120%); transform: translateX(120%);
min-width: 300px; box-sizing: border-box;
min-width: 200px;
max-width: 400px; max-width: 400px;
background: var(--lora-surface); background: var(--lora-surface);
color: var(--text-color); color: var(--text-color);
padding: 12px 16px; padding: 12px 16px;
border-radius: var(--border-radius-sm); border-radius: var(--border-radius-sm);
box-shadow: var(--shadow-toast); box-shadow: var(--shadow-toast);
z-index: calc(var(--z-overlay) + 10); z-index: var(--z-toast);
opacity: 0; opacity: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
opacity 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 { .toast {
width: calc(100% - 40px); width: calc(100% - 40px);
max-width: none; max-width: none;
right: 20px;
} }
} }
@@ -166,16 +166,17 @@
opacity: 1; opacity: 1;
} }
/* Toast Container for stacked notifications */ /* Toast Container for stacked notifications (top-right, flush below the header) */
.toast-container { .toast-container {
position: fixed; position: fixed;
top: 0; top: var(--header-height, 48px); /* Start right below the fixed header */
right: 0; right: 0;
z-index: calc(var(--z-overlay) + 10); z-index: var(--z-toast);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end;
gap: 10px; gap: 10px;
padding: 20px; padding: 8px 20px 0; /* Small breathing room below the header */
pointer-events: none; /* Allow clicking through the container */ pointer-events: none; /* Allow clicking through the container */
width: 400px; width: 400px;
max-width: 100%; max-width: 100%;
@@ -215,8 +216,7 @@
/* Responsive adjustments */ /* Responsive adjustments */
@media (max-width: 480px) { @media (max-width: 480px) {
.toast-container { .toast-container {
width: 100%; padding: 0 10px;
padding: 10px;
} }
.toast { .toast {
+3
View File
@@ -27,6 +27,9 @@
--shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.25); --shadow-dialog: 0 10px 24px rgba(0, 0, 0, 0.25);
--shadow-inset-top: 0 -2px 8px rgba(0, 0, 0, 0.1); --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-fast: 150ms ease;
--transition-base: 200ms ease; --transition-base: 200ms ease;
--transition-slow: 300ms ease; --transition-slow: 300ms ease;
+9 -2
View File
@@ -1206,9 +1206,13 @@ export class BaseModelApiClient {
} }
} }
async fetchUnifiedFolderTree() { async fetchUnifiedFolderTree(options = {}) {
try { try {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree); const { includeEmpty = false } = options;
const url = includeEmpty
? `${this.apiConfig.endpoints.unifiedFolderTree}?include_empty=1`
: this.apiConfig.endpoints.unifiedFolderTree;
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch unified folder tree`); throw new Error(`Failed to fetch unified folder tree`);
} }
@@ -1337,6 +1341,9 @@ export class BaseModelApiClient {
if (pageState.searchOptions.creator !== undefined) { if (pageState.searchOptions.creator !== undefined) {
params.append('search_creator', pageState.searchOptions.creator.toString()); params.append('search_creator', pageState.searchOptions.creator.toString());
} }
if (pageState.searchOptions.hash !== undefined) {
params.append('search_hash', pageState.searchOptions.hash.toString());
}
} }
} }
+27
View File
@@ -49,6 +49,28 @@ export async function fetchRecipeDetails(recipeId) {
return response.json(); 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 * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @param {number} page - Page number to fetch
@@ -152,6 +174,11 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
} }
}); });
} }
// Add LoRA availability filter (no statuses selected = no filtering)
if (pageState.filters?.loraAvailability && pageState.filters.loraAvailability.length > 0) {
params.append('lora_availability', pageState.filters.loraAvailability.join(','));
}
} }
// Fetch recipes // Fetch recipes
+132 -115
View File
@@ -339,124 +339,11 @@ class RecipeCard {
} }
showDeleteConfirmation() { showDeleteConfirmation() {
try { showRecipeDeleteConfirmation(this.recipe);
// Get recipe ID
const recipeId = this.recipe.id;
const filePath = this.recipe.file_path;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
return;
}
// Create delete modal content
const previewUrl = this.recipe.file_url || '/loras_static/images/no-preview.png';
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const deleteModalContent = `
<div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info">
<div class="delete-preview">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${this.recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
</div>
<div class="delete-info">
<h3>${this.recipe.title}</h3>
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
</div>
</div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
`;
// Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
deleteBtn.disabled = false;
});
// Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn');
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
}
} }
confirmDeleteRecipe() { confirmDeleteRecipe() {
const deleteModal = document.getElementById('deleteModal'); confirmRecipeDelete(this.recipe);
const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal');
return;
}
// Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Deleting...';
deleteBtn.disabled = true;
// Call API to delete the recipe
fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete recipe');
}
return response.json();
})
.then(data => {
if (data.batch_id) {
// Staged delete: offer undo instead of the plain success toast
const batchId = data.batch_id;
showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
actionText: translate('toast.undo.action'),
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
});
} else {
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
}
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state
deleteBtn.textContent = originalText;
deleteBtn.disabled = false;
});
} }
shareRecipe() { shareRecipe() {
@@ -507,4 +394,134 @@ class RecipeCard {
} }
} }
/**
* Show the delete confirmation modal for a recipe. Shared by RecipeCard and
* RecipeModal so the flow stays identical regardless of where it starts.
* @param {Object} recipe - The recipe to delete
*/
export function showRecipeDeleteConfirmation(recipe) {
try {
// Get recipe ID
const recipeId = recipe.id;
const filePath = recipe.file_path;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
return;
}
// Create delete modal content
const previewUrl = recipe.file_url || '/loras_static/images/no-preview.png';
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const deleteModalContent = `
<div class="modal-content delete-modal-content">
<h2>Delete Recipe</h2>
<p class="delete-message">Are you sure you want to delete this recipe?</p>
<div class="delete-model-info">
<div class="delete-preview">
${isVideo ?
`<video src="${previewUrl}" controls muted loop playsinline style="max-width: 100%;"></video>` :
`<img src="${previewUrl}" alt="${recipe.title}" onerror="this.onerror=null; this.src='/loras_static/images/no-preview.png'">`
}
</div>
<div class="delete-info">
<h3>${recipe.title}</h3>
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
</div>
</div>
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
<div class="modal-actions">
<button class="cancel-btn" onclick="closeDeleteModal()">Cancel</button>
<button class="delete-btn" onclick="confirmDelete()">Delete</button>
</div>
</div>
`;
// Show the modal with custom content and setup callbacks
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
deleteBtn.disabled = false;
});
// Set up the delete and cancel buttons with proper event handlers
const deleteModal = document.getElementById('deleteModal');
const cancelBtn = deleteModal.querySelector('.cancel-btn');
const deleteBtn = deleteModal.querySelector('.delete-btn');
// Store recipe ID in the modal for the delete confirmation handler
deleteModal.dataset.recipeId = recipeId;
deleteModal.dataset.filePath = filePath;
// Update button event handlers
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => confirmRecipeDelete(recipe);
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
}
}
/**
* Execute the recipe deletion after the user confirms in the delete modal.
* @param {Object} recipe - The recipe being deleted (used for toast messaging)
*/
function confirmRecipeDelete(recipe) {
const deleteModal = document.getElementById('deleteModal');
const recipeId = deleteModal.dataset.recipeId;
if (!recipeId) {
showToast('toast.recipes.cannotDelete', {}, 'error');
modalManager.closeModal('deleteModal');
return;
}
// Show loading state
const deleteBtn = deleteModal.querySelector('.delete-btn');
const originalText = deleteBtn.textContent;
deleteBtn.textContent = 'Deleting...';
deleteBtn.disabled = true;
// Call API to delete the recipe
fetch(`/api/lm/recipe/${recipeId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => {
if (!response.ok) {
throw new Error('Failed to delete recipe');
}
return response.json();
})
.then(data => {
if (data.batch_id) {
// Staged delete: offer undo instead of the plain success toast
const batchId = data.batch_id;
showActionToast('toast.undo.deleted', { name: recipe.title }, 'success', {
actionText: translate('toast.undo.action'),
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
});
} else {
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
}
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
modalManager.closeModal('deleteModal');
})
.catch(error => {
console.error('Error deleting recipe:', error);
showToast('toast.recipes.deleteFailed', { message: error.message }, 'error');
// Reset button state
deleteBtn.textContent = originalText;
deleteBtn.disabled = false;
});
}
export { RecipeCard }; export { RecipeCard };
+182 -55
View File
@@ -4,10 +4,11 @@ import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.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 { downloadManager } from '../managers/DownloadManager.js';
import { MODEL_TYPES } from '../api/apiConfig.js'; import { MODEL_TYPES } from '../api/apiConfig.js';
import { openMediaViewer } from './shared/MediaViewer.js'; import { openMediaViewer } from './shared/MediaViewer.js';
import { showRecipeDeleteConfirmation } from './RecipeCard.js';
import { renderCompactTags, setupTagTooltip } from './shared/utils.js'; import { renderCompactTags, setupTagTooltip } from './shared/utils.js';
import { setupTagEditMode } from './shared/ModelTags.js'; import { setupTagEditMode } from './shared/ModelTags.js';
@@ -55,6 +56,8 @@ class RecipeModal {
constructor() { constructor() {
this.promptEditorState = {}; this.promptEditorState = {};
this.recipeHydrationRequestId = 0; this.recipeHydrationRequestId = 0;
this.navigationKeyHandler = null;
this.navigationInProgress = false;
this.resetLocalEditState(); this.resetLocalEditState();
this.init(); this.init();
} }
@@ -120,6 +123,8 @@ class RecipeModal {
this.setupCopyButtons(); this.setupCopyButtons();
this.setupStripLoraToggle(); this.setupStripLoraToggle();
this.setupPromptEditors(); this.setupPromptEditors();
this.setupNavigationControls();
this.setupDeleteControl();
// Set up tooltip positioning handlers after DOM is ready // Set up tooltip positioning handlers after DOM is ready
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
this.setupTooltipPositioning(); this.setupTooltipPositioning();
@@ -164,6 +169,119 @@ class RecipeModal {
}); });
} }
setupNavigationControls() {
const prevBtn = document.getElementById('recipeNavPrevBtn');
const nextBtn = document.getElementById('recipeNavNextBtn');
if (prevBtn) {
prevBtn.addEventListener('click', () => this.handleDirectionalNavigation('prev'));
}
if (nextBtn) {
nextBtn.addEventListener('click', () => this.handleDirectionalNavigation('next'));
}
this.updateNavigationControls();
}
setupDeleteControl() {
const deleteBtn = document.getElementById('deleteRecipeBtn');
if (deleteBtn) {
deleteBtn.addEventListener('click', () => this.handleDeleteRecipe());
}
}
handleDeleteRecipe() {
if (!this.currentRecipe) return;
showRecipeDeleteConfirmation(this.currentRecipe);
}
shouldIgnoreNavigationKey(event) {
const target = event.target;
if (!target) return false;
const tagName = target.tagName ? target.tagName.toLowerCase() : '';
return target.isContentEditable || ['input', 'textarea', 'select', 'button'].includes(tagName);
}
updateNavigationControls() {
const modalElement = document.getElementById('recipeModal');
if (!modalElement) return;
const prevBtn = modalElement.querySelector('#recipeNavPrevBtn');
const nextBtn = modalElement.querySelector('#recipeNavNextBtn');
if (!prevBtn || !nextBtn) return;
const scroller = state.virtualScroller;
if (!scroller || typeof scroller.getNavigationState !== 'function') {
prevBtn.disabled = true;
nextBtn.disabled = true;
return;
}
const { hasPrev, hasNext } = scroller.getNavigationState(this.listFilePath || this.filePath || '');
prevBtn.disabled = this.navigationInProgress || !hasPrev;
nextBtn.disabled = this.navigationInProgress || !hasNext;
}
cleanupNavigationShortcuts() {
if (this.navigationKeyHandler) {
document.removeEventListener('keydown', this.navigationKeyHandler);
this.navigationKeyHandler = null;
}
this.navigationInProgress = false;
}
setupNavigationShortcuts() {
const modalElement = document.getElementById('recipeModal');
if (!modalElement) return;
this.cleanupNavigationShortcuts();
this.navigationKeyHandler = (event) => {
if (this.shouldIgnoreNavigationKey(event)) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
this.handleDirectionalNavigation('prev');
} else if (event.key === 'ArrowRight') {
event.preventDefault();
this.handleDirectionalNavigation('next');
} else if (event.key === 'Delete') {
event.preventDefault();
this.handleDeleteRecipe();
}
};
document.addEventListener('keydown', this.navigationKeyHandler);
}
async handleDirectionalNavigation(direction) {
if (this.navigationInProgress) return;
const scroller = state.virtualScroller;
const filePath = this.listFilePath || this.filePath || '';
if (!filePath || !scroller || typeof scroller.getAdjacentItemByFilePath !== 'function') {
return;
}
this.navigationInProgress = true;
this.updateNavigationControls();
try {
const adjacent = await scroller.getAdjacentItemByFilePath(filePath, direction);
if (!adjacent || !adjacent.item) {
const toastKey = direction === 'prev' ? 'toast.recipes.noPreviousRecipe' : 'toast.recipes.noNextRecipe';
const toastFallback = direction === 'prev' ? 'No previous recipe available' : 'No next recipe available';
showToast(toastKey, {}, 'info', toastFallback);
return;
}
this.showRecipeDetails(adjacent.item);
} finally {
this.navigationInProgress = false;
this.updateNavigationControls();
}
}
// Add tooltip positioning handler to ensure correct positioning of fixed tooltips // Add tooltip positioning handler to ensure correct positioning of fixed tooltips
setupTooltipPositioning() { setupTooltipPositioning() {
document.addEventListener('mouseover', (event) => { document.addEventListener('mouseover', (event) => {
@@ -300,10 +418,12 @@ class RecipeModal {
this.syncGenerationParams(hydratedRecipe.gen_params); this.syncGenerationParams(hydratedRecipe.gen_params);
this.syncResourcesSection(hydratedRecipe); this.syncResourcesSection(hydratedRecipe);
this.syncSourceUrlAction(); this.syncHeaderActions();
// Show the modal // Show the modal
modalManager.showModal('recipeModal'); modalManager.showModal('recipeModal', null, null, () => this.cleanupNavigationShortcuts());
this.updateNavigationControls();
this.setupNavigationShortcuts();
if (this.recipeId) { if (this.recipeId) {
// Fire-and-forget: record this open for the "Recently Opened" // Fire-and-forget: record this open for the "Recently Opened"
@@ -385,6 +505,10 @@ class RecipeModal {
nextRecipe.gen_params = preservedGenParams; nextRecipe.gen_params = preservedGenParams;
} }
if (fullRecipe.has_workflow !== undefined) {
nextRecipe.has_workflow = fullRecipe.has_workflow;
}
if (fullRecipe.checkpoint !== undefined) { if (fullRecipe.checkpoint !== undefined) {
nextRecipe.checkpoint = fullRecipe.checkpoint; nextRecipe.checkpoint = fullRecipe.checkpoint;
} else { } else {
@@ -441,7 +565,7 @@ class RecipeModal {
} else { } else {
this.updateSourceUrlDisplay(this.currentRecipe.source_path || ''); this.updateSourceUrlDisplay(this.currentRecipe.source_path || '');
} }
this.syncSourceUrlAction(); this.syncHeaderActions();
} }
getPreviewMediaUrl(recipe = {}) { getPreviewMediaUrl(recipe = {}) {
@@ -509,28 +633,68 @@ class RecipeModal {
} }
} }
syncSourceUrlAction() { syncHeaderActions() {
const actionsContainer = document.getElementById('recipeHeaderActions'); const actionsContainer = document.getElementById('recipeHeaderActions');
if (!actionsContainer) { if (!actionsContainer) {
return; return;
} }
actionsContainer.innerHTML = ''; actionsContainer.querySelectorAll('.recipe-source-url-btn').forEach(btn => btn.remove());
// Keep the delete button as the last (rightmost) header action;
// insertBefore with null falls back to appendChild if it is missing.
const deleteBtn = document.getElementById('deleteRecipeBtn');
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.insertBefore(workflowBtn, deleteBtn);
}
const sourcePath = this.currentRecipe?.source_path || ''; const sourcePath = this.currentRecipe?.source_path || '';
const isValidUrl = sourcePath.startsWith('http://') || sourcePath.startsWith('https://'); 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.insertBefore(btn, deleteBtn);
}
}
async sendWorkflowToComfyUI() {
if (!this.recipeId) {
return; return;
} }
const btn = document.createElement('button'); try {
btn.className = 'recipe-source-url-btn'; const result = await sendRecipeWorkflow(this.recipeId);
btn.title = sourcePath; if (result?.success) {
btn.innerHTML = '<i class="fas fa-globe"></i> Open Source URL'; showToast('toast.recipes.workflowSent', {}, 'success', 'Workflow sent to ComfyUI');
btn.addEventListener('click', () => { return;
window.open(sourcePath, '_blank'); }
});
actionsContainer.appendChild(btn); 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) { syncTagsDisplay(tags) {
@@ -719,7 +883,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(() => { setTimeout(() => {
const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn'); const viewRecipeLorasBtn = document.getElementById('viewRecipeLorasBtn');
@@ -1153,7 +1317,7 @@ class RecipeModal {
// Update source URL in the UI // Update source URL in the UI
this.commitField('source_path'); this.commitField('source_path');
this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true }); this.updateSourceUrlDisplay(newSourceUrl, { forceInputSync: true });
this.syncSourceUrlAction(); this.syncHeaderActions();
// Update the current recipe object // Update the current recipe object
this.currentRecipe.source_path = newSourceUrl; this.currentRecipe.source_path = newSourceUrl;
@@ -1180,11 +1344,10 @@ class RecipeModal {
}); });
} }
// Setup copy buttons for prompts and recipe syntax // Setup copy buttons for prompts and send recipe button
setupCopyButtons() { setupCopyButtons() {
const copyPromptBtn = document.getElementById('copyPromptBtn'); const copyPromptBtn = document.getElementById('copyPromptBtn');
const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn'); const copyNegativePromptBtn = document.getElementById('copyNegativePromptBtn');
const copyRecipeSyntaxBtn = document.getElementById('copyRecipeSyntaxBtn');
const sendRecipeBtn = document.getElementById('sendRecipeBtn'); const sendRecipeBtn = document.getElementById('sendRecipeBtn');
if (copyPromptBtn) { if (copyPromptBtn) {
@@ -1207,13 +1370,6 @@ class RecipeModal {
}); });
} }
if (copyRecipeSyntaxBtn) {
copyRecipeSyntaxBtn.addEventListener('click', () => {
// Use backend API to get recipe syntax
this.fetchAndCopyRecipeSyntax();
});
}
if (sendRecipeBtn) { if (sendRecipeBtn) {
sendRecipeBtn.addEventListener('click', () => { sendRecipeBtn.addEventListener('click', () => {
// Send recipe to ComfyUI workflow // Send recipe to ComfyUI workflow
@@ -1299,35 +1455,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 // Helper method to copy text to clipboard
copyToClipboard(text, successMessage) { copyToClipboard(text, successMessage) {
copyToClipboard(text, successMessage); copyToClipboard(text, successMessage);
+49 -1
View File
@@ -9,12 +9,14 @@ import { bulkManager } from '../managers/BulkManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js'; import { performFolderUpdateCheck } from '../utils/updateCheckHelpers.js';
import { escapeHtml, escapeAttribute } from './shared/utils.js'; import { escapeHtml, escapeAttribute } from './shared/utils.js';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../utils/constants.js';
export class SidebarManager { export class SidebarManager {
constructor() { constructor() {
this.pageControls = null; this.pageControls = null;
this.pageType = null; this.pageType = null;
this.treeData = {}; this.treeData = {};
this.folderTreeLoaded = false;
this.selectedPath = ''; this.selectedPath = '';
this.expandedNodes = new Set(); this.expandedNodes = new Set();
this.apiClient = null; this.apiClient = null;
@@ -252,6 +254,9 @@ export class SidebarManager {
if (dataTransfer) { if (dataTransfer) {
dataTransfer.effectAllowed = 'move'; dataTransfer.effectAllowed = 'move';
dataTransfer.setData('text/plain', filePaths.join(',')); 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 { try {
dataTransfer.setData('application/json', JSON.stringify({ filePaths })); dataTransfer.setData('application/json', JSON.stringify({ filePaths }));
} catch (error) { } catch (error) {
@@ -1167,13 +1172,32 @@ export class SidebarManager {
const response = await this.apiClient.fetchModelFolders(); const response = await this.apiClient.fetchModelFolders();
this.foldersList = response.folders || []; this.foldersList = response.folders || [];
} }
this.folderTreeLoaded = true;
this.renderFolderDisplay(); this.renderFolderDisplay();
} catch (error) { } catch (error) {
this.folderTreeLoaded = false;
console.error('Failed to load folder data:', error); console.error('Failed to load folder data:', error);
this.renderEmptyState(); this.renderEmptyState();
} }
} }
folderExistsInTree(path) {
if (!path) return true;
if (this.displayMode === 'tree') {
let node = this.treeData;
for (const segment of path.split('/')) {
if (!node || typeof node !== 'object' || !(segment in node)) {
return false;
}
node = node[segment];
}
return true;
}
return this.foldersList.includes(path);
}
renderFolderDisplay() { renderFolderDisplay() {
if (this.displayMode === 'tree') { if (this.displayMode === 'tree') {
this.renderTree(); this.renderTree();
@@ -1805,7 +1829,31 @@ export class SidebarManager {
restoreSelectedFolder() { restoreSelectedFolder() {
const activeFolder = getStorageItem(`${this.pageType}_activeFolder`); const activeFolder = getStorageItem(`${this.pageType}_activeFolder`);
if (activeFolder && typeof activeFolder === 'string') { if (activeFolder && typeof activeFolder === 'string') {
this.selectedPath = activeFolder; // Fall back to the root when the persisted folder no longer
// exists in the freshly loaded tree (e.g. it was moved or
// deleted); otherwise the grid stays empty with a phantom
// breadcrumb. Skip validation when the tree failed to load so a
// transient API error doesn't wipe the saved location.
if (this.folderTreeLoaded && !this.folderExistsInTree(activeFolder)) {
console.warn(`Persisted folder "${activeFolder}" not found in folder tree, falling back to root`);
this.selectedPath = '';
if (this.pageControls?.pageState) {
this.pageControls.pageState.activeFolder = '';
}
setStorageItem(`${this.pageType}_activeFolder`, '');
// When the reset happens after initialization (e.g. via
// refresh() after a drag move emptied the folder), reload the
// listing so the grid shows the root contents instead of
// staying empty. Skipped during initialize() — the first load
// picks up the cleared filter on its own.
if (this.isInitialized && typeof this.pageControls?.resetAndReload === 'function') {
this.pageControls.resetAndReload().catch((error) => {
console.error('Failed to reload after resetting folder selection:', error);
});
}
} else {
this.selectedPath = activeFolder;
}
this.updateTreeSelection(); this.updateTreeSelection();
this.updateBreadcrumbs(); this.updateBreadcrumbs();
this.updateSidebarHeader(); this.updateSidebarHeader();
+7 -2
View File
@@ -52,7 +52,11 @@ class InitializationManager {
detectPageType() { detectPageType() {
// Get the current page type from URL or data attribute // Get the current page type from URL or data attribute
const path = window.location.pathname; const path = window.location.pathname;
if (path.includes('/checkpoints')) { // The recipes page lives at /loras/recipes, so it must be matched
// before the generic '/loras' check.
if (path.includes('/recipes')) {
this.pageType = 'recipes';
} else if (path.includes('/checkpoints')) {
this.pageType = 'checkpoints'; this.pageType = 'checkpoints';
} else if (path.includes('/loras')) { } else if (path.includes('/loras')) {
this.pageType = 'loras'; this.pageType = 'loras';
@@ -216,7 +220,8 @@ class InitializationManager {
const scannerTypeToPageType = { const scannerTypeToPageType = {
'lora': 'loras', 'lora': 'loras',
'checkpoint': 'checkpoints', 'checkpoint': 'checkpoints',
'embedding': 'embeddings' 'embedding': 'embeddings',
'recipe': 'recipes'
}; };
if (scannerTypeToPageType[data.scanner_type] !== this.pageType) { if (scannerTypeToPageType[data.scanner_type] !== this.pageType) {
@@ -134,6 +134,10 @@ export function openMediaViewer(arg1, arg2, arg3) {
const keyHandler = (e) => { const keyHandler = (e) => {
if (e.key === 'Escape') { 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(); closeMediaViewer();
return; return;
} }
+37 -25
View File
@@ -1,10 +1,9 @@
import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, openHuggingFace, copyToClipboard, copyLoraSyntax, sendLoraToWorkflow, sendEmbeddingToWorkflow, openExampleImagesFolder, buildLoraSyntax, sendModelPathToWorkflow } from '../../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../../state/index.js'; import { state, getCurrentPageState } from '../../state/index.js';
import { showModelModal } from './ModelModal.js'; import { showModelModal } from './ModelModal.js';
import { toggleShowcase } from './showcase/ShowcaseView.js';
import { bulkManager } from '../../managers/BulkManager.js'; import { bulkManager } from '../../managers/BulkManager.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getSubTypeAbbreviation, getMatureBlurThreshold, MODEL_SUBTYPE_DISPLAY_NAMES } 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 { MODEL_TYPES } from '../../api/apiConfig.js';
import { getModelApiClient } from '../../api/modelApiFactory.js'; import { getModelApiClient } from '../../api/modelApiFactory.js';
import { showDeleteModal } from '../../utils/modalUtils.js'; import { showDeleteModal } from '../../utils/modalUtils.js';
@@ -304,10 +303,21 @@ 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) { async function showModelModalFromCard(card, modelType) {
// Create model metadata object // Create model metadata object
const modelMeta = { const modelMeta = {
sha256: card.dataset.sha256, sha256: card.dataset.sha256,
autov3: card.dataset.autov3 || '',
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath, file_path: card.dataset.filepath,
model_name: card.dataset.name, model_name: card.dataset.name,
file_name: card.dataset.file_name, file_name: card.dataset.file_name,
@@ -397,6 +407,8 @@ function showExampleAccessModal(card, modelType) {
// Get the model data from card dataset (works for both lora and checkpoint) // Get the model data from card dataset (works for both lora and checkpoint)
const modelMeta = { const modelMeta = {
sha256: card.dataset.sha256, sha256: card.dataset.sha256,
autov3: card.dataset.autov3 || '',
preview_url: getCardPreviewUrl(card),
file_path: card.dataset.filepath, file_path: card.dataset.filepath,
model_name: card.dataset.name, model_name: card.dataset.name,
file_name: card.dataset.file_name, file_name: card.dataset.file_name,
@@ -421,30 +433,18 @@ function showExampleAccessModal(card, modelType) {
// Show the model modal // Show the model modal
await showModelModal(modelMeta, modelType); await showModelModal(modelMeta, modelType);
// Scroll to import area after modal is visible // Reveal the import entry once the modal content has rendered
setTimeout(() => { 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) { if (importArea) {
const showcaseTab = document.getElementById('showcase-tab'); importArea.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
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' });
}
} }
}, 500); }, 500);
}; };
@@ -457,8 +457,12 @@ function showExampleAccessModal(card, modelType) {
export function createModelCard(model, modelType) { export function createModelCard(model, modelType) {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'model-card'; // Reuse the same class for styling 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.draggable = true;
card.dataset.sha256 = model.sha256; card.dataset.sha256 = model.sha256;
card.dataset.autov3 = model.autov3 || '';
card.dataset.filepath = model.file_path; card.dataset.filepath = model.file_path;
card.dataset.name = model.model_name; card.dataset.name = model.model_name;
card.dataset.file_name = model.file_name; card.dataset.file_name = model.file_name;
@@ -649,7 +653,7 @@ export function createModelCard(model, modelType) {
<div class="card-preview ${shouldBlur ? 'blurred' : ''}"> <div class="card-preview ${shouldBlur ? 'blurred' : ''}">
${isVideo ? ${isVideo ?
`<video ${videoAttrs.join(' ')} style="pointer-events: none;"></video>` : `<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"> <div class="card-header">
${shouldBlur ? ${shouldBlur ?
@@ -743,6 +747,11 @@ export function createModelCard(model, modelType) {
// Dropping an image/video onto the card replaces the model preview via the // Dropping an image/video onto the card replaces the model preview via the
// existing replace-preview endpoint (overwrites file on disk, refreshes card). // 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) => { const preventDragDefaults = (event) => {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
@@ -750,17 +759,20 @@ export function createModelCard(model, modelType) {
['dragenter', 'dragover'].forEach((eventName) => { ['dragenter', 'dragover'].forEach((eventName) => {
card.addEventListener(eventName, (event) => { card.addEventListener(eventName, (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event); preventDragDefaults(event);
card.classList.add('drag-over'); card.classList.add('drag-over');
}); });
}); });
card.addEventListener('dragleave', (event) => { card.addEventListener('dragleave', (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event); preventDragDefaults(event);
card.classList.remove('drag-over'); card.classList.remove('drag-over');
}); });
card.addEventListener('drop', (event) => { card.addEventListener('drop', (event) => {
if (isInternalCardDrag(event)) return;
preventDragDefaults(event); preventDragDefaults(event);
card.classList.remove('drag-over'); card.classList.remove('drag-over');
+78 -12
View File
@@ -1,9 +1,7 @@
import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax } from '../../utils/uiHelpers.js'; import { showToast, openCivitai, sendLoraToWorkflow, sendEmbeddingToWorkflow, sendModelPathToWorkflow, buildLoraSyntax, copyToClipboard } from '../../utils/uiHelpers.js';
import { modalManager } from '../../managers/ModalManager.js'; import { modalManager } from '../../managers/ModalManager.js';
import { MODEL_TYPES } from '../../api/apiConfig.js'; import { MODEL_TYPES } from '../../api/apiConfig.js';
import { import {
toggleShowcase,
setupShowcaseScroll,
scrollToTop, scrollToTop,
loadExampleImages loadExampleImages
} from './showcase/ShowcaseView.js'; } from './showcase/ShowcaseView.js';
@@ -22,6 +20,7 @@ import { parsePresets, renderPresetTags } from './PresetTags.js';
import { initVersionsTab } from './ModelVersionsTab.js'; import { initVersionsTab } from './ModelVersionsTab.js';
import { loadRecipesForModel } from './RecipeTab.js'; import { loadRecipesForModel } from './RecipeTab.js';
import { translate } from '../../utils/i18nHelpers.js'; import { translate } from '../../utils/i18nHelpers.js';
import { showDeleteModal } from '../../utils/modalUtils.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
function getModalFilePath(fallback = '') { function getModalFilePath(fallback = '') {
@@ -353,6 +352,39 @@ export async function showModelModal(model, modelType) {
}; };
const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || ''); const escapedFilePathAttr = escapeAttribute(modelWithFullData.file_path || '');
const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A'); const escapedFolderPath = escapeHtml((modelWithFullData.file_path || '').replace(/[^/]+$/, '') || 'N/A');
// De-emphasized hash display: a borderless full-width footnote line below
// the info grid — sha256 middle-truncated (first 10 + last 6), autov3 in
// full (12 chars); the full value is copied via data-hash.
const modelSha256 = modelWithFullData.sha256 || '';
const modelAutov3 = modelWithFullData.autov3 || '';
const truncatedSha256 = modelSha256.length > 16
? `${modelSha256.slice(0, 10)}\u2026${modelSha256.slice(-6)}`
: modelSha256;
const copyHashTitle = translate('modals.model.actions.copyHash', {}, 'Copy hash');
const hashEntries = [];
if (modelSha256) {
hashEntries.push(`
<span class="hash-entry">
<span class="hash-kind">SHA256</span>
<span class="model-hash-value" title="${escapeAttribute(modelSha256)}">${escapeHtml(truncatedSha256)}</span>
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(modelSha256)}" title="${copyHashTitle}">
<i class="fas fa-copy"></i>
</button>
</span>`);
}
if (modelAutov3) {
hashEntries.push(`
<span class="hash-entry">
<span class="hash-kind">AutoV3</span>
<span class="model-hash-value" title="${escapeAttribute(modelAutov3)}">${escapeHtml(modelAutov3)}</span>
<button class="hash-copy-btn" data-action="copy-hash" data-hash="${escapeAttribute(modelAutov3)}" title="${copyHashTitle}">
<i class="fas fa-copy"></i>
</button>
</span>`);
}
const hashesMarkup = modelSha256 && hashEntries.length ? `
<div class="hash-footnote" aria-label="${translate('modals.model.metadata.hashes', {}, 'Hashes')}">${hashEntries.join('<span class="hash-sep">·</span>')}
</div>` : '';
const useNewIcons = state.global.settings.use_new_license_icons !== false; const useNewIcons = state.global.settings.use_new_license_icons !== false;
const licenseIcons = useNewIcons const licenseIcons = useNewIcons
? renderNewLicenseIcons(modelWithFullData) ? renderNewLicenseIcons(modelWithFullData)
@@ -413,6 +445,17 @@ export async function showModelModal(model, modelType) {
if (licenseIcons) { if (licenseIcons) {
headerActionItems.push(indentMarkup(licenseIcons.trim(), 20)); headerActionItems.push(indentMarkup(licenseIcons.trim(), 20));
} }
// Destructive action stays last (rightmost). The license icons' auto
// margin right-anchors the [license][delete] cluster as one group.
const deleteModelTitle = translate('modals.model.actions.deleteModelWithShortcut', {}, 'Delete model (Del)');
const deleteModelButton = `
<button class="modal-delete-btn" data-action="delete-model" title="${deleteModelTitle}" aria-label="${deleteModelTitle}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
`.trim();
headerActionItems.push(indentMarkup(deleteModelButton, 20));
const headerActionsMarkup = headerActionItems.length const headerActionsMarkup = headerActionItems.length
? [ ? [
' <div class="modal-header-actions">', ' <div class="modal-header-actions">',
@@ -615,6 +658,7 @@ export async function showModelModal(model, modelType) {
<span>${formatFileSize(modelWithFullData.file_size)}</span> <span>${formatFileSize(modelWithFullData.file_size)}</span>
</div> </div>
</div> </div>
${hashesMarkup}
${typeSpecificContent} ${typeSpecificContent}
<div class="info-item notes"> <div class="info-item notes">
<div class="notes-header"> <div class="notes-header">
@@ -727,8 +771,6 @@ export async function showModelModal(model, modelType) {
updateCardUpdateAvailability(hasUpdate); updateCardUpdateAvailability(hasUpdate);
} }
let showcaseCleanup;
const onCloseCallback = function () { const onCloseCallback = function () {
// Clean up all handlers when modal closes for LoRA // Clean up all handlers when modal closes for LoRA
const modalElement = document.getElementById(modalId); const modalElement = document.getElementById(modalId);
@@ -736,10 +778,6 @@ export async function showModelModal(model, modelType) {
modalElement.removeEventListener('click', modalElement._clickHandler); modalElement.removeEventListener('click', modalElement._clickHandler);
delete modalElement._clickHandler; delete modalElement._clickHandler;
} }
if (showcaseCleanup) {
showcaseCleanup();
showcaseCleanup = null;
}
cleanupNavigationShortcuts(); cleanupNavigationShortcuts();
}; };
@@ -759,6 +797,14 @@ export async function showModelModal(model, modelType) {
if (modelType === 'embeddings' && modelWithFullData.folder) { if (modelType === 'embeddings' && modelWithFullData.folder) {
activeModalElement.dataset.folder = 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); updateVersionsTabBadge(updateAvailabilityState.hasUpdateAvailable);
const versionsTabController = initVersionsTab({ const versionsTabController = initVersionsTab({
@@ -771,7 +817,6 @@ export async function showModelModal(model, modelType) {
onUpdateStatusChange: handleUpdateStatusChange, onUpdateStatusChange: handleUpdateStatusChange,
}); });
setupEditableFields(modelWithFullData.file_path, modelType); setupEditableFields(modelWithFullData.file_path, modelType);
showcaseCleanup = setupShowcaseScroll(modalId);
setupTabSwitching({ setupTabSwitching({
onTabChange: async (tab) => { onTabChange: async (tab) => {
if (tab === 'versions') { if (tab === 'versions') {
@@ -814,7 +859,7 @@ export async function showModelModal(model, modelType) {
const customImages = modelWithFullData.civitai?.customImages || []; const customImages = modelWithFullData.civitai?.customImages || [];
// Combine images - regular images first, then custom images // Combine images - regular images first, then custom images
const allImages = [...regularImages, ...customImages]; const allImages = [...regularImages, ...customImages];
loadExampleImages(allImages, modelWithFullData.sha256); loadExampleImages(allImages, modelWithFullData.sha256, modelWithFullData.preview_url || '');
} }
function renderLoraSpecificContent(lora, escapedWords) { function renderLoraSpecificContent(lora, escapedWords) {
@@ -911,6 +956,14 @@ function setupEventHandlers(filePath, modelType) {
case 'send-to-workflow': case 'send-to-workflow':
handleSendToWorkflow(target, modelType); handleSendToWorkflow(target, modelType);
break; break;
case 'delete-model':
handleDeleteModel();
break;
case 'copy-hash':
if (target.dataset.hash) {
copyToClipboard(target.dataset.hash, 'Hash copied to clipboard');
}
break;
} }
} }
@@ -1180,12 +1233,26 @@ function setupNavigationShortcuts(modelType) {
} else if (event.key === 'ArrowRight') { } else if (event.key === 'ArrowRight') {
event.preventDefault(); event.preventDefault();
handleDirectionalNavigation('next', navigationModelType); handleDirectionalNavigation('next', navigationModelType);
} else if (event.key === 'Delete') {
event.preventDefault();
handleDeleteModel();
} }
}; };
document.addEventListener('keydown', navigationKeyHandler); document.addEventListener('keydown', navigationKeyHandler);
} }
/**
* Open the shared delete confirmation for the model currently shown in the
* modal. Showing the delete modal replaces this modal (ModalManager only
* keeps one modal open), which also unregisters these shortcuts.
*/
function handleDeleteModel() {
const filePath = getModalFilePath();
if (!filePath) return;
showDeleteModal(filePath);
}
async function handleDirectionalNavigation(direction, modelType) { async function handleDirectionalNavigation(direction, modelType) {
if (navigationInProgress) return; if (navigationInProgress) return;
@@ -1316,7 +1383,6 @@ async function handleSendToWorkflow(target, modelType) {
// Export the model modal API // Export the model modal API
const modelModal = { const modelModal = {
show: showModelModal, show: showModelModal,
toggleShowcase,
scrollToTop scrollToTop
}; };
@@ -573,9 +573,17 @@ function renderRow(version, options) {
); );
const actions = []; const actions = [];
if (!version.isInLibrary) { const canDownload = isDownloadAllowed(version);
const canDownload = isDownloadAllowed(version); const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : '';
const downloadIcon = isEarlyAccess ? '<i class="fas fa-bolt"></i> ' : ''; // The Download button always fetches the default (primary) file, keeping
// the single-file experience for users who don't care about variants.
// In-library versions hide it: their default file already exists locally,
// and multi-file versions use the "N files" badge below for the remaining
// variants instead (#1058). fileCount is null for records persisted before
// the field existed; default to the single-file behavior in that case.
const fileCount = typeof version.fileCount === 'number' ? version.fileCount : null;
const showDownload = !version.isInLibrary;
if (showDownload) {
let downloadTitle; let downloadTitle;
if (!canDownload) { if (!canDownload) {
downloadTitle = translate( downloadTitle = translate(
@@ -612,7 +620,16 @@ function renderRow(version, options) {
disabled: !canDownload, disabled: !canDownload,
} }
)); ));
} else if (version.filePath) { }
// Multi-file versions get an explicit entry into the download modal's
// file-selection step, mirroring the version step's file badge (#1058).
const fileSelectionBadge = fileCount !== null && fileCount > 1
? `<button type="button" class="file-select-badge" data-version-files title="${escapeHtml(translate('modals.model.versions.actions.downloadChooseFilesTooltip', {}, 'Choose which files to download'))}">
<i class="fas fa-th-list"></i> ${fileCount} ${escapeHtml(translate('modals.download.fileSelection.files', {}, 'files'))} <i class="fas fa-chevron-right badge-arrow"></i>
</button>`
: '';
if (version.isInLibrary && version.filePath) {
actions.push(buildActionButton( actions.push(buildActionButton(
deleteLabel, deleteLabel,
'version-action-danger', 'version-action-danger',
@@ -689,6 +706,7 @@ function renderRow(version, options) {
<div class="version-badges">${badges.join('')}</div> <div class="version-badges">${badges.join('')}</div>
<div class="version-meta"> <div class="version-meta">
${buildMetaMarkup(version, { showEarlyAccess: true })} ${buildMetaMarkup(version, { showEarlyAccess: true })}
${fileSelectionBadge}
</div> </div>
</div> </div>
<div class="version-actions"> <div class="version-actions">
@@ -1422,6 +1440,9 @@ export function initVersionsTab({
button.disabled = true; button.disabled = true;
try { try {
// The Download button only renders for versions not in the library
// and always fetches the default (primary) file. Multi-file
// variants are reached through the "N files" badge instead.
const pathInfo = await resolveDownloadPathFromCurrentVersion(); const pathInfo = await resolveDownloadPathFromCurrentVersion();
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo); const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, { const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
@@ -1500,6 +1521,21 @@ export function initVersionsTab({
return; return;
} }
// File-selection badge: enter the download modal's file step directly.
// Must run before the row-click navigation below (rows are clickable).
const filesBadge = event.target.closest('[data-version-files]');
if (filesBadge) {
event.preventDefault();
event.stopPropagation();
const row = filesBadge.closest('.model-version-row');
if (!row) {
return;
}
const versionId = Number(row.dataset.versionId);
await downloadManager.openFileSelectionForVersion(modelType, modelId, versionId);
return;
}
const row = event.target.closest('.model-version-row.is-clickable'); const row = event.target.closest('.model-version-row.is-clickable');
const civitaiLink = event.target.closest('.version-civitai-link'); const civitaiLink = event.target.closest('.version-civitai-link');
if (civitaiLink) { if (civitaiLink) {
@@ -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 {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred * @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text * @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML * @param {string} metadataPanel - Metadata panel HTML
@@ -15,11 +15,11 @@
* @param {string} mediaControlsHtml - HTML for media control buttons * @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content * @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; const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return ` 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 ? ` ${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur"> <button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i> <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 {Object} media - Media metadata
* @param {number} heightPercent - Height percentage for container
* @param {boolean} shouldBlur - Whether content should be blurred * @param {boolean} shouldBlur - Whether content should be blurred
* @param {string} nsfwText - NSFW warning text * @param {string} nsfwText - NSFW warning text
* @param {string} metadataPanel - Metadata panel HTML * @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 * @param {string} mediaControlsHtml - HTML for media control buttons
* @returns {string} HTML content * @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; const nsfwLevel = media.nsfwLevel !== undefined ? media.nsfwLevel : 0;
return ` 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 ? ` ${shouldBlur ? `
<button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur"> <button class="toggle-blur-btn showcase-toggle-btn" title="Toggle blur">
<i class="fas fa-eye"></i> <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 * @param {HTMLElement} container - Container element with media wrappers
*/ */
export function initMetadataPanelHandlers(container) { export function initMetadataPanelHandlers(container) {
const mediaWrappers = container.querySelectorAll('.media-wrapper'); const mediaWrappers = container.querySelectorAll('.media-wrapper');
mediaWrappers.forEach(wrapper => { mediaWrappers.forEach(wrapper => {
// Get the metadata panel and media element (img or video)
const metadataPanel = wrapper.querySelector('.image-metadata-panel'); const metadataPanel = wrapper.querySelector('.image-metadata-panel');
if (!metadataPanel) return;
const mediaControls = wrapper.querySelector('.media-controls'); const mediaControls = wrapper.querySelector('.media-controls');
const mediaElement = wrapper.querySelector('img, video'); const mediaElement = wrapper.querySelector('img, video');
if (!mediaElement) return; if (mediaElement) {
let isOverMetadataPanel = false;
let isOverMetadataPanel = false;
// Hovering the actual media content reveals the metadata panel and controls
// Add event listeners to the wrapper for mouse tracking wrapper.addEventListener('mousemove', (e) => {
wrapper.addEventListener('mousemove', (e) => { const rect = wrapper.getBoundingClientRect();
// Get mouse position relative to wrapper const mouseX = e.clientX - rect.left;
const rect = wrapper.getBoundingClientRect(); const mouseY = e.clientY - rect.top;
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top; const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height);
const isOverMedia = (
// Get the actual displayed dimensions of the media element mouseX >= mediaRect.left &&
const mediaRect = getRenderedMediaRect(mediaElement, rect.width, rect.height); mouseX <= mediaRect.right &&
mouseY >= mediaRect.top &&
// Check if mouse is over the actual media content mouseY <= mediaRect.bottom
const isOverMedia = ( );
mouseX >= mediaRect.left &&
mouseX <= mediaRect.right && if (isOverMedia || isOverMetadataPanel) {
mouseY >= mediaRect.top && metadataPanel.classList.add('visible');
mouseY <= mediaRect.bottom if (mediaControls) mediaControls.classList.add('visible');
); } else {
metadataPanel.classList.remove('visible');
// Show metadata panel and controls when over media content or metadata panel itself if (mediaControls) mediaControls.classList.remove('visible');
if (isOverMedia || isOverMetadataPanel) { }
if (metadataPanel) metadataPanel.classList.add('visible'); });
if (mediaControls) mediaControls.classList.add('visible');
} else { wrapper.addEventListener('mouseleave', () => {
if (metadataPanel) metadataPanel.classList.remove('visible'); if (!isOverMetadataPanel) {
if (mediaControls) mediaControls.classList.remove('visible'); 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) {
metadataPanel.addEventListener('mouseenter', () => { metadataPanel.addEventListener('mouseenter', () => {
isOverMetadataPanel = true; isOverMetadataPanel = true;
metadataPanel.classList.add('visible'); metadataPanel.classList.add('visible');
if (mediaControls) mediaControls.classList.add('visible'); if (mediaControls) mediaControls.classList.add('visible');
}); });
metadataPanel.addEventListener('mouseleave', () => { metadataPanel.addEventListener('mouseleave', () => {
isOverMetadataPanel = false; isOverMetadataPanel = false;
// Only hide if mouse is not over the media metadataPanel.classList.remove('visible');
const rect = wrapper.getBoundingClientRect(); if (mediaControls) mediaControls.classList.remove('visible');
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');
}
}); });
// 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(); const result = await response.json();
if (result.success) { 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 // Success: remove the media wrapper from the DOM
mediaWrapper.style.opacity = '0'; mediaWrapper.style.opacity = '0';
mediaWrapper.style.height = '0'; mediaWrapper.style.height = '0';
@@ -649,7 +635,7 @@ export function initMediaControlHandlers(container) {
// Initialize NSFW level buttons // Initialize NSFW level buttons
initSetNsfwHandlers(container); 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 // Any click handlers or other functionality can still be added here
} }
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -49,7 +49,10 @@ class I18nManager {
} }
try { try {
const response = await fetch(`/locales/${normalizedLocale}.json`); // 'no-cache' forces revalidation (cheap 304 via ETag) so locale
// edits are picked up on a plain reload instead of serving a
// stale cached copy.
const response = await fetch(`/locales/${normalizedLocale}.json`, { cache: 'no-cache' });
if (!response.ok) { if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
+335 -43
View File
@@ -25,6 +25,12 @@ export class DownloadManager {
this.apiClient = null; this.apiClient = null;
this.useDefaultPath = false; 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 // Batch mode state
this.batchModels = []; this.batchModels = [];
this.isBatchMode = false; this.isBatchMode = false;
@@ -160,6 +166,8 @@ export class DownloadManager {
this.modelVersionId = null; this.modelVersionId = null;
this.source = null; this.source = null;
this.selectedFile = null; this.selectedFile = null;
this.selectedFiles = [];
this._lastDownloadError = null;
this._isDiffusionModel = false; this._isDiffusionModel = false;
this.selectedFolder = ''; this.selectedFolder = '';
@@ -546,6 +554,64 @@ export class DownloadManager {
await this.fetchVersionsForCurrentModel(); 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() { showVersionStep() {
document.getElementById('urlStep').style.display = 'none'; document.getElementById('urlStep').style.display = 'none';
document.getElementById('versionStep').style.display = 'block'; document.getElementById('versionStep').style.display = 'block';
@@ -595,7 +661,10 @@ export class DownloadManager {
</div>`; </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}"> ? `<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> <i class="fas fa-th-list"></i> ${modelFiles.length} ${translate('modals.download.fileSelection.files')} <i class="fas fa-chevron-right badge-arrow"></i>
</span>` </span>`
@@ -667,9 +736,14 @@ export class DownloadManager {
const nextButton = document.getElementById('nextFromVersion'); const nextButton = document.getElementById('nextFromVersion');
if (!nextButton) return; 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.disabled = true;
nextButton.classList.add('disabled'); nextButton.classList.add('disabled');
nextButton.textContent = translate('modals.download.alreadyInLibrary'); 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) { showFileSelectionStep(versionId) {
const version = this.versions.find(v => v.id.toString() === versionId.toString()); const version = this.versions.find(v => v.id.toString() === versionId.toString());
if (!version) return; if (!version) return;
this.currentVersion = version; 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'; document.getElementById('fileSelectionStep').style.display = 'block';
const nameEl = document.getElementById('fileSelectionVersionName'); const nameEl = document.getElementById('fileSelectionVersionName');
@@ -699,9 +800,12 @@ export class DownloadManager {
container.innerHTML = modelFiles.map(file => { container.innerHTML = modelFiles.map(file => {
const meta = file.metadata || {}; const meta = file.metadata || {};
const sizeGB = file.sizeKB ? (file.sizeKB / (1024 * 1024)).toFixed(2) : '--'; 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 = []; 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.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.format) tags.push(`<span class="file-tag format">${meta.format}</span>`);
if (meta.fp) tags.push(`<span class="file-tag fp">${meta.fp}</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 || ''; const fileName = file.name || '';
return ` 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"> <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>
<div class="file-option-info"> <div class="file-option-info">
<div class="file-option-tags"> <div class="file-option-tags">
@@ -725,33 +829,80 @@ export class DownloadManager {
}).join(''); }).join('');
container.querySelectorAll('.file-option').forEach(el => { container.querySelectorAll('.file-option').forEach(el => {
el.addEventListener('click', () => { el.addEventListener('click', (event) => {
container.querySelectorAll('.file-option').forEach(o => o.classList.remove('selected')); // Already-downloaded files stay disabled regardless
el.classList.add('selected'); if (el.classList.contains('disabled')) {
const radio = el.querySelector('input[type="radio"]'); event.preventDefault();
if (radio) radio.checked = true; 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() { // Sync this.selectedFiles with the DOM checkboxes and enforce the
const selectedRadio = document.querySelector('#fileSelectionList input[type="radio"]:checked'); // mixed-type routing guard by disabling the other routing group.
if (!selectedRadio) { _syncFileSelectionState() {
console.warn('[download] confirmFileSelection: no radio button checked'); const container = document.getElementById('fileSelectionList');
return; 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; const version = this.currentVersion;
if (!version) { if (!version) {
console.warn('[download] confirmFileSelection: no currentVersion set'); console.warn('[download] confirmFileSelection: no currentVersion set');
return; return;
} }
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type)); // Sync from the DOM first so programmatically checked boxes count too
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value); this._syncFileSelectionState();
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o', if (this.selectedFiles.length === 0) {
this.selectedFile?.id, this.selectedFile?.name, this.selectedFile?.type, this.selectedFile?.metadata); 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('fileSelectionStep').style.display = 'none';
document.getElementById('downloadLocationStep').style.display = 'block'; document.getElementById('downloadLocationStep').style.display = 'block';
@@ -782,6 +933,13 @@ export class DownloadManager {
return; return;
} }
if (this.currentVersion.existsLocally) { 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'); showToast('toast.loras.versionExists', {}, 'info');
return; return;
} }
@@ -916,6 +1074,9 @@ export class DownloadManager {
source = null, source = null,
fileParams = null, fileParams = null,
closeModal = false, closeModal = false,
deferReload = false,
suppressSuccessToast = false,
suppressFailureSummary = false,
}) { }) {
const config = this.apiClient?.apiConfig?.config; const config = this.apiClient?.apiConfig?.config;
@@ -924,7 +1085,8 @@ export class DownloadManager {
} }
const displayName = versionName || `#${versionId}`; 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 ws = null;
let updateProgress = () => { }; let updateProgress = () => { };
let cancelled = false; let cancelled = false;
@@ -1007,7 +1169,9 @@ export class DownloadManager {
if (response?.skipped) { if (response?.skipped) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing')); this.loadingManager.setStatus(translate('modals.download.status.finalizing'));
updateProgress(100, 0, displayName); 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) { if (closeModal) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
} }
@@ -1016,6 +1180,22 @@ export class DownloadManager {
if (!response?.success) { if (!response?.success) {
this.loadingManager.setStatus(translate('modals.download.status.finalizing')); 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({ showDownloadBatchSummary({
total: 1, total: 1,
completed: 0, completed: 0,
@@ -1026,7 +1206,7 @@ export class DownloadManager {
source, source,
url: this._buildSingleItemUrl({ modelId, versionId, source }), url: this._buildSingleItemUrl({ modelId, versionId, source }),
}, },
error: response?.error || 'Unknown error', error: errorMessage,
name: displayName, name: displayName,
}], }],
onRetry: () => this.executeDownloadWithProgress(retryParams), onRetry: () => this.executeDownloadWithProgress(retryParams),
@@ -1034,7 +1214,9 @@ export class DownloadManager {
return false; return false;
} }
showToast('toast.loras.downloadCompleted', {}, 'success'); if (!suppressSuccessToast) {
showToast('toast.loras.downloadCompleted', {}, 'success');
}
if (closeModal) { if (closeModal) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
@@ -1045,29 +1227,35 @@ export class DownloadManager {
ws = null; ws = null;
} }
const pageState = this.apiClient.getPageState(); if (!deferReload) {
const pageState = this.apiClient.getPageState();
if (!useDefaultPaths && targetFolder) { if (!useDefaultPaths && targetFolder) {
pageState.activeFolder = targetFolder; pageState.activeFolder = targetFolder;
setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder); setStorageItem(`${this.apiClient.modelType}_activeFolder`, targetFolder);
document.querySelectorAll('.folder-tags .tag').forEach(tag => { document.querySelectorAll('.folder-tags .tag').forEach(tag => {
const isActive = tag.dataset.folder === targetFolder; const isActive = tag.dataset.folder === targetFolder;
tag.classList.toggle('active', isActive); tag.classList.toggle('active', isActive);
if (isActive && !tag.parentNode.classList.contains('collapsed')) { if (isActive && !tag.parentNode.classList.contains('collapsed')) {
tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); tag.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} }
}); });
}
await resetAndReload(true);
} }
await resetAndReload(true);
return true; return true;
} catch (error) { } catch (error) {
if (cancelled) { if (cancelled) {
console.log('Download cancelled by user:', downloadId); console.log('Download cancelled by user:', downloadId);
} else { } else {
console.error('Failed to download model version:', error); console.error('Failed to download model version:', error);
if (suppressFailureSummary) {
this._lastDownloadError = error?.message || 'Unknown error';
return false;
}
showDownloadBatchSummary({ showDownloadBatchSummary({
total: 1, total: 1,
completed: 0, 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 }) { async _downloadHfSingle({ modelRoot, targetFolder, useDefaultPaths, files = null }) {
modalManager.closeModal('downloadModal'); modalManager.closeModal('downloadModal');
this.loadingManager.restoreProgressBar(); this.loadingManager.restoreProgressBar();
@@ -1307,6 +1578,14 @@ export class DownloadManager {
? (ver.modelSizeKB / 1024).toFixed(1) ? (ver.modelSizeKB / 1024).toFixed(1)
: (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?'); : (ver?.files?.[0]?.sizeKB ? (ver.files[0].sizeKB / 1024).toFixed(1) : '?');
const existsLocally = ver?.existsLocally; 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 ` return `
<div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}"> <div class="batch-preview-item ${existsLocally ? 'batch-preview-local' : ''}" data-index="${index}">
<div class="batch-preview-thumbnail"> <div class="batch-preview-thumbnail">
@@ -1317,7 +1596,7 @@ export class DownloadManager {
<div class="batch-preview-meta"> <div class="batch-preview-meta">
${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''} ${ver?.baseModel ? `<span>${ver.baseModel}</span>` : ''}
<span>${fileSize} MB</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>
</div> </div>
${item.versions.length > 1 ? ` ${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 ? { const fileParams = this.selectedFile ? {
id: this.selectedFile.id, id: this.selectedFile.id,
name: this.selectedFile.name || null,
type: this.selectedFile.type || 'Model', type: this.selectedFile.type || 'Model',
format: this.selectedFile.metadata?.format || null, format: this.selectedFile.metadata?.format || null,
size: this.selectedFile.metadata?.size || null, size: this.selectedFile.metadata?.size || null,
@@ -1843,8 +2134,9 @@ export class DownloadManager {
async initializeFolderTree() { async initializeFolderTree() {
try { try {
// Fetch unified folder tree // Fetch unified folder tree, including empty directories so they
const treeData = await this.apiClient.fetchUnifiedFolderTree(); // can be selected as download destinations
const treeData = await this.apiClient.fetchUnifiedFolderTree({ includeEmpty: true });
if (treeData.success) { if (treeData.success) {
// Load tree data into folder tree manager // Load tree data into folder tree manager
+86 -2
View File
@@ -7,6 +7,10 @@ import { MODEL_TYPE_DISPLAY_NAMES } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js'; import { translate } from '../utils/i18nHelpers.js';
import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js'; import { FilterPresetManager, EMPTY_WILDCARD_MARKER } from './FilterPresetManager.js';
// LoRA availability statuses available on the recipes page. No statuses
// selected (the default) means no filtering.
const LORA_AVAILABILITY_STATUSES = ['ready', 'missing', 'deleted'];
export class FilterManager { export class FilterManager {
constructor(options = {}) { constructor(options = {}) {
this.options = { this.options = {
@@ -74,6 +78,11 @@ export class FilterManager {
this.initializeLicenseFilters(); this.initializeLicenseFilters();
} }
// Add click handlers for LoRA availability tags (recipes page only)
if (this.shouldShowLoraAvailabilityFilter()) {
this.initializeLoraAvailabilityFilters();
}
// Initialize tag logic toggle // Initialize tag logic toggle
this.initializeTagLogicToggle(); this.initializeTagLogicToggle();
@@ -421,6 +430,42 @@ export class FilterManager {
}); });
} }
initializeLoraAvailabilityFilters() {
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
availabilityTags.forEach(tag => {
tag.addEventListener('click', async () => {
const status = tag.dataset.availability;
const selected = this.filters.loraAvailability || [];
if (selected.includes(status)) {
this.filters.loraAvailability = selected.filter(value => value !== status);
tag.classList.remove('active');
} else {
this.filters.loraAvailability = [...selected, status];
tag.classList.add('active');
}
this.updateActiveFiltersCount();
await this.applyFilters(false);
});
});
// Update selections based on stored filters
this.updateLoraAvailabilitySelections();
}
updateLoraAvailabilitySelections() {
const availabilityTags = document.querySelectorAll('.lora-availability-tag');
const selected = this.filters.loraAvailability || [];
availabilityTags.forEach(tag => {
if (selected.includes(tag.dataset.availability)) {
tag.classList.add('active');
} else {
tag.classList.remove('active');
}
});
}
createBaseModelTags() { createBaseModelTags() {
const baseModelTagsContainer = document.getElementById('baseModelTags'); const baseModelTagsContainer = document.getElementById('baseModelTags');
if (!baseModelTagsContainer) return; if (!baseModelTagsContainer) return;
@@ -681,6 +726,11 @@ export class FilterManager {
} }
this.updateModelTypeSelections(); this.updateModelTypeSelections();
// Update LoRA availability tags if visible on this page
if (this.shouldShowLoraAvailabilityFilter()) {
this.updateLoraAvailabilitySelections();
}
const autoTagEls = document.querySelectorAll('.auto-tag-filter'); const autoTagEls = document.querySelectorAll('.auto-tag-filter');
autoTagEls.forEach(el => { autoTagEls.forEach(el => {
const tag = el.dataset.autoTag; const tag = el.dataset.autoTag;
@@ -708,7 +758,9 @@ export class FilterManager {
const modelTypeFilterCount = this.filters.modelTypes.length; const modelTypeFilterCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count // Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount; // Active when at least one availability status is deselected
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
const totalActiveFilters = baseModelCount + tagFilterCount + autoTagFilterCount + licenseFilterCount + modelTypeFilterCount + loraAvailabilityCount;
if (this.activeFiltersCount) { if (this.activeFiltersCount) {
if (totalActiveFilters > 0) { if (totalActiveFilters > 0) {
@@ -805,6 +857,7 @@ export class FilterManager {
autoTags: {}, autoTags: {},
license: {}, license: {},
modelTypes: [], modelTypes: [],
loraAvailability: [],
tagLogic: 'any' tagLogic: 'any'
}); });
@@ -891,12 +944,14 @@ export class FilterManager {
const modelTypeCount = this.filters.modelTypes.length; const modelTypeCount = this.filters.modelTypes.length;
// Exclude EMPTY_WILDCARD_MARKER from base model count // Exclude EMPTY_WILDCARD_MARKER from base model count
const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length; const baseModelCount = this.filters.baseModel.filter(m => m !== EMPTY_WILDCARD_MARKER).length;
const loraAvailabilityCount = this.filters.loraAvailability?.length ?? 0;
return ( return (
baseModelCount > 0 || baseModelCount > 0 ||
tagCount > 0 || tagCount > 0 ||
autoTagCount > 0 || autoTagCount > 0 ||
licenseCount > 0 || licenseCount > 0 ||
modelTypeCount > 0 modelTypeCount > 0 ||
loraAvailabilityCount > 0
); );
} }
@@ -909,6 +964,7 @@ export class FilterManager {
autoTags: this.normalizeTagFilters(source.autoTags), autoTags: this.normalizeTagFilters(source.autoTags),
license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {}, license: this.shouldShowLicenseFilters() ? this.normalizeLicenseFilters(source.license) : {},
modelTypes: this.normalizeModelTypeFilters(source.modelTypes), modelTypes: this.normalizeModelTypeFilters(source.modelTypes),
loraAvailability: this.normalizeLoraAvailabilityFilters(source.loraAvailability),
tagLogic: source.tagLogic || 'any' tagLogic: source.tagLogic || 'any'
}; };
} }
@@ -917,6 +973,33 @@ export class FilterManager {
return this.currentPage !== 'recipes'; return this.currentPage !== 'recipes';
} }
shouldShowLoraAvailabilityFilter() {
return this.currentPage === 'recipes';
}
normalizeLoraAvailabilityFilters(loraAvailability) {
// Default to no statuses selected (= no filtering)
if (!Array.isArray(loraAvailability)) {
return [];
}
const seen = new Set();
return loraAvailability.reduce((acc, status) => {
if (typeof status !== 'string') {
return acc;
}
const normalized = status.trim().toLowerCase();
if (!LORA_AVAILABILITY_STATUSES.includes(normalized) || seen.has(normalized)) {
return acc;
}
seen.add(normalized);
acc.push(normalized);
return acc;
}, []);
}
normalizeTagFilters(tagFilters) { normalizeTagFilters(tagFilters) {
if (!tagFilters) { if (!tagFilters) {
return {}; return {};
@@ -994,6 +1077,7 @@ export class FilterManager {
autoTags: { ...(this.filters.autoTags || {}) }, autoTags: { ...(this.filters.autoTags || {}) },
license: { ...(this.filters.license || {}) }, license: { ...(this.filters.license || {}) },
modelTypes: [...(this.filters.modelTypes || [])], modelTypes: [...(this.filters.modelTypes || [])],
loraAvailability: [...(this.filters.loraAvailability || [])],
tagLogic: this.filters.tagLogic || 'any', tagLogic: this.filters.tagLogic || 'any',
search: pageState?.filters?.search ?? '' search: pageState?.filters?.search ?? ''
}; };
+82 -42
View File
@@ -25,7 +25,7 @@ export class ImportManager {
this.selectedFolder = ''; this.selectedFolder = '';
this.downloadableLoRAs = []; this.downloadableLoRAs = [];
this.recipeId = null; 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.useDefaultPath = false;
this.apiClient = null; this.apiClient = null;
@@ -70,10 +70,8 @@ export class ImportManager {
this.stepManager.removeInjectedStyles(); this.stepManager.removeInjectedStyles();
}); });
// Verify visibility and focus on URL input // Verify visibility and focus on the URL input (primary mode)
setTimeout(() => { setTimeout(() => {
// Ensure URL option is selected and focus on the input
this.toggleImportMode('url');
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
if (urlInput) { if (urlInput) {
urlInput.focus(); urlInput.focus();
@@ -87,6 +85,62 @@ export class ImportManager {
if (useDefaultPathToggle) { if (useDefaultPathToggle) {
useDefaultPathToggle.addEventListener('change', this.handleToggleDefaultPath); 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() { resetSteps() {
@@ -128,9 +182,11 @@ export class ImportManager {
this.downloadableLoRAs = []; this.downloadableLoRAs = [];
this.selectedFolder = ''; this.selectedFolder = '';
// Reset import mode // Import mode is set by the input handlers ('url' or 'upload')
this.importMode = 'url'; this.importMode = null;
this.toggleImportMode('url');
// Reset drop zone filename feedback
this.updateSelectedFileName(null);
// Clear folder tree selection // Clear folder tree selection
if (this.folderTreeManager) { 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 if (fileName) {
const uploadBtn = document.querySelector('.toggle-btn[data-mode="upload"]'); nameEl.textContent = fileName;
const urlBtn = document.querySelector('.toggle-btn[data-mode="url"]'); nameEl.style.display = 'block';
hintEl.style.display = 'none';
if (uploadBtn && urlBtn) { } else {
if (mode === 'upload') { nameEl.textContent = '';
uploadBtn.classList.add('active'); nameEl.style.display = 'none';
urlBtn.classList.remove('active'); hintEl.style.display = '';
} else {
uploadBtn.classList.remove('active');
urlBtn.classList.add('active');
}
} }
// 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) { handleImageUpload(event) {
@@ -345,6 +382,9 @@ export class ImportManager {
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
if (urlInput) urlInput.value = ''; if (urlInput) urlInput.value = '';
// Reset drop zone filename feedback
this.updateSelectedFileName(null);
// Clear error messages // Clear error messages
const uploadError = document.getElementById('uploadError'); const uploadError = document.getElementById('uploadError');
if (uploadError) uploadError.textContent = ''; if (uploadError) uploadError.textContent = '';
+3 -2
View File
@@ -200,8 +200,9 @@ class MoveManager {
async initializeFolderTree() { async initializeFolderTree() {
try { try {
const apiClient = this._getApiClient(); const apiClient = this._getApiClient();
// Fetch unified folder tree // Fetch unified folder tree, including empty directories so they
const treeData = await apiClient.fetchUnifiedFolderTree(); // can be selected as move targets
const treeData = await apiClient.fetchUnifiedFolderTree({ includeEmpty: true });
if (treeData.success) { if (treeData.success) {
// Load tree data into folder tree manager // Load tree data into folder tree manager
+1
View File
@@ -304,6 +304,7 @@ export class SearchManager {
pageState.searchOptions.modelname = options.modelname || false; pageState.searchOptions.modelname = options.modelname || false;
pageState.searchOptions.tags = options.tags || false; pageState.searchOptions.tags = options.tags || false;
pageState.searchOptions.creator = options.creator || false; pageState.searchOptions.creator = options.creator || false;
pageState.searchOptions.hash = options.hash || false;
} }
} }
+3
View File
@@ -904,6 +904,9 @@ export class SettingsManager {
// Helper to update model Combobox presets from catalog / Ollama API // Helper to update model Combobox presets from catalog / Ollama API
const llmModelInput = document.getElementById('llmModel'); const llmModelInput = document.getElementById('llmModel');
this._llmModelCombobox = null; this._llmModelCombobox = null;
if (llmModelInput) {
llmModelInput.value = state.global.settings.llm_model || '';
}
if (llmModelInput && typeof Combobox !== 'undefined') { if (llmModelInput && typeof Combobox !== 'undefined') {
const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai'; const currentProvider = llmProviderSelect ? llmProviderSelect.value : 'openai';
const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []); const fallbackModels = currentProvider === 'ollama' ? [] : (this._providerModels[currentProvider] || []);
+50 -9
View File
@@ -8,20 +8,32 @@ export class ImageProcessor {
handleFileUpload(event) { handleFileUpload(event) {
const file = event.target.files[0]; 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'); const errorElement = document.getElementById('uploadError');
if (!file) return;
// Validate file type // Validate file type
if (!file.type.match('image.*')) { if (!file.type.match('image.*')) {
errorElement.textContent = translate('recipes.controls.import.errors.selectImageFile', {}, 'Please select an image file'); errorElement.textContent = translate('recipes.controls.import.errors.selectImageFile', {}, 'Please select an image file');
return; return;
} }
// Reset error // Reset error
errorElement.textContent = ''; errorElement.textContent = '';
this.importManager.recipeImage = file; 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 // Auto-proceed to next step if file is selected
this.importManager.uploadAndAnalyzeImage(); this.importManager.uploadAndAnalyzeImage();
} }
@@ -30,19 +42,37 @@ export class ImageProcessor {
const urlInput = document.getElementById('imageUrlInput'); const urlInput = document.getElementById('imageUrlInput');
const errorElement = document.getElementById('importUrlError'); const errorElement = document.getElementById('importUrlError');
const input = urlInput.value.trim(); const input = urlInput.value.trim();
// Validate input // Validate input
if (!input) { if (!input) {
errorElement.textContent = translate('recipes.controls.import.errors.enterUrlOrPath', {}, 'Please enter a URL or file path'); errorElement.textContent = translate('recipes.controls.import.errors.enterUrlOrPath', {}, 'Please enter a URL or file path');
return; 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 // Reset error
errorElement.textContent = ''; 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 // Show loading indicator
this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.processingInput', {}, 'Processing input...')); this.importManager.loadingManager.showSimpleLoading(translate('recipes.controls.import.processingInput', {}, 'Processing input...'));
try { try {
// Check if it's a URL or a local file path // Check if it's a URL or a local file path
if (input.startsWith('http://') || input.startsWith('https://')) { if (input.startsWith('http://') || input.startsWith('https://')) {
@@ -55,10 +85,21 @@ export class ImageProcessor {
} catch (error) { } catch (error) {
errorElement.textContent = error.message || 'Failed to process input'; errorElement.textContent = error.message || 'Failed to process input';
} finally { } finally {
this._setFetchButtonLoading(fetchBtn, false);
this.importManager.loadingManager.hide(); 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) { async analyzeImageFromUrl(url) {
try { try {
// Call the API with URL data // Call the API with URL data
@@ -1,6 +1,7 @@
export class ImportStepManager { export class ImportStepManager {
constructor() { constructor() {
this.injectedStyles = null; this.injectedStyles = null;
this.currentStep = null;
} }
removeInjectedStyles() { removeInjectedStyles() {
@@ -18,6 +19,7 @@ export class ImportStepManager {
showStep(stepId) { showStep(stepId) {
// Remove any injected styles to prevent conflicts // Remove any injected styles to prevent conflicts
this.removeInjectedStyles(); this.removeInjectedStyles();
this.currentStep = stepId;
// Hide all steps first // Hide all steps first
document.querySelectorAll('.import-step').forEach(step => { document.querySelectorAll('.import-step').forEach(step => {
+4
View File
@@ -103,6 +103,7 @@ export const state = {
modelname: true, modelname: true,
tags: false, tags: false,
creator: false, creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
}, },
filters: { filters: {
@@ -147,6 +148,7 @@ export const state = {
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [], modelTypes: [],
loraAvailability: [],
search: '' search: ''
}, },
pageSize: 20, pageSize: 20,
@@ -168,6 +170,7 @@ export const state = {
filename: true, filename: true,
modelname: true, modelname: true,
creator: false, creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
}, },
filters: { filters: {
@@ -207,6 +210,7 @@ export const state = {
modelname: true, modelname: true,
tags: false, tags: false,
creator: false, creator: false,
hash: false,
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
}, },
filters: { filters: {
+4
View File
@@ -87,6 +87,10 @@ export const BASE_MODELS = {
UNKNOWN: "Other" 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) // Model sub-type display names (new canonical field: sub_type)
export const MODEL_SUBTYPE_DISPLAY_NAMES = { export const MODEL_SUBTYPE_DISPLAY_NAMES = {
// LoRA sub-types // LoRA sub-types
+19
View File
@@ -192,17 +192,20 @@
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div> <div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div> <div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div> <div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% elif request.path == '/embeddings' %} {% elif request.path == '/embeddings' %}
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div> <div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div> <div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div> <div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div> <div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% else %} {% else %}
<!-- Default options for LoRAs page --> <!-- Default options for LoRAs page -->
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div> <div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div> <div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div> <div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div> <div class="search-option-tag" data-option="creator">{{ t('header.search.filters.creator') }}</div>
<div class="search-option-tag" data-option="hash">{{ t('header.search.filters.hash') }}</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>
@@ -261,6 +264,22 @@
{{ t('header.filter.noTagMatches') }} {{ t('header.filter.noTagMatches') }}
</div> </div>
</div> </div>
{% if current_page == 'recipes' %}
<div class="filter-section">
<h4>{{ t('header.filter.loraAvailability') }}</h4>
<div class="filter-tags" id="loraAvailabilityTags">
<div class="filter-tag lora-availability-tag" data-availability="ready">
{{ t('header.filter.availabilityReady') }}
</div>
<div class="filter-tag lora-availability-tag" data-availability="missing">
{{ t('header.filter.availabilityMissing') }}
</div>
<div class="filter-tag lora-availability-tag" data-availability="deleted">
{{ t('header.filter.availabilityDeleted') }}
</div>
</div>
</div>
{% endif %}
{% if current_page == 'loras' or current_page == 'checkpoints' %} {% if current_page == 'loras' or current_page == 'checkpoints' %}
<div class="filter-section"> <div class="filter-section">
<h4>{{ t('header.filter.modelTypes') }}</h4> <h4>{{ t('header.filter.modelTypes') }}</h4>
+18 -28
View File
@@ -5,47 +5,37 @@
<h2>{{ t('recipes.controls.import.action') }}</h2> <h2>{{ t('recipes.controls.import.action') }}</h2>
</div> </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-step" id="uploadStep">
<div class="import-mode-toggle"> <p class="import-description">{{ t('recipes.controls.import.title') }}</p>
<button class="toggle-btn active" data-mode="url" onclick="importManager.toggleImportMode('url')">
<i class="fas fa-link"></i> {{ t('recipes.controls.import.urlLocalPath') }} <!-- Input URL/Path Section (primary mode) -->
</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 -->
<div class="import-section" id="urlSection"> <div class="import-section" id="urlSection">
<p>{{ t('recipes.controls.import.urlSectionDescription') }}</p>
<div class="input-group"> <div class="input-group">
<label for="imageUrlInput">{{ t('recipes.controls.import.imageUrlOrPath') }}</label> <label for="imageUrlInput">{{ t('recipes.controls.import.imageUrlOrPath') }}</label>
<div class="input-with-button"> <div class="input-with-button">
<input type="text" id="imageUrlInput" placeholder="{{ t('recipes.controls.import.urlPlaceholder') }}"> <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') }} <i class="fas fa-download"></i> {{ t('recipes.controls.import.fetchImage') }}
</button> </button>
</div> </div>
<div class="error-message" id="importUrlError"></div> <div class="error-message" id="importUrlError"></div>
</div> </div>
</div> </div>
<!-- Upload Image Section --> <div class="import-divider"><span>{{ t('recipes.controls.import.orDivider') }}</span></div>
<div class="import-section" id="uploadSection">
<p>{{ t('recipes.controls.import.uploadSectionDescription') }}</p> <!-- Unified drop zone: click to browse, drag & drop, or paste an image -->
<div class="input-group"> <div class="import-drop-zone" id="importDropZone" tabindex="0" role="button"
<label for="recipeImageUpload">{{ t('recipes.controls.import.selectImage') }}</label> aria-label="{{ t('recipes.controls.import.dropZoneLabel') }}">
<div class="file-input-wrapper"> <input type="file" id="recipeImageUpload" accept="image/*" hidden
<input type="file" id="recipeImageUpload" accept="image/*" onchange="importManager.handleImageUpload(event)"> onchange="importManager.handleImageUpload(event)">
<div class="file-input-button"> <i class="fas fa-cloud-upload-alt drop-zone-icon"></i>
<i class="fas fa-upload"></i> {{ t('recipes.controls.import.selectImage') }} <p class="drop-zone-primary" id="dropZonePrimaryText">{{ t('recipes.controls.import.dropZoneHint') }}</p>
</div> <p class="drop-zone-filename" id="selectedFileName" style="display: none;"></p>
</div>
<div class="error-message" id="uploadError"></div>
</div>
</div> </div>
<div class="error-message" id="uploadError"></div>
<div class="modal-actions"> <div class="modal-actions">
<button class="secondary-btn" onclick="modalManager.closeModal('importModal')">{{ t('common.actions.cancel') }}</button> <button class="secondary-btn" onclick="modalManager.closeModal('importModal')">{{ t('common.actions.cancel') }}</button>
</div> </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
+28 -16
View File
@@ -3,22 +3,41 @@
<button class="close" onclick="modalManager.closeModal('recipeModal')">&times;</button> <button class="close" onclick="modalManager.closeModal('recipeModal')">&times;</button>
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <div class="recipe-modal-header-row">
<!-- Header Actions: populated dynamically in RecipeModal.js --> <h2 id="recipeModalTitle">Recipe Details</h2>
<div class="recipe-header-actions" id="recipeHeaderActions"></div> <div class="modal-nav-controls" role="group" aria-label="{{ t('recipes.navigation.label') }}">
<button class="modal-nav-btn" id="recipeNavPrevBtn" title="{{ t('recipes.navigation.previousWithShortcut') }}" aria-label="{{ t('recipes.navigation.previousWithShortcut') }}" disabled>
<i class="fas fa-chevron-left" aria-hidden="true"></i>
</button>
<button class="modal-nav-btn" id="recipeNavNextBtn" title="{{ t('recipes.navigation.nextWithShortcut') }}" aria-label="{{ t('recipes.navigation.nextWithShortcut') }}" disabled>
<i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
</div>
</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>
<button class="modal-delete-btn" id="deleteRecipeBtn" title="{{ t('recipes.actions.deleteRecipeWithShortcut') }}" aria-label="{{ t('recipes.actions.deleteRecipeWithShortcut') }}">
<i class="fas fa-trash" aria-hidden="true"></i>
</button>
</div>
<!-- Recipe Tags Container (rendered by renderCompactTags) --> <!-- Recipe Tags Container (rendered by renderCompactTags) -->
<div id="recipeTagsContainer"></div> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<!-- Top Section: Preview and Generation Parameters --> <!-- Left Column: Preview -->
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
<!-- Source URL elements are now added dynamically in RecipeModal.js --> <!-- Source URL elements are now added dynamically in RecipeModal.js -->
</div> </div>
</div>
<div class="info-section recipe-gen-params">
<!-- Center Column: Generation Parameters -->
<div class="info-section recipe-gen-params">
<div class="gen-params-header-row"> <div class="gen-params-header-row">
<h3>Generation Parameters</h3> <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"> <label class="inline-toggle-container lora-strip-toggle" title="When enabled, &lt;lora:...&gt; tags are removed from prompt text when copying">
@@ -103,9 +122,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- Right Column: Resources -->
<!-- Bottom Section: Resources -->
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <h3>Resources</h3>
@@ -114,12 +132,6 @@
<button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </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> </div>
<div class="recipe-resources-list"> <div class="recipe-resources-list">
@@ -0,0 +1,97 @@
import { describe, it, afterEach, expect, vi } from 'vitest';
const {
BASE_MODEL_API_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
STORAGE_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
SIDEBAR_MANAGER_MODULE,
} = vi.hoisted(() => ({
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
}));
vi.mock(STATE_MODULE, () => ({
state: {},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
getSessionItem: vi.fn(),
removeSessionItem: vi.fn(),
saveMapToStorage: vi.fn(),
}));
vi.mock(API_CONFIG_MODULE, () => ({
getCompleteApiConfig: vi.fn(() => ({
endpoints: { unifiedFolderTree: '/api/lm/loras/unified-folder-tree' },
config: { displayName: 'LoRA', singularName: 'LoRA' },
})),
getCurrentModelType: vi.fn(() => 'loras'),
isValidModelType: vi.fn(() => true),
DOWNLOAD_ENDPOINTS: {},
HF_ENDPOINTS: {},
WS_ENDPOINTS: {},
}));
vi.mock(API_FACTORY_MODULE, () => ({
resetAndReload: vi.fn(),
}));
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
sidebarManager: { refresh: vi.fn() },
}));
describe('BaseModelApiClient.fetchUnifiedFolderTree', () => {
afterEach(() => {
delete global.fetch;
});
async function createClient() {
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
class TestClient extends BaseModelApiClient {}
return new TestClient('loras');
}
it('requests the plain endpoint by default', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, tree: {} }),
});
const client = await createClient();
await client.fetchUnifiedFolderTree();
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/unified-folder-tree');
});
it('appends include_empty=1 when requested', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true, tree: {} }),
});
const client = await createClient();
await client.fetchUnifiedFolderTree({ includeEmpty: true });
expect(global.fetch).toHaveBeenCalledWith('/api/lm/loras/unified-folder-tree?include_empty=1');
});
});
@@ -0,0 +1,120 @@
import { describe, it, expect, vi } from 'vitest';
const {
BASE_MODEL_API_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
STORAGE_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
SIDEBAR_MANAGER_MODULE,
} = vi.hoisted(() => ({
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
}));
vi.mock(STATE_MODULE, () => ({
state: {
global: { settings: {} },
},
getCurrentPageState: vi.fn(() => ({})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(STORAGE_MODULE, () => ({
getStorageItem: vi.fn(),
getSessionItem: vi.fn(() => null),
removeSessionItem: vi.fn(),
saveMapToStorage: vi.fn(),
}));
vi.mock(API_CONFIG_MODULE, () => ({
getCompleteApiConfig: vi.fn(() => ({
endpoints: {},
config: { displayName: 'LoRA', singularName: 'LoRA', supportsLetterFilter: false },
})),
getCurrentModelType: vi.fn(() => 'loras'),
isValidModelType: vi.fn(() => true),
DOWNLOAD_ENDPOINTS: {},
HF_ENDPOINTS: {},
WS_ENDPOINTS: {},
}));
vi.mock(API_FACTORY_MODULE, () => ({
resetAndReload: vi.fn(),
}));
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
sidebarManager: { refresh: vi.fn() },
}));
async function createClient() {
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
class TestClient extends BaseModelApiClient {}
return new TestClient('loras');
}
function makePageState(searchOptions) {
return {
viewMode: 'active',
activeFolder: null,
showFavoritesOnly: false,
showUpdateAvailableOnly: false,
filters: { search: 'abc123' },
searchOptions: {
filename: true,
modelname: true,
tags: false,
creator: false,
recursive: true,
...searchOptions,
},
};
}
describe('BaseModelApiClient._buildQueryParams hash search option', () => {
it('appends search_hash=true when the hash option is enabled', async () => {
const client = await createClient();
const params = client._buildQueryParams({}, makePageState({ hash: true }));
expect(params.get('search_hash')).toBe('true');
expect(params.get('search')).toBe('abc123');
});
it('appends search_hash=false when the hash option is disabled', async () => {
const client = await createClient();
const params = client._buildQueryParams({}, makePageState({ hash: false }));
expect(params.get('search_hash')).toBe('false');
});
it('omits search_hash when the option is absent (backend defaults to false)', async () => {
const client = await createClient();
const params = client._buildQueryParams({}, makePageState({}));
expect(params.get('search_hash')).toBeNull();
});
it('does not send search_hash without an active search term', async () => {
const client = await createClient();
const pageState = makePageState({ hash: true });
pageState.filters.search = '';
const params = client._buildQueryParams({}, pageState);
expect(params.get('search_hash')).toBeNull();
});
});
@@ -0,0 +1,100 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const getCurrentPageStateMock = vi.hoisted(() => vi.fn());
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
}));
vi.mock('../../../static/js/state/index.js', () => ({
state: {
loadingManager: {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
},
},
getCurrentPageState: getCurrentPageStateMock,
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
captureScrollPosition: vi.fn(),
restoreScrollPosition: vi.fn(),
recreateVirtualScroll: vi.fn(),
}));
import { fetchRecipesPage } from '../../../static/js/api/recipeApi.js';
function makePageState(loraAvailability) {
return {
pageSize: 50,
currentPage: 1,
hasMore: true,
isLoading: false,
sortBy: 'date:desc',
showFavoritesOnly: false,
activeFolder: null,
searchOptions: { recursive: true },
customFilter: { active: false },
filters: { loraAvailability },
};
}
describe('fetchRecipesPage lora_availability param', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ items: [], total: 0, total_pages: 0 }),
});
});
afterEach(() => {
delete global.fetch;
});
it('appends lora_availability when a subset of statuses is selected', async () => {
getCurrentPageStateMock.mockReturnValue(makePageState(['missing', 'deleted']));
await fetchRecipesPage(1, 50);
const url = global.fetch.mock.calls[0][0];
const params = new URL(url, 'http://localhost').searchParams;
expect(params.get('lora_availability')).toBe('missing,deleted');
});
it('appends lora_availability when all statuses are selected (backend treats it as show-all)', async () => {
getCurrentPageStateMock.mockReturnValue(
makePageState(['ready', 'missing', 'deleted'])
);
await fetchRecipesPage(1, 50);
const url = global.fetch.mock.calls[0][0];
const params = new URL(url, 'http://localhost').searchParams;
expect(params.get('lora_availability')).toBe('ready,missing,deleted');
});
it('omits lora_availability when no statuses are selected', async () => {
getCurrentPageStateMock.mockReturnValue(makePageState([]));
await fetchRecipesPage(1, 50);
const url = global.fetch.mock.calls[0][0];
const params = new URL(url, 'http://localhost').searchParams;
expect(params.get('lora_availability')).toBeNull();
});
it('omits lora_availability when the filter is absent', async () => {
getCurrentPageStateMock.mockReturnValue(makePageState(undefined));
await fetchRecipesPage(1, 50);
const url = global.fetch.mock.calls[0][0];
const params = new URL(url, 'http://localhost').searchParams;
expect(params.get('lora_availability')).toBeNull();
});
});
@@ -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);
});
});
@@ -2032,4 +2032,122 @@ describe('AutoComplete widget interactions', () => {
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle'); expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
expect(calledUrl).toContain('recursive=true'); expect(calledUrl).toContain('recursive=true');
}); });
describe('discoverability hints', () => {
beforeEach(() => {
localStorage.clear();
});
const typeSlashCommand = async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = 1;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('/');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
return autoComplete;
};
it('shows the current autocomplete state below the slash command list', async () => {
const autoComplete = await typeSlashCommand();
const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('/noautocomplete to disable');
});
it('shows how to re-enable autocomplete in the footer when it is off', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.prompt_tag_autocomplete') {
return false;
}
return undefined;
});
const autoComplete = await typeSlashCommand();
const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer');
expect(footer).not.toBeNull();
expect(footer.textContent).toContain('/autocomplete to enable');
});
it('stays silent when typing with tag autocomplete disabled', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.prompt_tag_autocomplete') {
return false;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'both';
}
return undefined;
});
const input = document.createElement('textarea');
input.value = 'hello';
input.selectionStart = 5;
document.body.append(input);
caretHelperInstance.getBeforeCursor.mockReturnValue('hello');
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 });
input.dispatchEvent(new Event('input', { bubbles: true }));
expect(autoComplete.isVisible).toBe(false);
expect(fetchApiMock).not.toHaveBeenCalled();
});
it('shows a dismissible first-run hint on tag suggestions and remembers dismissal', async () => {
vi.useFakeTimers();
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({
success: true,
words: [{ tag_name: '1girl', category: 4, post_count: 500000 }],
}),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1gi');
const triggerSearch = async () => {
const input = document.createElement('textarea');
input.value = '1gi';
input.selectionStart = 3;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input, 'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
return autoComplete;
};
const autoComplete = await triggerSearch();
const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint');
expect(hint).not.toBeNull();
expect(hint.textContent).toContain('/noautocomplete');
hint.querySelector('button').click();
expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
expect(localStorage.getItem('lm:autocomplete-disable-tip-dismissed')).toBe('1');
// A fresh instance no longer shows the hint once dismissed
const autoComplete2 = await triggerSearch();
expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull();
});
});
}); });
@@ -246,13 +246,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -284,7 +290,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -328,7 +330,7 @@ describe('Interaction-level regression coverage', () => {
await new Promise((resolve) => setTimeout(resolve, 60)); await new Promise((resolve) => setTimeout(resolve, 60));
await flushAsyncTasks(); await flushAsyncTasks();
expect(modalManagerMock.showModal).toHaveBeenCalledWith('recipeModal'); expect(modalManagerMock.showModal).toHaveBeenCalledWith('recipeModal', null, null, expect.any(Function));
const editIcon = document.querySelector('#recipeModalTitle .edit-icon'); const editIcon = document.querySelector('#recipeModalTitle .edit-icon');
editIcon.dispatchEvent(new Event('click', { bubbles: true })); editIcon.dispatchEvent(new Event('click', { bubbles: true }));
@@ -370,13 +372,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -408,7 +416,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -464,13 +468,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -502,7 +512,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -573,13 +579,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -611,7 +623,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -662,13 +670,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -700,7 +714,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -765,13 +775,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -803,7 +819,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -885,13 +897,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -923,7 +941,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1019,13 +1033,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1057,7 +1077,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div id="recipeCheckpoint"></div> <div id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1138,7 +1154,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeLorasList"></div> <div id="recipeLorasList"></div>
<span id="recipeLorasCount"></span> <span id="recipeLorasCount"></span>
<button id="viewRecipeLorasBtn"></button> <button id="viewRecipeLorasBtn"></button>
<button id="copyRecipeSyntaxBtn"></button>
</div> </div>
`; `;
@@ -1191,7 +1207,7 @@ describe('Interaction-level regression coverage', () => {
<div id="recipeLorasList"></div> <div id="recipeLorasList"></div>
<span id="recipeLorasCount"></span> <span id="recipeLorasCount"></span>
<button id="viewRecipeLorasBtn"></button> <button id="viewRecipeLorasBtn"></button>
<button id="copyRecipeSyntaxBtn"></button>
</div> </div>
`; `;
@@ -1255,13 +1271,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1293,7 +1315,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div id="recipeCheckpoint"></div> <div id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1368,13 +1386,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1406,7 +1430,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div id="recipeCheckpoint"></div> <div id="recipeCheckpoint"></div>
<div id="recipeResourceDivider"></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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1486,13 +1506,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1524,7 +1550,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1594,13 +1616,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1632,7 +1660,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1711,13 +1735,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1749,7 +1779,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <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"> <button class="action-btn view-loras-btn" id="viewRecipeLorasBtn" title="View all LoRAs in this recipe">
<i class="fas fa-external-link-alt"></i> <i class="fas fa-external-link-alt"></i>
</button> </button>
<button class="copy-btn" id="copyRecipeSyntaxBtn" title="Copy Recipe Syntax">
<i class="fas fa-copy"></i>
</button>
</div> </div>
</div> </div>
<div class="recipe-loras-list" id="recipeLorasList"></div> <div class="recipe-loras-list" id="recipeLorasList"></div>
@@ -1808,13 +1834,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1846,7 +1878,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <h3>Resources</h3>
@@ -1932,13 +1963,19 @@ describe('Interaction-level regression coverage', () => {
<div class="modal-content"> <div class="modal-content">
<header class="recipe-modal-header"> <header class="recipe-modal-header">
<h2 id="recipeModalTitle">Recipe Details</h2> <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> <div id="recipeTagsContainer"></div>
</header> </header>
<div class="modal-body"> <div class="modal-body">
<div class="recipe-top-section"> <div class="recipe-media-column">
<div class="recipe-preview-container" id="recipePreviewContainer"> <div class="recipe-preview-container" id="recipePreviewContainer">
<img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media"> <img id="recipeModalImage" src="" alt="Recipe Preview" class="recipe-preview-media">
</div> </div>
</div>
<div class="info-section recipe-gen-params"> <div class="info-section recipe-gen-params">
<div class="gen-params-container"> <div class="gen-params-container">
<div class="param-group info-item"> <div class="param-group info-item">
@@ -1970,7 +2007,6 @@ describe('Interaction-level regression coverage', () => {
<div class="other-params" id="recipeOtherParams"></div> <div class="other-params" id="recipeOtherParams"></div>
</div> </div>
</div> </div>
</div>
<div class="info-section recipe-bottom-section"> <div class="info-section recipe-bottom-section">
<div class="recipe-section-header"> <div class="recipe-section-header">
<h3>Resources</h3> <h3>Resources</h3>
@@ -4,13 +4,11 @@ const {
APP_MODULE, APP_MODULE,
API_MODULE, API_MODULE,
UTILS_MODULE, UTILS_MODULE,
LORAS_WIDGET_MODULE,
LORA_LOADER_MODULE, LORA_LOADER_MODULE,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname, APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.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, 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_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, LORA_PATTERN: /<lora:([^:]+):([-\d.]+)(?::([-\d.]+))?>/g,
})); }));
const addLorasWidget = vi.fn();
vi.mock(LORAS_WIDGET_MODULE, () => ({
addLorasWidget,
}));
describe("Lora Loader trigger word updates", () => { describe("Lora Loader trigger word updates", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
@@ -82,11 +74,6 @@ describe("Lora Loader trigger word updates", () => {
getWidgetByName.mockClear(); getWidgetByName.mockClear();
getWidgetSerializedValue.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 () => { 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: {}, 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 = { const node = {
comfyClass: "Lora Loader (LoraManager)", comfyClass: "Lora Loader (LoraManager)",
widgets: [metadataWidget, inputWidget], widgets: [metadataWidget, inputWidget, lorasWidget],
addInput: vi.fn(), addInput: vi.fn(),
graph: {}, 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 // The widget is now the AUTOCOMPLETE_TEXT_LORAS type, created automatically by Vue widgets
expect(node.inputWidget).toBe(inputWidget); expect(node.inputWidget).toBe(inputWidget);
expect(node.lorasWidget).toBeDefined(); expect(node.lorasWidget).toBe(lorasWidget);
expect(getWidgetByName).toHaveBeenCalledWith(node, "text"); expect(getWidgetByName).toHaveBeenCalledWith(node, "text");
expect(typeof lorasWidget.callback).toBe("function");
// The callback should have been set up by onNodeCreated // The callback should have been set up by onNodeCreated
const inputCallback = inputWidget.callback; const inputCallback = inputWidget.callback;
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MODEL_CARD_DRAG_MIME_TYPE } from '../../../static/js/utils/constants.js';
const { const {
MODEL_CARD_MODULE, MODEL_CARD_MODULE,
@@ -108,9 +109,9 @@ describe('ModelCard drag & drop preview upload', () => {
return createModelCard(model, 'loras'); return createModelCard(model, 'loras');
} }
function dispatchDrop(card, files) { function dispatchDrop(card, files, types = []) {
const event = new Event('drop', { bubbles: true, cancelable: true }); 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); card.dispatchEvent(event);
return event; return event;
} }
@@ -179,4 +180,41 @@ describe('ModelCard drag & drop preview upload', () => {
expect(event.defaultPrevented).toBe(true); expect(event.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(false); 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');
});
}); });

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