Compare commits

..
28 Commits
Author SHA1 Message Date
Will Miao 94dd08646d chore(release): bump version to v1.2.1 2026-08-16 19:47:15 +08:00
Will Miao 658f88ca48 feat(recipes): add toolbar toggle and settings preview for masonry layout 2026-08-16 15:23:30 +08:00
Will Miao f53352efb2 feat(metadata): collect generation params from Krea two/three stage samplers 2026-08-16 09:53:08 +08:00
Will Miao 38809a9d1b feat(recipes): add filename fallback tier to recipe rematch 2026-08-16 09:17:59 +08:00
Will Miao 395682509c feat(autocomplete): replace /af and /ac toggle abbreviations with full command names 2026-08-15 22:03:54 +08:00
Will Miao ef3e7d7bf4 feat(update): detect CivitAI paidAccess versions and add hide paid updates (#1060)
CivitAI's PaidAccess cutover deprecated the availability=EarlyAccess and
earlyAccessEndsAt signals; gated versions now report availability=Public
with a paidAccess DTO that LoRA Manager previously ignored, so "Hide
Early Access Updates" missed paid/early-access models and downloads
failed with 401.

Parse and persist paidAccess from model-level, bulk, and by-hash
responses; treat timed paid gates as early access and permanent paid
versions as a distinct is_paid state; add a hide_paid_updates setting
with a "Paid" badge in the versions tab; warn before downloading gated
versions. Includes SQLite migration, i18n for all locales, and
backend/frontend tests.
2026-08-15 18:08:14 +08:00
Will Miao c85b6b64a1 feat(recipes): add recently opened sort with modal open tracking
Track recipe modal opens in a separate stats file (never touching recipe
JSON/EXIF), expose a fire-and-forget POST endpoint, and add an 'opened'
sort that hides never-opened recipes as a true recently-opened view.
Includes i18n for all locales and backend/frontend tests.
2026-08-15 11:37:46 +08:00
Will Miao 34c87d4934 refactor(sort): extract seeded random sort helpers into SortDropdown 2026-08-15 09:53:28 +08:00
Will Miao 93472e5d67 feat(recipes): add sort by random option with seeded stable pagination 2026-08-15 09:50:46 +08:00
Will Miao ae185ee714 fix(loaders): correct random checkpoint loader return type annotation
load_checkpoint returns a 4-tuple (MODEL, CLIP, VAE, model_name) since the
random loader exposes the selected model name; the annotation still claimed
a 3-tuple.
2026-08-15 08:51:39 +08:00
Martial Michel 795036275a feat(loaders): add random model selection by base model to checkpoint/unet loaders
Add dedicated Random Checkpoint/Unet Loader (LoraManager) nodes that pick a random model from the indexed pool on every run, optionally filtered by base_model, and expose the selected model name via a STRING output.
2026-08-15 08:48:57 +08:00
Will Miao d43ab6e32f fix(vue-widgets): make text widget clear button undoable via Ctrl+Z (#1056) 2026-08-14 23:16:12 +08:00
Will Miao 280181f92e feat(metadata-overwrite): support wired SAMPLER input on sampler field
The sampler field now accepts either a manual string or a SAMPLER
connection. When wired, the sampler name is extracted from the
KSAMPLER object's sampler_function __name__ (sample_euler -> euler),
with special-casing for dpm_fast/dpm_adaptive local closures and
uni_pc/uni_pc_bh2 function names.

- sampler input declared as "STRING,SAMPLER" with widgetType STRING,
  mirroring the existing model field union pattern
- shared collect_overwrite_params() handles the non-str branch so the
  node and the metadata extractor conversion logic stay in sync;
  unrecognized sampler functions are logged and skipped
- note: ddim is constructed by ComfyUI as euler with random inpaint,
  so the ddim name is unrecoverable and extracts as euler
2026-08-14 15:21:28 +08:00
Will Miao f8d98934ad feat(ui): set preview via drag and drop on model cards (#1034) 2026-08-14 13:01:10 +08:00
Will Miao 303cca0d85 fix(download): accept newer CivitAI file types for primary file selection
Downloads failed with "No suitable file found in metadata" for models whose
only file uses newer CivitAI file types (e.g. 'Enhancement LoRA' for
Anima/AIR image-editing LoRAs) because the primary-file allowlist only
covered legacy types.

- unify the weights-type allowlist as MODEL_WEIGHT_FILE_TYPES
  (py/utils/constants.py) and apply it across download, recipe and
  metadata-refresh lookups
- mirror CivitAI's getPrimaryFile() semantics: prefer weights-type primary,
  fall back to weights files, then trust CivitAI's primary flag (excluding
  non-downloadable artifacts like Config/Archive/Workflow)
- mirror the allowlist in the frontend via shared isModelWeightFile() helper
- add regression tests for the Enhancement LoRA primary-file download,
  primary-flag fallback and weights-over-non-weights-primary preference
2026-08-12 21:14:23 +08:00
Will Miao c2f16784b3 fix(metadata): keep identity selectors from leaking unselected prompts 2026-08-12 19:44:43 +08:00
Will Miao 5bc6d8286c fix(metadata): exclude scalar fields from conditioning provenance inputs 2026-08-12 19:18:46 +08:00
Luna_K 3f8381ffee Fix prompt tracking through conditioning transforms 2026-08-12 19:16:30 +08:00
Will Miao 1ca99294c9 feat(delete): shorten undo window to 20s and make undo toast dismissible 2026-08-12 19:15:03 +08:00
Will Miao 680f0a57f5 fix(update): resolve template path when updating to a different base model (#1059)
Version-tab updates reused the current version's folder, so updating a LoRA
to a version with a different base model (e.g. Illustrious -> Anima) ignored
the download path template and landed in the old version's directory.

When the target version's base model differs from the current local version
and a path template is configured, re-resolve the template under the same
model root. The backend keeps an explicitly provided root when
use_save_dir_as_root is set, so regular downloads still use the default root.
2026-08-12 18:45:54 +08:00
Will Miao 94e3f54571 feat(workflow): exclude text-capable nodes with connected text from send targets
CLIP Text Encode and friends whose text widget is backed by a connected
input cannot have their text changed via the widget (execution reads the
linked input), so sending to them was a silent no-op.

- Registry: compute text_widget_connected capability from the widget's
  backing input link state; has_text_widget drops to false when wired;
  include the flag in the registration fingerprint so link changes
  re-register the affected nodes
- Registry: hook link connect/disconnect (graph events on new litegraph,
  onAfterChange fallback for classic) on root and subgraphs, plus
  subgraph-created for future subgraphs
- applyWidgetUpdate: skip inject_text when the target widget is connected
  and self-heal the registry instead of writing a value that is ignored
- Web UI: drop text_widget_connected nodes from prompt/embedding send
  candidates; show a Mark as -> Send Prompt Target hint toast when no
  candidates remain (new uiHelpers.workflow.noPromptTargets key, synced
  to all locales; zh-CN/zh-TW translated)
- Extract shared resolveTextWidget() used by both the candidate-set
  logic and the write path so the two cannot drift apart
- Tests: workflow registry connection-state registration, subgraph
  handling, fingerprint re-registration, inject_text write/skip paths,
  setup link-change hooks; uiHelpers candidate filtering and hint toast
2026-08-12 16:50:07 +08:00
Will Miao 5c2b2aedcc fix(i18n): complete recipe delete undo warning translations
Translate modals.deleteRecipe.recoverableWarning in all 9 non-English
locales (de, es, fr, he, ja, ko, ru, zh-CN, zh-TW)
2026-08-11 21:20:54 +08:00
Will Miao ebc31fb963 fix(i18n): translate recipe delete undo warning
Move the recipe delete modal's undo warning into modals.deleteRecipe.
recoverableWarning instead of hardcoded English; sync placeholders into
all 9 non-English locales
2026-08-11 21:18:36 +08:00
Will Miao 9659df6ad9 refactor(delete): make undo unconditional, remove undo toggle and button delay
- Remove delete_undo_enabled setting (backend default, frontend state,
  settings modal UI, 10 locales); staged deletes with 30s undo are now
  the only delete path and stale settings keys are silently ignored
- Remove the 1500ms delete-button arm delay (armDeleteButton) from all
  delete modals; misclicks are recoverable via the undo toast
- Delete modal always shows the recoverable warning
- Log the first staged file path in staging log lines for easier support
2026-08-11 21:15:59 +08:00
Will Miao 04d131e9dc docs(delete): correct same-volume guarantee after symlink fix 2026-08-11 19:01:44 +08:00
Will Miao 78fe6282c7 test(delete): symlink and restart regression for staged deletes 2026-08-11 19:00:28 +08:00
Will Miao 0c00ee22fc fix(delete): stage model deletes into the model folder (avoid EXDEV) 2026-08-11 18:48:03 +08:00
Will Miao 5fd4946b1f fix(delete): track staged batches in-process; reconcile at startup 2026-08-11 18:36:30 +08:00
106 changed files with 7944 additions and 1666 deletions
+202
View File
@@ -0,0 +1,202 @@
---
slug: undo-delete-staging
status: drafting
intent: clear
review_required: false
pending-action: write .omo/plans/undo-delete-staging.md
approach: "Option B: delayed physical deletion with Undo. Backend: same-volume rename to per-root staging dir (.lm-pending-delete/) [updated 2026-08: model staging moved to a SIBLING dir inside each deleted model's own folder — see 'Symlink fix (2026-08)' under Decisions] + manifest JSON (batch_id, expires_at, staged->original map) + purge (30s TTL timer + startup sweep + opportunistic) + undo-delete endpoint + settings toggle 'skip undo'. Small files (recipes: JSON+preview) copy to global staging under settings dir instead of rename. Frontend: extend toast system with action button + 30s countdown; delete flows (single model / recipe / bulk / duplicates) consume batch_id from delete response and show Undo toast; expired undo -> 'undo expired' toast. Plus confirm-modal friction (C-friction, NO type-to-confirm): delete button delay-activation 1.5s + modal shows file size 'will free X GB' + Cancel gets initial focus. i18n keys + sync_translation_keys.py."
---
# Draft: undo-delete-staging
## Components (topology ledger)
<!-- Lock the SHAPE before depth. One row per top-level component that can succeed or fail independently. -->
<!-- id | outcome (one line) | status: active|deferred | evidence path -->
- backend staging module (stage/purge/undo + manifest + per-volume dir resolution) | new module, active | pending exploration: model_lifecycle_service.py delete_model / delete_model_artifacts
- delete endpoints return batch_id (model/recipe/bulk/duplicates) | active | pending exploration: handlers + response shapes
- undo-delete HTTP endpoint + route registration | active | pending exploration: route registrar pattern
- purge scheduling (30s timer + startup sweep + opportunistic) | active | pending exploration: app on_startup hooks
- settings toggle "skip undo window" | active | pending exploration: settings service read pattern
- frontend toast extension (action button + countdown) | active | pending exploration: showToast impl
- frontend delete flows consume batch_id + Undo toast | active | pending exploration: call sites
- confirm-modal friction (delay-activate + size display + cancel focus) | active | pending exploration: modal focus behavior
- i18n keys + sync_translation_keys.py | active | known
## Open assumptions (announced defaults)
<!-- Record any default you adopt instead of asking, so the user can veto it at the gate. -->
<!-- assumption | adopted default | rationale | reversible? -->
- Undo window TTL = 30s | 30s balances space-freeing intent vs accident recovery | yes (constant)
- Staging dir name: `.lm-pending-delete/` under each model root; recipes: `{settings_dir}/.lm-pending-delete/` | hidden, same-volume [updated 2026-08: same-volume is now guaranteed by sibling staging inside the model's own folder, not by the root location], consistent | yes
- Staging failure falls back to existing hard delete | user intent is delete; staging is best-effort; hard delete likely fails identically under same conditions | yes
- Purge on startup uses expires_at (not purge-all) so a <30s restart with live tab can still undo | robust, matches client-side timer | yes
- Settings toggle label: "Delete permanently immediately (skip undo window)" | power users freeing space | yes
- C-friction: delete button enabled after 1.5s + modal shows freed size; NO type-to-confirm (user vetoed) | user explicitly rejected type-to-confirm | n/a
- Bulk/duplicates delete: one batch id for whole action, one undo restores all | simplest consistent semantics | yes
## Findings (cited - path:lines)
### Backend
- `delete_model_artifacts` (py/services/model_lifecycle_service.py:19-48) = physical delete via os.remove; patterns: main file + `{name}.metadata.json` + PREVIEW_EXTENSIONS (py/utils/constants.py:22-37). ALSO called by ModelScanner.bulk_delete_models (py/services/model_scanner.py:2221) - single swap point covers bulk models.
- `ModelLifecycleService.delete_model` (model_lifecycle_service.py:101-154): fetches `cached_entry` (111-116) - SNAPSHOT available for cache restore; after delete: cache.raw_data removal + resort + bump_cache_version (136-143), `_hash_index.remove_by_path` (145-146), `_sync_update_for_model` (148; update-service only, no recipe JSON rewrites - recipe refs are hash-based, re-resolve on restore), `_persist_current_cache` (150-152), returns `{"success": True, "deleted_files": [...]}` (154).
- Handler `delete_model` (py/routes/handlers/model_handlers.py:478-492): POST /api/lm/{prefix}/delete; response passthrough; `_broadcast_models_changed()` (57-74) after success; 400 `{"success":false,"error"}`; 500 plain text.
- Recipe delete: handler (recipe_handlers.py:1422-1438) DELETE /api/lm/recipe/{recipe_id} -> persistence_service.delete_recipe (py/services/recipes/persistence_service.py:193-209): os.remove(recipe_json_path) + os.remove(image_path) (204-206), recipe_scanner.remove_recipe (208), returns `{"success": true, "message": ...}`. PersistenceResult dataclass (20-25).
- Bulk models: POST /api/lm/{prefix}/bulk-delete (model_route_registrar.py:39) -> handler (model_handlers.py:974-994) -> lifecycle_service.bulk_delete_models (model_lifecycle_service.py:308-318) -> scanner.bulk_delete_models (model_scanner.py:2181-2269) which calls delete_model_artifacts per file (2221) + `_batch_update_cache_for_deleted_models` (2271-2335); response `{"success","status","total_deleted","total_attempted","cache_updated","results"}` (2254-2269).
- Bulk recipes: POST /api/lm/recipes/bulk-delete (recipe_route_registrar.py:50) -> handler (recipe_handlers.py:1554-1573) -> persistence_service.bulk_delete (persistence_service.py:439-482): per-id os.remove x2 (464-466), recipe_scanner.bulk_remove (472); response `{"success","deleted","failed","total_deleted","total_failed"}` (474-482).
- Duplicates: NO dedicated delete endpoints (find-only: GET /api/lm/{prefix}/find-duplicates model_route_registrar.py:59, GET /api/lm/recipes/find-duplicates recipe_route_registrar.py:49). Duplicate deletion reuses bulk-delete endpoints.
- Startup hooks: lora_manager.py:183-187 `app.on_startup.append(lambda app: cls._initialize_services())` (ComfyUI mode, app = PromptServer.instance.app at :78); standalone.py:370-374 same (StandaloneLoraManager.add_routes). Background tasks: `asyncio.create_task(name=...)` (lora_manager.py:224-239; recipe_handlers.py:793). Singleton+asyncio.Lock pattern: model_scanner.py:40-63.
- Settings: DEFAULT_SETTINGS (py/services/settings_manager.py:57-119), `get(key, default)` (1390-1392), get_settings_manager() (2215-2228), reset_settings_manager() (2231). Typed-bool getter example: get_skip_previously_downloaded_model_versions (1253-1262). Handlers: base_model_routes.py:70, base_recipe_routes.py:54.
- Model roots: ModelScanner.get_model_roots base NotImplementedError (model_scanner.py:1073-1075); impls lora_scanner.py:31-45, checkpoint_scanner.py:428-441, embedding_scanner.py:24-36. `_find_root_for_file(file_path)` (model_scanner.py:1108-1124) returns containing root - for per-root staging dir computation [updated 2026-08: staging no longer uses the containing root; batches are siblings inside the model's own folder]. Business-path rule (AGENTS.md): use os.path.abspath, never realpath, for staging/undo routing.
- Cache restore methods: ModelCache has raw_data + resort (conftest mocks: tests/conftest.py:144-154); ModelHashIndex.add_entry(sha256, file_path, autov3) (py/services/model_hash_index.py:16); RecipeScanner.add_recipe(recipe_data) (recipe_scanner.py:2136) -> recipe_cache.add_recipe (recipe_cache.py:64). No single-file incremental model rescan - use snapshot restore instead of rescan.
- Route registrar: model_route_registrar.py:177 add_route(method, path, handler), :180 add_prefixed_route - undo endpoint can be a non-prefixed route via add_route.
- Tests: tests/services/test_model_lifecycle_service.py (inline tmp_path files, per-test stub scanners ScannerForDelete/VersionAwareScanner etc); conftest MockScanner/MockCache/MockHashIndex (tests/conftest.py:134-212); integration fixtures tests/integration/conftest.py; lifecycle hook tests tests/routes/test_lora_manager_lifecycle.py:177-178, tests/standalone/test_standalone_server.py:83-84.
### Frontend
- 5 delete call sites:
a) Single model: static/js/utils/modalUtils.js confirmDelete (27-42) -> getModelApiClient().deleteModel(path); ignores return.
b) Recipe single: static/js/components/RecipeCard.js confirmDeleteRecipe (405-449) - RAW fetch DELETE /api/lm/recipe/{id}, checks only response.ok, showToast toast.recipes.deletedSuccessfully, state.virtualScroller.removeItemByFilePath.
c) Bulk: static/js/managers/BulkManager.js confirmBulkDelete (633-672) -> getActiveApiClient() (134-142) -> bulkDeleteModels(filePaths); reads result.cancelled/success/deleted_count/error.
d) Recipe duplicates: static/js/components/DuplicatesManager.js confirmDeleteDuplicates (457-494) - RAW fetch POST /api/lm/recipes/bulk-delete, reads data.success/data.total_deleted, exitDuplicateMode().
e) Model duplicates: static/js/components/ModelDuplicatesManager.js confirmDeleteDuplicates (710-776) - RAW fetch POST /api/lm/{type}/bulk-delete, reads data.total_deleted, then resetAndReload(true) + find-duplicates re-check.
Bonus: static/js/components/shared/ModelVersionsTab.js:1136-1144 client.deleteModel (ignores return).
- API clients: BaseModelApiClient.deleteModel (static/js/api/baseModelApi.js:184-216) returns true/false, shows its own toasts, does removeItemByFilePath inside; bulkDeleteModels (1591-1642) returns {success, deleted_count, failed_count, errors} or {success:false, cancelled:true}; RecipeSidebarApiClient.bulkDeleteModels (recipeApi.js:623-664) returns {success, deleted_count: total_deleted, ...}. Endpoint map apiConfig.js:56,64.
- Toast: showToast(key, params={}, type='info', fallback=null) (static/js/utils/uiHelpers.js:136-193) - textContent only, NO action/button support; durations 2000/5000ms; CSS static/css/components/toast.css (.toast flex gap:12px - button can be added). Closest action pattern: bannerService.registerBanner actions array + onRegister (static/js/managers/BannerService.js; used uiHelpers.js:18-57).
- i18n: locales/en.json delete keys (1303-1314 bulkDelete, 1945-1948 recipes, 1987-1991 models, 2124-2130 duplicates, 2166-2170 toast.api); t()/interpolate (static/js/i18n/index.js:193-248); translate wrapper (utils/i18nHelpers.js:13-23); sync script scripts/sync_translation_keys.py (en reference, [TODO: Translate] placeholders).
- Refresh after undo: recipes -> window.recipeManager.loadRecipes(true) (recipes.js:359; used by FilterManager.js:752 etc) or refreshRecipes (recipeApi.js:308); models -> resetAndReload(true) from modelApiFactory (used by ModelDuplicatesManager.js:740).
- Size for modal: card.dataset.file_size (ModelCard.js:467), formatFileSize (ModelModal.js:615).
- Tests: tests/frontend/utils/uiHelpers.dom.test.js (toast), api/recipeApi.bulk.test.js, components/duplicatesManager.test.js, components/modelDuplicatesManager.test.js, pages/*Page.test.js, i18n tests tests/i18n/test_i18n.py.
## Decisions (with rationale)
1. Same-volume rename staging for model files (atomic, no copy cost for multi-GB files); cross-volume rename forbidden. [CORRECTED 2026-08: "same-volume because under the containing root" was only true for plain directories — nested symlinked subdirs could cross volumes. Superseded by sibling staging: `.lm-pending-delete/<batch_id>/` inside the deleted model's own folder makes stage/undo same-device by construction; see "Symlink fix (2026-08)" below.]
2. Copy-to-global-staging for recipes (small files; avoids recipe JSON vs preview image cross-volume problem).
3. Manifest JSON files are the only state - no DB changes. Manifest includes model cached_entry snapshot for exact cache restore (no rescan needed).
4. Undo endpoint returns restored paths; expired batch -> 404-style error -> frontend 'undo expired' toast.
5. Skip-undo setting honored server-side (no batch_id in response -> no undo toast client-side).
6. Staging failure falls back to existing hard delete (best-effort undo, never blocks delete).
7. Undo window TTL = 30s constant (PENDING_DELETE_TTL_SECONDS); startup sweep uses expires_at (survives restart; browser-tab timer survives).
8. Purge triple-trigger: per-batch asyncio timer task + on_startup sweep + opportunistic purge at each stage/undo.
9. Frontend: new showActionToast (keep showToast signature untouched; extract shared createToastElement/appendToast internals); undo click -> shared handleUndoDelete(batchId, refreshFn); full list refresh after undo (recipes: window.recipeManager.loadRecipes(true); models: resetAndReload(true)).
10. C-friction wave (NO type-to-confirm - user vetoed): delete buttons delay-activate 1.5s after modal open, initial focus on Cancel, model delete modal gains "permanently deleted from disk" warning + file size display (card.dataset.file_size + formatFileSize).
11. Model cache restore on undo: append snapshot to cache.raw_data (dedupe by file_path) + resort + bump_cache_version + _persist_current_cache + _hash_index.add_entry + _broadcast_models_changed. Recipe restore: copy back files + recipe_scanner.add_recipe(recipe_data loaded from restored JSON).
### Symlink fix (2026-08)
Post-execution addendum (plan `.omo/plans/undo-delete-symlink-fix.md`, commits 5fd4946b / 0c00ee22):
12. Model staging moved from `<model_root>/.lm-pending-delete/<batch_id>/` to `<model_dir>/.lm-pending-delete/<batch_id>/` (sibling of the model artifacts, inside the deleted model's own folder). Stage/undo renames are same-device BY CONSTRUCTION — EXDEV is impossible even when the business path traverses nested symlinks to other volumes (the decision-1 "containing root" guarantee covered only plain directories). EXDEV remains possible only for cross-volume merges, which keep the batch_ids-array fallback. Accepted edge: deleting the model's whole FOLDER during the 30s window destroys that batch (undo returns 404). Batch discovery uses an in-memory registry (`_known_batch_dirs`) with a startup reconciliation scan (`purge_expired(scan_roots=True)`) covering restarts and crash leftovers. Recipe batches unchanged (copy-based settings-dir staging with the `_restore_file` EXDEV fallback).
## Scope IN
- Model single delete (model_handlers delete_model / model_lifecycle_service)
- Recipe delete (recipe_handlers delete_recipe / persistence_service)
- Bulk delete (models scanner + recipes persistence) + duplicates (reuse bulk endpoints)
- Undo endpoint POST /api/lm/undo-delete (models + recipes, one batch space)
- Purge: timer + startup sweep + opportunistic
- Settings toggle delete_undo_enabled + settings page checkbox
- Frontend: showActionToast + all 5 delete flows + shared undo handler
- C-friction modal changes (delay-activate + cancel focus + warning copy + size display)
- i18n keys + sync_translation_keys.py
- Backend + frontend tests
## Scope OUT (Must NOT have)
- NO type-to-confirm / hold-to-confirm friction (user vetoed)
- NO OS trash integration (send2trash) in this iteration
- NO persistent recycle-bin UI (no trash browsing page)
- NO changes to exclude/unexclude flow
- NO DB migrations
- NO new dependencies (no send2trash)
- NO changes to download flows
- NO recipe-JSON rewriting on model undo (hash-based refs re-resolve themselves)
## Open questions
None - all implementation details resolved by exploration. Design decisions settled in conversation (B+C, no type-to-confirm).
## Approval gate
status: approved
<!-- Approach approved -> rerun scaffold without --draft-only, run Metis gap analysis, APPEND todo batches, fill TL;DR last, run structural self-check, then Phase 4 handoff. -->
## Review round state (ulw-plan-review-round-state-contract)
```json
{
"transition": "replace",
"phase": "review_round_initialized",
"applies_when": ["retry_after_plan_change"],
"atomic": true,
"review_required": true,
"plan_path": ".omo/plans/undo-delete-staging.md",
"plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc",
"review_round_id": "rr-undo-del-20260811-006",
"round_status": "active",
"pending-action": "review .omo/plans/undo-delete-staging.md",
"review": {
"momus": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null },
"independent": { "status": "pending", "workspace_root": "/mnt/data/reinstall-backup-2026-04-12/data/workspace/ComfyUI/custom_nodes/ComfyUI-Lora-Manager", "runtime_home": null, "target": ".omo/plans/undo-delete-staging.md", "round_id": "rr-undo-del-20260811-006", "plan_sha256": "8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc", "launch_id": null, "session": null, "result": null }
}
}
```
## Review results + fix/retry ledger
### Round 1 (rr-undo-del-20260811-001, plan sha256 6c52bf99...)
- momus: APPROVE (non-blocking notes: todo1+7 duplicate DEFAULT_SETTINGS key -> fixed todo 7 to verify-only; "batch_ids" plural in todos 8/9 acceptance -> fixed; purge OSError note -> folded into todo 1 purge semantics)
- independent (oracle): CHANGES_REQUESTED
- BLOCKING S1: scanner walks would index .lm-pending-delete staged files as ghost entries -> fixed: todo 1 now mandates scanner walk exclusion at model_scanner.py:706/:867/:1404/_process_model_file + acceptance (o) scanner-visibility test
- BLOCKING S2: manifest lacks model_type, undo could restore into wrong cache/hash index -> fixed: manifest now carries model_type + todo 5 resolves per-type scanner via registrar pattern + acceptance (b) checkpoint-batch test
- S3 merged-batch expires_at re-anchor -> fixed: merge_batches re-anchors now+TTL in todo 1 + todo 3/4 assertions
- S4 manifest-less dir policy -> fixed: quarantine to <batch_id>.orphaned, never delete (todo 1 + acceptance g)
- S5 partial-undo retry semantics -> fixed: per-entry restored flag write-through + retry test (acceptance e)
- S6 purge locked-file failure semantics -> fixed: skip file, keep batch, never rmtree past errors (todo 1 + acceptance i)
- T8 undo-after-restart test -> fixed: todo 5 acceptance (f)
- T9 recipe undo -> re-delete test -> fixed: todo 5 acceptance (h)
- T7 rescan-stale-entry test -> fixed: todo 5 acceptance (g)
- Route registration pinned to shared routes class per mode (NOT per-model-type registrar which registers 3x) -> fixed: todo 5 now creates py/routes/pending_delete_routes.py registered once in lora_manager.py:170-172 + standalone.py:356-358 + duplicate-route test (e)
- Version-index staleness on single-delete undo -> fixed: todo 5 follows bulk cache-update pattern incl. rebuild_version_index (model_scanner.py:2324)
- Cancelled-bulk batch_id frontend handling -> fixed: todo 9 shows action toast on cancelled+staged-subset
- Single-instance assumption -> added to Scope OUT
- Occupied-refusal loss UX -> accepted-intent documented in success criteria + modal copy
### Round 2 (rr-undo-del-20260811-002, plan sha256 f3d52235...)
- momus: APPROVE (all 12 round-1 fixes verified present; zero dead references; non-blocking nits only)
- independent (oracle): CHANGES_REQUESTED
- BLOCK-1: merge_batches file-movement semantics unspecified (silent data-loss vector) -> fixed: todo 1 now specifies move-into-winner-dir + entry re-point + loser-dirs-removed-only-when-empty + abort-on-move-failure (all batches intact) + merge inside service lock + acceptance (k) file-survival assertions + acceptance (l) merge-failure abort test
- BLOCK-2: same-file parallel edits within waves (todo 5 vs 6 on lora_manager.py; todo 8 vs 9 on baseModelApi.js) -> fixed: waves/matrix now serialize 5->6 and 8->9 with explicit reasons; matrix updated
- Recommended: checkpoint_scanner.py:331 exclusion -> fixed (todo 1 + acceptance p); S5 pre-check skips restored:true entries -> fixed (todo 1); _tags_count restore on undo -> fixed (todo 5 + acceptance j); undo-blind flows documented (ModelVersionsTab + misc_handlers:2456) -> fixed (todo 8 note + Scope OUT); merge-failure no-merge fallback contract (batch_ids array) -> fixed (todos 3/4/9)
### Round 3 (rr-undo-del-20260811-003, plan sha256 8f2dfd46...)
- momus: APPROVE (all round-2 fixes verified present + spot-checked refs; no new contradictions)
- independent (oracle): CHANGES_REQUESTED
- BLOCKING A: merged batches never timer-purged after re-anchor (winner's original timer no-ops at old expiry; no fresh timer for re-anchored expiry; idle server -> merged batch lingers, violating "30s purge" success criterion; affects EVERY bulk delete) -> fixed: todo 1 merge_batches now ARMS A FRESH PURGE TIMER for the winner with re-anchored expiry + acceptance (q) fresh-timer test + purge_expired must enumerate ALL scanner types' roots (explicit in todo 1)
- BLOCKING B: dependency matrix contradicted same-file policy for todos 8/9<->11 (5 shared files) and 12<->11 -> fixed: todo 11 now "Blocked by: 8, 9 (same files...)"; todo 12 blocked by 11 (sync after 11); wave text updated (11, then 12 AFTER 11); "Can parallelize with" columns corrected
- BLOCKING C: frontend batch_ids sequential-undo fallback has NO test + merge->undo loser-restore + merge->purge assertions missing -> fixed: todo 9 acceptance now tests the batch_ids fallback path; todo 1 acceptance now has (k2)/(k3)
- Notes folded: sub-second toast-tail expiry race accepted; EXDEV fallback = NORMAL path for cross-volume bulks [annotated 2026-08: after the sibling-staging fix, EXDEV can only arise during cross-volume MERGES, never during single stage/undo renames]
### Round 4 (rr-undo-del-20260811-004, plan sha256 179e7ff7...)
- momus: APPROVE (round-3 fixes verified; one non-blocking nit: todo 11 inline "Blocked by: —" stale -> fixed to "8, 9")
- independent (oracle): CHANGES_REQUESTED
- BLOCKING GAP-1 (NEW, introduced by round-3 fix): todo 8 handleUndoDelete always-refresh/always-toast contract contradicted todo 9's sequential loop "exactly ONE final refresh" -> fixed: handleUndoDelete(batchId, refreshFn, {showToast, refresh}) suppression options; todo 9 loop uses suppressed calls + one final refresh/toast; acceptance extended (loop failure mid-way -> stop + error toast + no final refresh; 404 body discrimination expired vs occupied)
- BLOCKING GAP-2: no cross-type purge enumeration test -> fixed: todo 1 acceptance (r) purges expired batches across lora root + checkpoint root + recipe staging dir in one call
- Non-blocking folded: GAP-3 404-copy discrimination -> fixed in todo 8 (d); GAP-4 merge partial-failure rollback direction (move back + restore manifests, extended (l) asserts sequential constituent undo still restores everything) -> fixed in todo 1; GAP-5 post-restart timer-loss residual gap documented -> fixed in todo 6; GAP-6 usage_stats.py:424 walk added to exclusion mandate + todo 5 acceptance (k) embeddings undo test
### Round 5 (rr-undo-del-20260811-005, plan sha256 dfaa39ea...)
- momus: APPROVE (all round-4 fixes verified; no new contradictions)
- independent (oracle): CHANGES_REQUESTED
- BLOCK-1: lock-ordering deadlock ambiguity (asyncio.Lock not re-entrant: opportunistic purge_expired called while stage/undo hold the lock would deadlock on first use) -> fixed: todo 1 now has explicit LOCK HIERARCHY (lock acquired ONLY by stage/merge/undo/purge_batch; purge_expired is lock-free and must be called BEFORE lock acquisition); todo 6 (c) updated with the same rule + acceptance (u) lock-no-deadlock test
- BLOCK-2: purge edge semantics unspecified -> fixed: purge_batch treats missing staged files (partially-restored batches) as already-purged (FileNotFoundError silent no-op); sweep skips `.orphaned`-suffixed dirs (quarantine is terminal); acceptance (s) partially-restored purge + (t) quarantine-terminal tests
- Non-blocking folded: todo 2/3 test-file collision -> todo 3's bulk tests moved to tests/services/test_model_scanner.py; todo 9 (d) DuplicatesManager refreshFn stated explicitly (recipes loadRecipes / models resetAndReload); modal-copy + bulk-count trade-offs acknowledged in success criteria; acceptance (r) extended with embeddings root
### Round 6 (rr-undo-del-20260811-006, plan sha256 8cf7c9be...)
- momus: APPROVE (all round-5 fixes verified; no new contradictions; references verified)
- independent (oracle): APPROVE — no blocking issues; all round-5 items fixed with working, tested solutions; no new race/data-loss/consistency defects
- Deferred optional improvements (non-blocking, recorded for executor awareness; plan file left untouched to preserve the approved digest):
1. Tag-count asymmetry: single delete_model never decrements _tags_count (lifecycle 101-154), bulk does (scanner 2297-2303); undo re-increment is exact for bulk, over-counts for single until rescan (cosmetic, self-healing). Optional fix riding in todo 2: decrement tags in the single-delete path to mirror bulk.
2. Todo 5 factual nit: ModelCache.resort() already rebuilds the version index — explicit rebuild in undo is belt-and-braces, no action needed.
3. Todo 8 premise nit: ModelVersionsTab call ignores deleteModel's return entirely — nothing breaks, no adaptation needed.
4. Todo 3's pytest command includes test_model_lifecycle_service.py which todo 2 edits in the same wave — run that file's tests after todo 2 lands.
5. merge_batches with a missing/quarantined constituent id: any sane fallback (abort -> batch_ids, or skip missing) acceptable — files stay staged either way.
## Review lifecycle
- rounds: 6 (rr-undo-del-20260811-001..006); final round both lanes APPROVE
- final live-plan validation: sha256 = 8cf7c9be38a76d8ef1fb832aba043d6d7e82b60465b1bf28c6eafa7045117adc — MATCHES approved round-6 digest
- status: APPROVED — ready for execution handoff ($start-work undo-delete-staging)
File diff suppressed because one or more lines are too long
+10
View File
@@ -3,6 +3,8 @@ try: # pragma: no cover - import fallback for pytest collection
from .py.nodes.lora_loader import LoraLoaderLM, LoraTextLoaderLM
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
from .py.nodes.unet_loader import UNETLoaderLM
from .py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
from .py.nodes.random_unet_loader import RandomUNETLoaderLM
from .py.nodes.trigger_word_toggle import TriggerWordToggleLM
from .py.nodes.prompt import PromptLM
from .py.nodes.text import TextLM
@@ -40,6 +42,12 @@ except (
"py.nodes.checkpoint_loader"
).CheckpointLoaderLM
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
RandomCheckpointLoaderLM = importlib.import_module(
"py.nodes.random_checkpoint_loader"
).RandomCheckpointLoaderLM
RandomUNETLoaderLM = importlib.import_module(
"py.nodes.random_unet_loader"
).RandomUNETLoaderLM
TriggerWordToggleLM = importlib.import_module(
"py.nodes.trigger_word_toggle"
).TriggerWordToggleLM
@@ -79,6 +87,8 @@ NODE_CLASS_MAPPINGS = {
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
UNETLoaderLM.NAME: UNETLoaderLM,
RandomCheckpointLoaderLM.NAME: RandomCheckpointLoaderLM,
RandomUNETLoaderLM.NAME: RandomUNETLoaderLM,
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
LoraStackerLM.NAME: LoraStackerLM,
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
+327 -295
View File
File diff suppressed because it is too large Load Diff
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "Bereits heruntergeladene Modellversionen überspringen",
"help": "Wenn aktiviert, überspringt LoRA Manager den Download einer Modellversion, wenn der Download-Verlaufsdienst diese spezifische Version als bereits heruntergeladen erfasst hat. Gilt für alle Download-Abläufe."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Nach Modell gruppieren",
"groupByModelHelp": "Wenn aktiviert, wird nur die neueste Version jedes Civitai-Modells als einzelne Karte angezeigt. Ältere Versionen werden ausgeblendet.",
@@ -623,6 +622,10 @@
"label": "Früher Zugriff Updates ausblenden",
"help": "Nur Early-Access-Updates"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
@@ -921,7 +924,9 @@
"dateAsc": "Älteste",
"lorasCount": "LoRA-Anzahl",
"lorasCountDesc": "Meiste",
"lorasCountAsc": "Wenigste"
"lorasCountAsc": "Wenigste",
"opened": "Zuletzt geöffnet",
"openedDesc": "Zuletzt geöffnet"
},
"refresh": {
"title": "Rezeptliste aktualisieren",
@@ -932,6 +937,11 @@
"favorites": {
"title": "Nur Favoriten anzeigen",
"action": "Favoriten"
},
"layout": {
"title": "Rezepte-Layout",
"grid": "Raster-Layout",
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Gibt {size} frei",
"title": "Modell löschen",
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Die Datei wird nach 20 Sekunden endgültig gelöscht, sofern Sie nicht rückgängig machen."
},
"deleteRecipe": {
"recoverableWarning": "Diese Aktion kann 20 Sekunden lang rückgängig gemacht werden."
},
"excludeModel": {
"title": "Modell ausschließen",
@@ -1547,6 +1559,8 @@
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
"earlyAccess": "Früher Zugriff",
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Ignoriert",
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
"onSiteOnly": "Nur On-Site",
@@ -1556,6 +1570,7 @@
"download": "Herunterladen",
"downloadTooltip": "Diese Version herunterladen",
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
"delete": "Löschen",
"deleteTooltip": "Diese lokale Version löschen",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "Rezept im Workflow ersetzt",
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
"modelUpdated": "Modell im Workflow aktualisiert",
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
@@ -2118,12 +2134,12 @@
"copyFailed": "Kopieren fehlgeschlagen"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Rückgängig",
"deleted": "Gelöscht: {name}",
"deletedBulk": "{count} Element(e) gelöscht",
"expired": "Undo-Fenster abgelaufen. Das Element wurde endgültig gelöscht.",
"failed": "Rückgängig machen fehlgeschlagen: {error}",
"restored": "Element wiederhergestellt"
},
"virtual": {
"loadFailed": "Fehler beim Laden der Elemente",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
"previewUpdated": "Vorschau erfolgreich aktualisiert",
"previewUploadFailed": "Fehler beim Hochladen des Vorschaubilds",
"previewDropInvalid": "Nicht unterstützter Dateityp: {name}. Ziehen Sie stattdessen ein Bild oder ein MP4-Video hinein.",
"refreshComplete": "{action} abgeschlossen",
"refreshFailed": "Fehler beim {action} der {type}s",
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
+21 -4
View File
@@ -443,7 +443,6 @@
"label": "Skip previously downloaded model versions",
"help": "When enabled, versions downloaded before will be skipped."
},
"deleteUndoEnabled": "Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Group by Model",
"groupByModelHelp": "When enabled, only the latest version of each Civitai model is shown as a single card. Older versions are hidden.",
@@ -623,6 +622,10 @@
"label": "Hide Early Access Updates",
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
},
"hidePaidUpdates": {
"label": "Hide Paid Updates",
"help": "When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Use updated license icons",
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
@@ -921,7 +924,9 @@
"dateAsc": "Oldest",
"lorasCount": "LoRA Count",
"lorasCountDesc": "Most",
"lorasCountAsc": "Least"
"lorasCountAsc": "Least",
"opened": "Recently Opened",
"openedDesc": "Recently opened"
},
"refresh": {
"title": "Refresh recipe list",
@@ -932,6 +937,11 @@
"favorites": {
"title": "Show Favorites Only",
"action": "Favorites"
},
"layout": {
"title": "Recipes Layout",
"grid": "Grid layout",
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
}
},
"duplicates": {
@@ -1281,8 +1291,10 @@
"freesSpace": "Frees {size}",
"title": "Delete Model",
"message": "Are you sure you want to delete this model and all associated files?",
"permanentWarning": "This will permanently delete the file from disk.",
"recoverableWarning": "This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo."
},
"deleteRecipe": {
"recoverableWarning": "This action can be undone for 20 seconds."
},
"excludeModel": {
"title": "Exclude Model",
@@ -1547,6 +1559,8 @@
"newerTooltip": "This version is newer than your latest local version",
"earlyAccess": "Early Access",
"earlyAccessTooltip": "This version currently requires Civitai early access",
"paid": "Paid",
"paidTooltip": "This version requires payment to download",
"ignored": "Ignored",
"ignoredTooltip": "Update notifications are disabled for this version",
"onSiteOnly": "On-Site Only",
@@ -1556,6 +1570,7 @@
"download": "Download",
"downloadTooltip": "Download this version",
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
"downloadPaidTooltip": "Download this paid version from Civitai",
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
"delete": "Delete",
"deleteTooltip": "Delete this local version",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "Recipe replaced in workflow",
"recipeFailedToSend": "Failed to send recipe to workflow",
"noMatchingNodes": "No compatible nodes available in the current workflow",
"noPromptTargets": "No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No target node selected",
"modelUpdated": "Model updated in workflow",
"modelFailed": "Failed to update model node",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "Failed to rename file: {error}",
"previewUpdated": "Preview updated successfully",
"previewUploadFailed": "Failed to upload preview image",
"previewDropInvalid": "Unsupported file type: {name}. Drop an image or MP4 video instead.",
"refreshComplete": "{action} complete",
"refreshFailed": "Failed to {action} {type}s",
"metadataRefreshed": "Metadata refreshed successfully",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "Omitir versiones de modelos previamente descargadas",
"help": "Cuando está habilitado, LoRA Manager omitirá la descarga de una versión de modelo si el servicio de historial de descargas registra esa versión exacta como ya descargada. Aplica a todos los flujos de descarga."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Agrupar por modelo",
"groupByModelHelp": "Cuando está activado, solo se muestra la versión más reciente de cada modelo de Civitai como una tarjeta única. Las versiones anteriores están ocultas.",
@@ -623,6 +622,10 @@
"label": "Ocultar actualizaciones de acceso temprano",
"help": "Solo actualizaciones de acceso temprano"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Usar iconos de licencia actualizados",
"useNewStyleHelp": "Mostrar permisos de licencia con indicadores de color (nuevo estilo) o solo iconos de restricción (estilo clásico). Refleja el diseño actual de CivitAI."
@@ -921,7 +924,9 @@
"dateAsc": "Más antiguo",
"lorasCount": "Cant. de LoRAs",
"lorasCountDesc": "Más",
"lorasCountAsc": "Menos"
"lorasCountAsc": "Menos",
"opened": "Abiertos recientemente",
"openedDesc": "Abiertos recientemente"
},
"refresh": {
"title": "Actualizar lista de recetas",
@@ -932,6 +937,11 @@
"favorites": {
"title": "Mostrar solo favoritos",
"action": "Favoritos"
},
"layout": {
"title": "Diseño de recetas",
"grid": "Vista de cuadrícula",
"masonry": "Vista masonry (estilo Pinterest, conserva la proporción de aspecto de la imagen)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Libera {size}",
"title": "Eliminar modelo",
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "El archivo se eliminará permanentemente después de 20 segundos a menos que deshaga la acción."
},
"deleteRecipe": {
"recoverableWarning": "Esta acción se puede deshacer durante 20 segundos."
},
"excludeModel": {
"title": "Excluir modelo",
@@ -1547,6 +1559,8 @@
"newerTooltip": "Esta versión es más reciente que tu última versión local",
"earlyAccess": "Acceso temprano",
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Ignorada",
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
"onSiteOnly": "Solo en Sitio",
@@ -1556,6 +1570,7 @@
"download": "Descargar",
"downloadTooltip": "Descargar esta versión",
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
"delete": "Eliminar",
"deleteTooltip": "Eliminar esta versión local",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
"modelFailed": "Error al actualizar nodo de modelo",
@@ -2118,12 +2134,12 @@
"copyFailed": "Error al copiar"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Deshacer",
"deleted": "Eliminado: {name}",
"deletedBulk": "{count} elemento(s) eliminado(s)",
"expired": "La ventana de deshacer ha caducado. El elemento se eliminó permanentemente.",
"failed": "No se pudo deshacer: {error}",
"restored": "Elemento restaurado"
},
"virtual": {
"loadFailed": "Error al cargar elementos",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "Error al renombrar archivo: {error}",
"previewUpdated": "Vista previa actualizada exitosamente",
"previewUploadFailed": "Error al subir imagen de vista previa",
"previewDropInvalid": "Tipo de archivo no admitido: {name}. Arrastra una imagen o un video MP4 en su lugar.",
"refreshComplete": "{action} completada",
"refreshFailed": "Error al {action} {type}s",
"metadataRefreshed": "Metadatos actualizados exitosamente",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "Ignorer les versions de modèles précédemment téléchargées",
"help": "Lorsque activé, LoRA Manager ignorera le téléchargement d'une version de modèle si le service d'historique des téléchargements enregistre cette version exacte comme déjà téléchargée. S'applique à tous les flux de téléchargement."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Grouper par modèle",
"groupByModelHelp": "Lorsque activé, seule la version la plus récente de chaque modèle Civitai s'affiche sous forme de carte unique. Les versions plus anciennes sont masquées.",
@@ -623,6 +622,10 @@
"label": "Masquer les mises à jour en accès anticipé",
"help": "Seulement les mises à jour en accès anticipé"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Utiliser les icônes de licence mises à jour",
"useNewStyleHelp": "Afficher les permissions de licence avec des indicateurs colorés (nouveau style) ou des icônes de restriction uniquement (style classique). Reprend le design actuel de CivitAI."
@@ -921,7 +924,9 @@
"dateAsc": "Plus ancien",
"lorasCount": "Nombre de LoRAs",
"lorasCountDesc": "Plus",
"lorasCountAsc": "Moins"
"lorasCountAsc": "Moins",
"opened": "Récemment ouverts",
"openedDesc": "Récemment ouverts"
},
"refresh": {
"title": "Actualiser la liste des recipes",
@@ -932,6 +937,11 @@
"favorites": {
"title": "Afficher uniquement les favoris",
"action": "Favoris"
},
"layout": {
"title": "Disposition des recettes",
"grid": "Disposition en grille",
"masonry": "Disposition masonry (style Pinterest, préserve le rapport d'aspect de l'image)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Libère {size}",
"title": "Supprimer le modèle",
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Le fichier sera définitivement supprimé après 20 secondes, sauf si vous annulez."
},
"deleteRecipe": {
"recoverableWarning": "Cette action peut être annulée pendant 20 secondes."
},
"excludeModel": {
"title": "Exclure le modèle",
@@ -1547,6 +1559,8 @@
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
"earlyAccess": "Accès anticipé",
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Ignorée",
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
"onSiteOnly": "Uniquement sur Site",
@@ -1556,6 +1570,7 @@
"download": "Télécharger",
"downloadTooltip": "Télécharger cette version",
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
"delete": "Supprimer",
"deleteTooltip": "Supprimer cette version locale",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "Recipe remplacée dans le workflow",
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
"modelUpdated": "Modèle mis à jour dans le workflow",
"modelFailed": "Échec de la mise à jour du nœud modèle",
@@ -2118,12 +2134,12 @@
"copyFailed": "Échec de la copie"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Annuler",
"deleted": "Supprimé : {name}",
"deletedBulk": "{count} élément(s) supprimé(s)",
"expired": "La fenêtre d'annulation a expiré. L'élément a été définitivement supprimé.",
"failed": "Échec de l'annulation : {error}",
"restored": "Élément restauré"
},
"virtual": {
"loadFailed": "Échec du chargement des éléments",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "Échec du renommage du fichier : {error}",
"previewUpdated": "Aperçu mis à jour avec succès",
"previewUploadFailed": "Échec du téléchargement de l'image d'aperçu",
"previewDropInvalid": "Type de fichier non pris en charge : {name}. Déposez plutôt une image ou une vidéo MP4.",
"refreshComplete": "{action} terminé",
"refreshFailed": "Échec de {action} des {type}s",
"metadataRefreshed": "Métadonnées actualisées avec succès",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "דלג על גרסאות מודלים שהורדו בעבר",
"help": "כאשר מופעל, LoRA Manager ידלג על הורדת גרסת מודל אם שירות היסטוריית ההורדות רושם את הגרסה המדויקת הזו ככבר שהורדה. חל על כל תהליכי ההורדה."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "קיבוץ לפי דגם",
"groupByModelHelp": "כאשר מופעל, רק הגרסה העדכנית ביותר של כל דגם Civitai מוצגת ככרטיס בודד. גרסאות ישנות יותר מוסתרות.",
@@ -623,6 +622,10 @@
"label": "הסתר עדכוני גישה מוקדמת",
"help": "רק עדכוני גישה מוקדמת"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
@@ -921,7 +924,9 @@
"dateAsc": "הכי ישן",
"lorasCount": "מספר LoRAs",
"lorasCountDesc": "הכי הרבה",
"lorasCountAsc": "הכי פחות"
"lorasCountAsc": "הכי פחות",
"opened": "נפתחו לאחרונה",
"openedDesc": "נפתחו לאחרונה"
},
"refresh": {
"title": "רענן רשימת מתכונים",
@@ -932,6 +937,11 @@
"favorites": {
"title": "הצג מועדפים בלבד",
"action": "מועדפים"
},
"layout": {
"title": "פריסת מתכונים",
"grid": "פריסת רשת",
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "מפנה {size}",
"title": "מחק מודל",
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה."
},
"deleteRecipe": {
"recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות."
},
"excludeModel": {
"title": "החרג מודל",
@@ -1547,6 +1559,8 @@
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
"earlyAccess": "גישה מוקדמת",
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "התעלם",
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
"onSiteOnly": "רק באתר",
@@ -1556,6 +1570,7 @@
"download": "הורדה",
"downloadTooltip": "הורד את הגרסה הזו",
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
"delete": "מחיקה",
"deleteTooltip": "מחק את הגרסה המקומית הזו",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "מתכון הוחלף ב-workflow",
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "לא נבחר צומת יעד",
"modelUpdated": "מודל עודכן ב-workflow",
"modelFailed": "עדכון צומת המודל נכשל",
@@ -2118,12 +2134,12 @@
"copyFailed": "ההעתקה נכשלה"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "בטל",
"deleted": "נמחק: {name}",
"deletedBulk": "{count} פריטים נמחקו",
"expired": "חלון הביטול פג. הפריט נמחק לצמיתות.",
"failed": "הביטול נכשל: {error}",
"restored": "הפריט שוחזר"
},
"virtual": {
"loadFailed": "טעינת הפריטים נכשלה",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
"previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה",
"previewDropInvalid": "סוג קובץ לא נתמך: {name}. גרור במקום זאת תמונה או סרטון MP4.",
"refreshComplete": "{action} הושלם",
"refreshFailed": "{action} של {type}s נכשל",
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "以前にダウンロードしたモデルバージョンをスキップ",
"help": "有効にすると、ダウンロード履歴サービスがそのバージョンが既にダウンロード済みと記録している場合、LoRA Managerはそのモデルバージョンのダウンロードをスキップします。すべてのダウンロードフローに適用されます。"
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "モデルでグループ化",
"groupByModelHelp": "有効にすると、各Civitaiモデルの最新バージョンのみが1枚のカードとして表示され、古いバージョンは非表示になります。",
@@ -623,6 +622,10 @@
"label": "早期アクセス更新を非表示",
"help": "早期アクセスのみの更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "更新されたライセンスアイコンを使用",
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
@@ -921,7 +924,9 @@
"dateAsc": "古い順",
"lorasCount": "LoRA数",
"lorasCountDesc": "多い順",
"lorasCountAsc": "少ない順"
"lorasCountAsc": "少ない順",
"opened": "最近開いた",
"openedDesc": "最近開いた"
},
"refresh": {
"title": "レシピリストを更新",
@@ -932,6 +937,11 @@
"favorites": {
"title": "お気に入りのみ表示",
"action": "お気に入り"
},
"layout": {
"title": "レシピのレイアウト",
"grid": "グリッドレイアウト",
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "{size} を解放します",
"title": "モデルを削除",
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。"
},
"deleteRecipe": {
"recoverableWarning": "この操作は20秒以内であれば元に戻せます。"
},
"excludeModel": {
"title": "モデルを除外",
@@ -1547,6 +1559,8 @@
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
"earlyAccess": "早期アクセス",
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "無視中",
"ignoredTooltip": "このバージョンの更新通知は無効です",
"onSiteOnly": "サイト内のみ",
@@ -1556,6 +1570,7 @@
"download": "ダウンロード",
"downloadTooltip": "このバージョンをダウンロード",
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
"delete": "削除",
"deleteTooltip": "このローカルバージョンを削除",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "レシピがワークフローで置換されました",
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "ターゲットノードが選択されていません",
"modelUpdated": "モデルがワークフローで更新されました",
"modelFailed": "モデルノードの更新に失敗しました",
@@ -2118,12 +2134,12 @@
"copyFailed": "コピーに失敗しました"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "元に戻す",
"deleted": "{name} を削除しました",
"deletedBulk": "{count} 個のアイテムを削除しました",
"expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。",
"failed": "元に戻せませんでした: {error}",
"restored": "アイテムを復元しました"
},
"virtual": {
"loadFailed": "アイテムの読み込みに失敗しました",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
"previewUpdated": "プレビューが正常に更新されました",
"previewUploadFailed": "プレビュー画像のアップロードに失敗しました",
"previewDropInvalid": "サポートされていないファイル形式:{name}。画像またはMP4ビデオをドロップしてください。",
"refreshComplete": "{action} 完了",
"refreshFailed": "{type}の{action}に失敗しました",
"metadataRefreshed": "メタデータが正常に更新されました",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "이전에 다운로드한 모델 버전 건너뛰기",
"help": "활성화하면 다운로드 기록 서비스가 해당 버전이 이미 다운로드되었음을 기록한 경우 LoRA Manager는 해당 모델 버전 다운로드를 건너뜁니다. 모든 다운로드 플로우에 적용됩니다."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "모델별 그룹화",
"groupByModelHelp": "활성화하면 각 Civitai 모델의 최신 버전만 단일 카드로 표시되며, 이전 버전은 숨겨집니다.",
@@ -623,6 +622,10 @@
"label": "얼리 액세스 업데이트 숨기기",
"help": "얼리 액세스 업데이트만"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
@@ -921,7 +924,9 @@
"dateAsc": "오래된순",
"lorasCount": "LoRA 수",
"lorasCountDesc": "많은순",
"lorasCountAsc": "적은순"
"lorasCountAsc": "적은순",
"opened": "최근에 연",
"openedDesc": "최근에 연"
},
"refresh": {
"title": "레시피 목록 새로고침",
@@ -932,6 +937,11 @@
"favorites": {
"title": "즐겨찾기만 표시",
"action": "즐겨찾기"
},
"layout": {
"title": "레시피 레이아웃",
"grid": "그리드 레이아웃",
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "{size} 확보",
"title": "모델 삭제",
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다."
},
"deleteRecipe": {
"recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다."
},
"excludeModel": {
"title": "모델 제외",
@@ -1547,6 +1559,8 @@
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
"earlyAccess": "얼리 액세스",
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "무시됨",
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
"onSiteOnly": "사이트 내 전용",
@@ -1556,6 +1570,7 @@
"download": "다운로드",
"downloadTooltip": "이 버전 다운로드",
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
"delete": "삭제",
"deleteTooltip": "이 로컬 버전 삭제",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
"modelFailed": "모델 노드 업데이트 실패",
@@ -2118,12 +2134,12 @@
"copyFailed": "복사 실패"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "실행 취소",
"deleted": "{name} 삭제됨",
"deletedBulk": "{count}개 항목 삭제됨",
"expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.",
"failed": "실행 취소 실패: {error}",
"restored": "항목이 복원되었습니다"
},
"virtual": {
"loadFailed": "항목 로딩 실패",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "파일 이름 변경 실패: {error}",
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
"previewUploadFailed": "미리보기 이미지 업로드 실패",
"previewDropInvalid": "지원되지 않는 파일 형식: {name}. 이미지 또는 MP4 동영상을 드롭하세요.",
"refreshComplete": "{action} 완료",
"refreshFailed": "{type} {action} 실패",
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "Пропускать ранее загруженные версии моделей",
"help": "Если включено, LoRA Manager будет пропускать загрузку версии модели, если сервис истории загрузок записал, что эта конкретная версия уже загружена. Применяется ко всем потокам загрузки."
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "Группировать по модели",
"groupByModelHelp": "При включении отображается только последняя версия каждой модели Civitai в виде одной карточки. Старые версии скрыты.",
@@ -623,6 +622,10 @@
"label": "Скрыть обновления раннего доступа",
"help": "Только обновления раннего доступа"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "Использовать обновлённые значки лицензии",
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
@@ -921,7 +924,9 @@
"dateAsc": "Сначала старые",
"lorasCount": "Кол-во LoRA",
"lorasCountDesc": "Больше всего",
"lorasCountAsc": "Меньше всего"
"lorasCountAsc": "Меньше всего",
"opened": "Недавно открытые",
"openedDesc": "Недавно открытые"
},
"refresh": {
"title": "Обновить список рецептов",
@@ -932,6 +937,11 @@
"favorites": {
"title": "Только избранные",
"action": "Избранное"
},
"layout": {
"title": "Макет рецептов",
"grid": "Макет сеткой",
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "Освобождает {size}",
"title": "Удалить модель",
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие."
},
"deleteRecipe": {
"recoverableWarning": "Это действие можно отменить в течение 20 секунд."
},
"excludeModel": {
"title": "Исключить модель",
@@ -1547,6 +1559,8 @@
"newerTooltip": "Эта версия новее вашей последней локальной версии",
"earlyAccess": "Ранний доступ",
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "Игнорируется",
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
"onSiteOnly": "Только на Сайте",
@@ -1556,6 +1570,7 @@
"download": "Скачать",
"downloadTooltip": "Скачать эту версию",
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
"delete": "Удалить",
"deleteTooltip": "Удалить эту локальную версию",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "Рецепт заменён в workflow",
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
"noTargetNodeSelected": "Целевой узел не выбран",
"modelUpdated": "Модель обновлена в workflow",
"modelFailed": "Не удалось обновить узел модели",
@@ -2118,12 +2134,12 @@
"copyFailed": "Копирование не удалось"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "Отменить",
"deleted": "Удалено: {name}",
"deletedBulk": "Удалено: {count} шт.",
"expired": "Время отмены истекло. Элемент был удалён навсегда.",
"failed": "Не удалось отменить: {error}",
"restored": "Элемент восстановлен"
},
"virtual": {
"loadFailed": "Не удалось загрузить элементы",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "Не удалось переименовать файл: {error}",
"previewUpdated": "Превью успешно обновлено",
"previewUploadFailed": "Не удалось загрузить превью изображение",
"previewDropInvalid": "Неподдерживаемый тип файла: {name}. Перетащите вместо этого изображение или видео MP4.",
"refreshComplete": "{action} завершено",
"refreshFailed": "Не удалось {action} {type}s",
"metadataRefreshed": "Метаданные успешно обновлены",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "跳过已下载的模型版本",
"help": "启用后,如果下载历史服务记录显示该版本已下载,LoRA Manager 将跳过下载该模型版本。适用于所有下载流程。"
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "按模型分组",
"groupByModelHelp": "开启后,每个 Civitai 模型仅显示最新版本的单张卡片,旧版本将被隐藏。",
@@ -623,6 +622,10 @@
"label": "隐藏抢先体验更新",
"help": "抢先体验更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "使用新版许可协议图标",
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
@@ -921,7 +924,9 @@
"dateAsc": "最早",
"lorasCount": "LoRA 数量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
"lorasCountAsc": "最少",
"opened": "最近打开",
"openedDesc": "最近打开"
},
"refresh": {
"title": "刷新配方列表",
@@ -932,6 +937,11 @@
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
},
"layout": {
"title": "配方布局",
"grid": "网格布局",
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "释放 {size}",
"title": "删除模型",
"message": "你确定要删除此模型及所有相关文件吗?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 20 秒内撤销。"
},
"excludeModel": {
"title": "排除模型",
@@ -1547,6 +1559,8 @@
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "抢先体验",
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略",
"ignoredTooltip": "此版本已关闭更新通知",
"onSiteOnly": "仅站内生成",
@@ -1556,6 +1570,7 @@
"download": "下载",
"downloadTooltip": "下载此版本",
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
"delete": "删除",
"deleteTooltip": "删除此本地版本",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "配方已替换到工作流",
"recipeFailedToSend": "发送配方到工作流失败",
"noMatchingNodes": "当前工作流中没有兼容的节点",
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未选择目标节点",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型节点失败",
@@ -2118,12 +2134,12 @@
"copyFailed": "复制失败"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "撤销",
"deleted": "已删除 {name}",
"deletedBulk": "已删除 {count} 个项目",
"expired": "撤销窗口已过期,项目已被永久删除。",
"failed": "撤销失败:{error}",
"restored": "项目已恢复"
},
"virtual": {
"loadFailed": "加载项目失败",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "重命名文件失败:{error}",
"previewUpdated": "预览图片更新成功",
"previewUploadFailed": "上传预览图片失败",
"previewDropInvalid": "不支持的文件类型:{name}。请拖入图片或 MP4 视频。",
"refreshComplete": "{action} 完成",
"refreshFailed": "{action} {type} 失败",
"metadataRefreshed": "元数据刷新成功",
+28 -11
View File
@@ -443,7 +443,6 @@
"label": "跳過已下載的模型版本",
"help": "啟用後,如果下載歷史服務記錄顯示該版本已下載,LoRA Manager 將跳過下載該模型版本。適用於所有下載流程。"
},
"deleteUndoEnabled": "[TODO: Translate] Keep deleted items recoverable for 30 seconds (undo)",
"layoutSettings": {
"groupByModel": "按模型分組",
"groupByModelHelp": "啟用後,每個 Civitai 模型僅顯示最新版本的單張卡片,舊版本將被隱藏。",
@@ -623,6 +622,10 @@
"label": "隱藏搶先體驗更新",
"help": "搶先體驗更新"
},
"hidePaidUpdates": {
"label": "[TODO: Translate] Hide Paid Updates",
"help": "[TODO: Translate] When enabled, models with only paid updates will not show 'Update available' badge"
},
"licenseIcons": {
"useNewStyle": "使用新版許可協議圖標",
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
@@ -921,7 +924,9 @@
"dateAsc": "最舊",
"lorasCount": "LoRA 數量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
"lorasCountAsc": "最少",
"opened": "最近開啟",
"openedDesc": "最近開啟"
},
"refresh": {
"title": "重新整理配方列表",
@@ -932,6 +937,11 @@
"favorites": {
"title": "僅顯示收藏",
"action": "收藏"
},
"layout": {
"title": "配方版面",
"grid": "網格版面",
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
}
},
"duplicates": {
@@ -1278,11 +1288,13 @@
}
},
"deleteModel": {
"freesSpace": "[TODO: Translate] Frees {size}",
"freesSpace": "釋放 {size}",
"title": "刪除模型",
"message": "您確定要刪除此模型及所有相關檔案嗎?",
"permanentWarning": "[TODO: Translate] This will permanently delete the file from disk.",
"recoverableWarning": "[TODO: Translate] This will permanently delete the file after 30 seconds unless you undo."
"recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。"
},
"deleteRecipe": {
"recoverableWarning": "此操作可在 20 秒內復原。"
},
"excludeModel": {
"title": "排除模型",
@@ -1547,6 +1559,8 @@
"newerTooltip": "此版本比你本地的最新版本更新",
"earlyAccess": "搶先體驗",
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
"paid": "[TODO: Translate] Paid",
"paidTooltip": "[TODO: Translate] This version requires payment to download",
"ignored": "已忽略",
"ignoredTooltip": "此版本已關閉更新通知",
"onSiteOnly": "僅站內生成",
@@ -1556,6 +1570,7 @@
"download": "下載",
"downloadTooltip": "下載此版本",
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
"delete": "刪除",
"deleteTooltip": "刪除此本地版本",
@@ -1725,6 +1740,7 @@
"recipeReplaced": "配方已取代於工作流",
"recipeFailedToSend": "傳送配方到工作流失敗",
"noMatchingNodes": "目前工作流程中沒有相容的節點",
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
"noTargetNodeSelected": "未選擇目標節點",
"modelUpdated": "模型已更新到工作流",
"modelFailed": "更新模型節點失敗",
@@ -2118,12 +2134,12 @@
"copyFailed": "複製失敗"
},
"undo": {
"action": "[TODO: Translate] Undo",
"deleted": "[TODO: Translate] Deleted {name}",
"deletedBulk": "[TODO: Translate] Deleted {count} item(s)",
"expired": "[TODO: Translate] Undo window expired. The item was permanently deleted.",
"failed": "[TODO: Translate] Undo failed: {error}",
"restored": "[TODO: Translate] Item restored"
"action": "復原",
"deleted": "已刪除 {name}",
"deletedBulk": "已刪除 {count} 個項目",
"expired": "復原視窗已過期,項目已被永久刪除。",
"failed": "復原失敗:{error}",
"restored": "項目已還原"
},
"virtual": {
"loadFailed": "載入項目失敗",
@@ -2188,6 +2204,7 @@
"fileRenameFailed": "重新命名檔案失敗:{error}",
"previewUpdated": "預覽圖片已成功更新",
"previewUploadFailed": "上傳預覽圖片失敗",
"previewDropInvalid": "不支援的檔案類型:{name}。請拖入圖片或 MP4 影片。",
"refreshComplete": "{action} 完成",
"refreshFailed": "{action} {type} 失敗",
"metadataRefreshed": "metadata 已成功刷新",
+6 -3
View File
@@ -251,11 +251,14 @@ class LoraManager:
# Startup sweep: purge pending-delete batches that expired during a
# previous run. Non-blocking (fire-and-forget); purge_expired only
# removes already-expired batches, so a staged undo that survived a
# restart stays restorable. Covers both plugin and standalone modes
# (StandaloneLoraManager reuses this classmethod).
# restart stays restorable. scan_roots=True runs the reconciliation
# pass first so leftover batches (the in-process registry is empty
# after a restart) are re-discovered on disk. Covers both plugin
# and standalone modes (StandaloneLoraManager reuses this
# classmethod).
pending_delete_service = await get_pending_delete_service()
asyncio.create_task(
pending_delete_service.purge_expired(),
pending_delete_service.purge_expired(scan_roots=True),
name="pending_delete_startup_sweep",
)
@@ -214,6 +214,24 @@ class MetadataProcessor:
max_denoise = denoise
primary_sampler = sampler_info
primary_sampler_id = node_id
# Last resort: any registered sampler. Samplers without a denoise or
# add_noise parameter (e.g. multi-stage samplers like KreaTwoStageSampler)
# are not caught by the criteria above. Prefer execution order so the
# first executed sampler wins, matching the downstream_id branch.
if primary_sampler is None:
sampler_ids = [
node_id
for node_id, sampler_info in metadata.get(SAMPLING, {}).items()
if sampler_info.get(IS_SAMPLER, False)
]
if sampler_ids:
if downstream_id and "execution_order" in metadata:
for node_id in metadata["execution_order"]:
if node_id in sampler_ids:
return node_id, metadata[SAMPLING][node_id]
primary_sampler_id = sampler_ids[0]
primary_sampler = metadata[SAMPLING][sampler_ids[0]]
return primary_sampler_id, primary_sampler
+160 -20
View File
@@ -40,7 +40,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
are checked for a model file name and stored as checkpoint metadata.
* ``CONDITIONING`` output: common text input fields are checked for
prompt text and stored as prompt metadata.
prompt text, and conditioning inputs are tracked through transforms.
"""
# Input field names that carry a model path in loader-style nodes.
@@ -73,7 +73,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
_store_checkpoint_metadata(metadata, node_id, name)
return
# — CONDITIONING encoder detection (CLIPTextEncode, Flux, custom)
# — CONDITIONING encoder / transform detection
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
text = None
for field in GenericNodeExtractor._TEXT_FIELDS:
@@ -81,12 +81,14 @@ class GenericNodeExtractor(NodeMetadataExtractor):
if val and isinstance(val, str) and val.strip():
text = val.strip()
break
if text:
prompt_data = metadata.setdefault(PROMPTS, {})
prompt_data[node_id] = {
"text": text,
"node_id": node_id,
}
input_conditionings = _collect_conditioning_inputs(inputs)
if text or input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
if text:
prompt_metadata["text"] = text
if input_conditionings:
prompt_metadata["orig_conditionings"] = input_conditionings
@staticmethod
def update(node_id, outputs, metadata, return_types=None):
@@ -98,11 +100,26 @@ class GenericNodeExtractor(NodeMetadataExtractor):
return
if node_id not in metadata.get(PROMPTS, {}):
return
if outputs and isinstance(outputs, list) and len(outputs) > 0:
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
cond = outputs[0][0]
if cond is not None:
metadata[PROMPTS][node_id]["conditioning"] = cond
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 1:
return
conditioning_index = _first_conditioning_index(return_types)
if conditioning_index is None or len(output_tuple) <= conditioning_index:
return
output_conditioning = output_tuple[conditioning_index]
if output_conditioning is None:
return
prompt_metadata = metadata[PROMPTS][node_id]
prompt_metadata["conditioning"] = output_conditioning
_record_conditioning_source(
metadata,
node_id,
output_conditioning,
prompt_metadata.get("orig_conditionings", []),
)
class CheckpointLoaderExtractor(NodeMetadataExtractor):
@staticmethod
@@ -417,6 +434,34 @@ def _first_output_tuple(outputs):
return None
def _first_conditioning_index(return_types):
"""Return the index of the first CONDITIONING output slot, or None."""
if not return_types:
return None
for index, return_type in enumerate(return_types):
if "CONDITIONING" in str(return_type):
return index
return None
def _collect_conditioning_inputs(inputs):
"""Collect conditioning object inputs (``conditioning*`` keys).
Primitive values (None, str, int, float, bool) are excluded so scalar
fields like ``conditioning_strength`` are not mistaken for conditioning
objects during provenance tracking.
"""
if not inputs:
return []
return [
value
for input_name, value in inputs.items()
if input_name.startswith("conditioning")
and value is not None
and not isinstance(value, (str, int, float, bool))
]
def _record_conditioning_source(
metadata, node_id, output_conditioning, input_conditionings
):
@@ -429,6 +474,14 @@ def _record_conditioning_source(
if not sources:
return
# Identity-preserving selectors return one of their inputs unchanged:
# only that input contributed to the output, so record it alone instead
# of treating every input as a combination source.
for conditioning in sources:
if id(conditioning) == id(output_conditioning):
sources = [conditioning]
break
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
prompt_metadata.setdefault("conditioning_sources", []).append(
{
@@ -508,13 +561,7 @@ class ConditioningCombineExtractor(NodeMetadataExtractor):
if not inputs:
return
input_conditionings = []
for input_name in inputs:
if (
input_name.startswith("conditioning")
and inputs[input_name] is not None
):
input_conditionings.append(inputs[input_name])
input_conditionings = _collect_conditioning_inputs(inputs)
if input_conditionings:
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
@@ -814,6 +861,65 @@ class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtr
# Update method is inherited from TSCSamplerBaseExtractor
class KreaTwoStageSamplerExtractor(BaseSamplerExtractor):
"""Extractor for Krea Two/Three Stage Samplers (Auryg/Krea-2-Two-Stage-Sampler).
The node samples in two (or three) stages with per-stage settings
(stage1_steps/stage2_steps, stage1_cfg/stage2_cfg, ...). The canonical
metadata fields consumed by ``extract_generation_params`` (steps, cfg,
sampler_name, scheduler) are derived from the base stage (stage 1; the
three-stage variant reuses stage 1 settings for stage 3), while the full
per-stage breakdown is preserved in the raw parameters.
"""
# All per-stage parameter keys present on both node variants.
_STAGE_PARAM_KEYS = (
"stage1_steps", "stage1_cfg", "stage1_sampler_name", "stage1_scheduler",
"stage2_steps", "stage2_cfg", "stage2_sampler_name", "stage2_scheduler",
)
@staticmethod
def extract(node_id, inputs, outputs, metadata):
if not inputs:
return
BaseSamplerExtractor.extract_sampling_params(
node_id,
inputs,
metadata,
("seed", "handoff_percent", "stage3_handoff_percent")
+ KreaTwoStageSamplerExtractor._STAGE_PARAM_KEYS,
)
# Derive the canonical fields expected by extract_generation_params.
sampling_params = metadata[SAMPLING][node_id]["parameters"]
if "stage1_steps" in sampling_params or "stage2_steps" in sampling_params:
sampling_params["steps"] = (
(sampling_params.get("stage1_steps") or 0)
+ (sampling_params.get("stage2_steps") or 0)
)
if "stage1_cfg" in sampling_params:
sampling_params["cfg"] = sampling_params["stage1_cfg"]
if "stage1_sampler_name" in sampling_params:
sampling_params["sampler_name"] = sampling_params["stage1_sampler_name"]
if "stage1_scheduler" in sampling_params:
sampling_params["scheduler"] = sampling_params["stage1_scheduler"]
BaseSamplerExtractor.extract_conditioning(node_id, inputs, metadata)
# Prefer the final generation resolution; latent dims are the fallback.
BaseSamplerExtractor.extract_latent_dimensions(node_id, inputs, metadata)
final_width = inputs.get("final_width")
final_height = inputs.get("final_height")
if final_width and final_height:
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": final_width,
"height": final_height,
"node_id": node_id,
}
class LoraLoaderExtractor(NodeMetadataExtractor):
@staticmethod
def extract(node_id, inputs, outputs, metadata):
@@ -854,6 +960,37 @@ class ImageSizeExtractor(NodeMetadataExtractor):
"node_id": node_id
}
class KreaDualResolutionSelectorExtractor(NodeMetadataExtractor):
"""Extract base resolution from Krea Dual Resolution Selector outputs
(Auryg/Krea-2-Two-Stage-Sampler).
The node computes base/final dimensions at runtime from aspect ratio and
megapixel settings, so the values are only available in the update phase
(outputs: base_width, base_height, final_width, final_height, seed).
"""
@staticmethod
def extract(node_id, inputs, outputs, metadata):
# Dimensions are computed at runtime; nothing to do here.
pass
@staticmethod
def update(node_id, outputs, metadata):
output_tuple = _first_output_tuple(outputs)
if not output_tuple or len(output_tuple) < 2:
return
width, height = output_tuple[0], output_tuple[1]
if not isinstance(width, int) or not isinstance(height, int):
return
if SIZE not in metadata:
metadata[SIZE] = {}
metadata[SIZE][node_id] = {
"width": width,
"height": height,
"node_id": node_id,
}
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
"""Extract LoRA metadata from rgthree Power Lora Loader.
@@ -1255,6 +1392,8 @@ NODE_EXTRACTORS = {
"ClownsharKSampler_Beta": SamplerExtractor,
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # Efficient Nodes
"KreaTwoStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
"KreaThreeStageSampler": KreaTwoStageSamplerExtractor, # Auryg/Krea-2-Two-Stage-Sampler
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
@@ -1306,6 +1445,7 @@ NODE_EXTRACTORS = {
"GetNode": GetNodeExtractor,
# Latent
"EmptyLatentImage": ImageSizeExtractor,
"KreaDualResolutionSelector": KreaDualResolutionSelectorExtractor, # Auryg/Krea-2-Two-Stage-Sampler
# Flux
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
+11 -2
View File
@@ -8,7 +8,7 @@ cannot drift between the two paths.
import logging
from typing import Any, Dict
from ..utils.utils import model_patcher_to_name
from ..utils.utils import model_patcher_to_name, sampler_object_to_name
from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS
logger = logging.getLogger(__name__)
@@ -22,7 +22,9 @@ def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
of 0 is preserved. The ``model`` field accepts either a manual string or
a wired MODEL (ModelPatcher) connection; in the latter case the source
model name is extracted from the patcher's ``cached_patcher_init`` and
stored as a ComfyUI-style relative path.
stored as a ComfyUI-style relative path. The ``sampler`` field likewise
accepts a manual string or a wired SAMPLER (KSAMPLER) connection, from
which the sampler name is extracted via the sampler function's name.
"""
result: Dict[str, Any] = {}
for key in METADATA_OVERWRITE_FIELDS:
@@ -34,6 +36,13 @@ def collect_overwrite_params(values: Dict[str, Any]) -> Dict[str, Any]:
"Could not extract model name from wired MODEL input "
"(no cached_patcher_init); model metadata overwrite skipped"
)
elif key == "sampler" and not isinstance(value, str):
value = sampler_object_to_name(value)
if value is None:
logger.warning(
"Could not extract sampler name from wired SAMPLER input "
"(unrecognized sampler function); sampler metadata overwrite skipped"
)
if key == "clip_skip":
if value != CLIP_SKIP_SENTINEL:
result[key] = value
+13 -3
View File
@@ -71,10 +71,18 @@ class MetadataOverwriteLM:
},
),
"sampler": (
"STRING",
"STRING,SAMPLER",
{
"default": "",
"tooltip": "Sampler name. Only overwrites when non-empty.",
"widgetType": "STRING",
"tooltip": (
"Sampler name. Fill in the name manually or "
"connect a SAMPLER output (e.g. KSamplerSelect) "
"— the sampler name is then extracted "
"automatically. Note: ddim is recorded as "
"euler (ComfyUI internal representation). "
"Only overwrites when non-empty."
),
},
),
"scheduler": (
@@ -164,6 +172,8 @@ class MetadataOverwriteLM:
The ``model`` field accepts either a manual string or a wired MODEL
(ModelPatcher) connection; in the latter case the underlying model
name is extracted from the patcher's ``cached_patcher_init`` and
stored as a ComfyUI-style relative path.
stored as a ComfyUI-style relative path. The ``sampler`` field
likewise accepts a manual string or a wired SAMPLER (KSAMPLER)
connection, from which the sampler name is extracted automatically.
"""
return (collect_overwrite_params(kwargs),)
+214
View File
@@ -0,0 +1,214 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
import folder_paths # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
class RandomCheckpointLoaderLM:
"""Checkpoint Loader that can randomly pick a checkpoint from the pool
Loads checkpoints from both standard ComfyUI folders and LoRA Manager's
extra folder paths. When select_at_random is enabled, ignores ckpt_name
and picks a random checkpoint (optionally filtered by base_model) on
every run.
"""
NAME = "Random Checkpoint Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of checkpoint names from scanner (includes extra folder paths)
checkpoint_names = cls._get_checkpoint_names()
base_models = cls._get_available_base_models()
return {
"required": {
"ckpt_name": (
checkpoint_names,
{"tooltip": "The name of the checkpoint (model) to load."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore ckpt_name and pick a random checkpoint from the "
"pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "CLIP", "VAE", "STRING")
RETURN_NAMES = ("MODEL", "CLIP", "VAE", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The CLIP model used for encoding text prompts.",
"The VAE model used for encoding and decoding images to and from latent space.",
"The name of the checkpoint that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_checkpoint"
@classmethod
def IS_CHANGED(cls, ckpt_name, select_at_random=False, base_model="Any"):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return ckpt_name
@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())
@classmethod
def _get_checkpoint_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of checkpoint names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include checkpoints matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only checkpoint type (not diffusion_model) and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "checkpoint":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing checkpoints at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting checkpoint names: {e}")
return []
@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"]
def load_checkpoint(
self,
ckpt_name: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, Any, Any, str]:
"""Load a checkpoint by name, supporting extra folder paths
Args:
ckpt_name: The name of the checkpoint to load (relative path with extension)
select_at_random: If True, ignore ckpt_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, CLIP, VAE, model_name)
"""
if select_at_random:
pool = self._get_checkpoint_names(base_model)
if not pool:
raise FileNotFoundError(
f"No checkpoints found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
ckpt_name = random.choice(pool)
logger.info(
f"[RandomCheckpointLoaderLM] Randomly selected checkpoint: {ckpt_name}"
)
# Get absolute path from cache using ComfyUI-style name
ckpt_path, metadata = get_checkpoint_info_absolute(ckpt_name)
if metadata is None:
raise FileNotFoundError(
f"Checkpoint '{ckpt_name}' not found in LoRA Manager cache. "
"Make sure the checkpoint is indexed and try again."
)
# Load regular checkpoint using ComfyUI's API
logger.info(f"Loading checkpoint from: {ckpt_path}")
out = comfy.sd.load_checkpoint_guess_config(
ckpt_path,
output_vae=True,
output_clip=True,
embedding_directory=folder_paths.get_folder_paths("embeddings"),
)
return out[:3] + (ckpt_name,)
+326
View File
@@ -0,0 +1,326 @@
import logging
import os
import random
from typing import Any, List, Optional, Tuple
import comfy.sd # pyright: ignore[reportMissingImports]
from ..utils.utils import get_checkpoint_info_absolute, _format_model_name_for_comfyui
logger = logging.getLogger(__name__)
def _reload_gguf_unet(
unet_path: str, weight_dtype: str, disable_dynamic: bool = False
) -> object:
"""Reload a GGUF diffusion model from disk (cached_patcher_init factory).
Mirrors the GGUF branch of RandomUNETLoaderLM.load_unet so ModelPatcher
deepclone/dynamic machinery can rebuild GGUF models with the correct
GGMLOps. ``disable_dynamic`` is accepted for signature compatibility
with core ComfyUI loaders.
"""
loader = RandomUNETLoaderLM()
model, _unet_name = loader._load_gguf_unet(unet_path, unet_path, weight_dtype)
return model
class RandomUNETLoaderLM:
"""UNET Loader that can randomly pick a diffusion model from the pool
Loads diffusion models/UNets from both standard ComfyUI folders and LoRA
Manager's extra folder paths. Supports both regular diffusion models and
GGUF format models. When select_at_random is enabled, ignores unet_name
and picks a random diffusion model (optionally filtered by base_model)
on every run.
"""
NAME = "Random Unet Loader (LoraManager)"
CATEGORY = "Lora Manager/loaders"
@classmethod
def INPUT_TYPES(cls):
# Get list of unet names from scanner (includes extra folder paths)
unet_names = cls._get_unet_names()
base_models = cls._get_available_base_models()
return {
"required": {
"unet_name": (
unet_names,
{"tooltip": "The name of the diffusion model to load."},
),
"weight_dtype": (
["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"],
{"tooltip": "The dtype to use for the model weights."},
),
"select_at_random": (
"BOOLEAN",
{
"default": False,
"tooltip": (
"Ignore unet_name and pick a random diffusion model from "
"the pool (optionally filtered by base_model) on every run."
),
},
),
"base_model": (
base_models,
{
"default": "Any",
"tooltip": "Restrict random selection to this base model. 'Any' uses the full pool.",
},
),
}
}
RETURN_TYPES = ("MODEL", "STRING")
RETURN_NAMES = ("MODEL", "model_name")
OUTPUT_TOOLTIPS = (
"The model used for denoising latents.",
"The name of the diffusion model that was loaded (useful when select_at_random is enabled).",
)
FUNCTION = "load_unet"
@classmethod
def IS_CHANGED(
cls, unet_name, weight_dtype, select_at_random=False, base_model="Any"
):
# Force re-execution on every run while randomizing, since the widget
# values themselves don't change between queue runs.
if select_at_random:
return float("nan")
return unet_name
@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())
@classmethod
def _get_unet_names(cls, base_model: Optional[str] = None) -> List[str]:
"""Get list of diffusion model names from scanner cache in ComfyUI format (relative path with extension)
Args:
base_model: If given (and not "Any"), only include models matching this base model.
"""
try:
from ..services.service_registry import ServiceRegistry
async def _get_names():
scanner = await ServiceRegistry.get_checkpoint_scanner()
cache = await scanner.get_cached_data()
# Get all model roots for calculating relative paths
model_roots = scanner.get_model_roots()
# Filter only diffusion_model type and format names
names = []
for item in cache.raw_data:
if item.get("sub_type") != "diffusion_model":
continue
if (
base_model
and base_model != "Any"
and item.get("base_model") != base_model
):
continue
file_path = item.get("file_path", "")
# Only offer models that still exist on disk so ComfyUI
# flags missing diffusion models at queue time via
# "value not in list" (the scanner cache can be stale).
if file_path and os.path.exists(file_path):
# Format using relative path with OS-native separator
formatted_name = _format_model_name_for_comfyui(
file_path, model_roots
)
if formatted_name:
names.append(formatted_name)
return sorted(names)
return cls._run_async(_get_names)
except Exception as e:
logger.error(f"Error getting unet names: {e}")
return []
@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"]
def load_unet(
self,
unet_name: str,
weight_dtype: str,
select_at_random: bool = False,
base_model: str = "Any",
) -> Tuple[Any, ...]:
"""Load a diffusion model by name, supporting extra folder paths
Args:
unet_name: The name of the diffusion model to load (relative path with extension)
weight_dtype: The dtype to use for model weights
select_at_random: If True, ignore unet_name and pick randomly from the pool
base_model: Restricts random selection to this base model ("Any" = no filter)
Returns:
Tuple of (MODEL, model_name)
"""
import torch
if select_at_random:
pool = self._get_unet_names(base_model)
if not pool:
raise FileNotFoundError(
f"No diffusion models found for base model '{base_model}'. "
"Pick a different base model or disable 'select_at_random'."
)
unet_name = random.choice(pool)
logger.info(
f"[RandomUNETLoaderLM] Randomly selected diffusion model: {unet_name}"
)
# Get absolute path from cache using ComfyUI-style name
unet_path, metadata = get_checkpoint_info_absolute(unet_name)
if metadata is None:
raise FileNotFoundError(
f"Diffusion model '{unet_name}' not found in LoRA Manager cache. "
"Make sure the model is indexed and try again."
)
# Check if it's a GGUF model
if unet_path.endswith(".gguf"):
return self._load_gguf_unet(unet_path, unet_name, weight_dtype)
# Load regular diffusion model using ComfyUI's API
logger.info(f"Loading diffusion model from: {unet_path}")
# Build model options based on weight_dtype
model_options = {}
if weight_dtype == "fp8_e4m3fn":
model_options["dtype"] = torch.float8_e4m3fn
elif weight_dtype == "fp8_e4m3fn_fast":
model_options["dtype"] = torch.float8_e4m3fn
model_options["fp8_optimizations"] = True
elif weight_dtype == "fp8_e5m2":
model_options["dtype"] = torch.float8_e5m2
model = comfy.sd.load_diffusion_model(unet_path, model_options=model_options)
return (model, unet_name)
def _load_gguf_unet(
self, unet_path: str, unet_name: str, weight_dtype: str
) -> Tuple[Any, ...]:
"""Load a GGUF format diffusion model
Args:
unet_path: Absolute path to the GGUF file
unet_name: Name of the model for error messages
weight_dtype: The dtype to use for model weights
Returns:
Tuple of (MODEL, model_name)
"""
import torch
from .gguf_import_helper import get_gguf_modules
# Get ComfyUI-GGUF modules using helper (handles various import scenarios)
try:
loader_module, ops_module, nodes_module = get_gguf_modules()
gguf_sd_loader = getattr(loader_module, "gguf_sd_loader")
GGMLOps = getattr(ops_module, "GGMLOps")
GGUFModelPatcher = getattr(nodes_module, "GGUFModelPatcher")
except RuntimeError as e:
raise RuntimeError(f"Cannot load GGUF model '{unet_name}'. {str(e)}")
logger.info(f"Loading GGUF diffusion model from: {unet_path}")
try:
# Load GGUF state dict
sd, extra = gguf_sd_loader(unet_path)
# Prepare kwargs for metadata if supported
kwargs = {}
import inspect
valid_params = inspect.signature(
comfy.sd.load_diffusion_model_state_dict
).parameters
if "metadata" in valid_params:
kwargs["metadata"] = extra.get("metadata", {})
# Setup custom operations with GGUF support
ops = GGMLOps()
# Handle weight_dtype for GGUF models
if weight_dtype in ("default", None):
ops.Linear.dequant_dtype = None
elif weight_dtype in ["target"]:
ops.Linear.dequant_dtype = weight_dtype
else:
ops.Linear.dequant_dtype = getattr(torch, weight_dtype, None)
# Load the model
model = comfy.sd.load_diffusion_model_state_dict(
sd, model_options={"custom_operations": ops}, **kwargs
)
if model is None:
raise RuntimeError(
f"Could not detect model type for GGUF diffusion model: {unet_path}"
)
# Wrap with GGUFModelPatcher
model = GGUFModelPatcher.clone(model)
# Register a reload factory so the MODEL carries its source path
# (cached_patcher_init) like core ComfyUI loaders do — required
# for model-name extraction downstream and for ModelPatcher
# deepclone/dynamic machinery.
model.cached_patcher_init = (_reload_gguf_unet, (unet_path, weight_dtype))
return (model, unet_name)
except Exception as e:
logger.error(f"Error loading GGUF diffusion model '{unet_name}': {e}")
raise RuntimeError(
f"Failed to load GGUF diffusion model '{unet_name}': {str(e)}"
)
+14 -4
View File
@@ -11,7 +11,7 @@ import re
from typing import Dict, List, Any, Optional, Tuple
from abc import ABC, abstractmethod
from ..config import config
from ..utils.constants import VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES, VALID_LORA_TYPES, VALID_CHECKPOINT_SUB_TYPES
from ..utils.civitai_utils import rewrite_preview_url
logger = logging.getLogger(__name__)
@@ -155,9 +155,9 @@ class RecipeMetadataParser(ABC):
# Process file information if available
if 'files' in civitai_info:
# Find the primary model file (type="Model" and primary=true) in the files list
# Find the primary model file (weights-type and primary=true) in the files list
model_file = next((file for file in civitai_info.get('files', [])
if file.get('type') == 'Model' and file.get('primary') == True), None)
if file.get('type') in MODEL_WEIGHT_FILE_TYPES and file.get('primary') == True), None)
if model_file:
# Get size
@@ -261,11 +261,21 @@ class RecipeMetadataParser(ABC):
checkpoint['id'] = civitai_data.get('id', 0)
if 'files' in civitai_data:
# Prefer the file CivitAI marked primary; fall back to any
# weights-type file (providers without primary flags).
model_file = next(
(
file
for file in civitai_data.get('files', [])
if file.get('type') == 'Model'
if file.get('type') in MODEL_WEIGHT_FILE_TYPES
and file.get('primary') is True
),
None,
) or next(
(
file
for file in civitai_data.get('files', [])
if file.get('type') in MODEL_WEIGHT_FILE_TYPES
),
None,
)
+2 -1
View File
@@ -30,6 +30,7 @@ from ..services.websocket_progress_callback import (
WebSocketProgressCallback,
)
from ..utils.exif_utils import ExifUtils
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
from ..utils.metadata_manager import MetadataManager
from .model_route_registrar import COMMON_ROUTE_DEFINITIONS, ModelRouteRegistrar
from .handlers.model_handlers import (
@@ -251,7 +252,7 @@ class BaseModelRoutes(ABC):
def _find_model_file(self, files):
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
return next((file for file in files if file.get("type") in ("Model", "Diffusion Model") and file.get("primary") is True), None)
return next((file for file in files if file.get("type") in MODEL_WEIGHT_FILE_TYPES and file.get("primary") is True), None)
def get_handler(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
"""Expose handlers for subclasses or tests."""
+37 -5
View File
@@ -2535,6 +2535,7 @@ class ModelUpdateHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500)
hide_early_access = False
hide_paid = False
if self._settings is not None:
try:
hide_early_access = bool(
@@ -2542,12 +2543,17 @@ class ModelUpdateHandler:
)
except Exception:
pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
serialized_records = []
for record in records.values():
has_update_fn = getattr(record, "has_update", None)
if callable(has_update_fn) and has_update_fn(
hide_early_access=hide_early_access
hide_early_access=hide_early_access,
hide_paid=hide_paid,
):
serialized_records.append(self._serialize_record(record))
@@ -2701,10 +2707,16 @@ class ModelUpdateHandler:
if not record or not record.versions:
return record
# Find versions that need enrichment
# Find versions that need enrichment. Permanent paid versions are not
# early access (mirror _is_early_access_active) and never carry an end
# time, so skip them to avoid pointless per-version API calls.
versions_needing_update = []
for version in record.versions:
if version.is_early_access and not version.early_access_ends_at:
if (
version.is_early_access
and not version.early_access_ends_at
and not getattr(version, "is_paid", False)
):
versions_needing_update.append(version)
if not versions_needing_update:
@@ -2934,6 +2946,7 @@ class ModelUpdateHandler:
context = version_context or {}
# Check user setting for hiding early access versions
hide_early_access = False
hide_paid = False
if self._settings is not None:
try:
hide_early_access = bool(
@@ -2941,6 +2954,10 @@ class ModelUpdateHandler:
)
except Exception:
pass
try:
hide_paid = bool(self._settings.get("hide_paid_updates", False))
except Exception:
pass
return {
"modelType": record.model_type,
"modelId": record.model_id,
@@ -2949,7 +2966,10 @@ class ModelUpdateHandler:
"inLibraryVersionIds": record.in_library_version_ids,
"lastCheckedAt": record.last_checked_at,
"shouldIgnore": record.should_ignore_model,
"hasUpdate": record.has_update(hide_early_access=hide_early_access),
"hasUpdate": record.has_update(
hide_early_access=hide_early_access,
hide_paid=hide_paid,
),
"versions": [
self._serialize_version(version, context.get(version.version_id))
for version in record.versions
@@ -2968,8 +2988,11 @@ class ModelUpdateHandler:
# Determine if version is currently in early access
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag
# Mirror _is_early_access_active: permanent paid versions (no end time) are NOT early access
is_early_access = False
if version.early_access_ends_at:
if getattr(version, "is_paid", False) and not version.early_access_ends_at:
is_early_access = False
elif version.early_access_ends_at:
try:
from datetime import datetime, timezone
@@ -2984,6 +3007,13 @@ class ModelUpdateHandler:
# Fallback to basic EA flag from bulk API
is_early_access = True
paid_access_payload = None
if getattr(version, "paid_access", None):
try:
paid_access_payload = json.loads(version.paid_access)
except (TypeError, ValueError):
paid_access_payload = None
return {
"versionId": version.version_id,
"name": version.name,
@@ -2997,6 +3027,8 @@ class ModelUpdateHandler:
"earlyAccessEndsAt": version.early_access_ends_at,
"isEarlyAccess": is_early_access,
"usageControl": version.usage_control,
"isPaid": bool(getattr(version, "is_paid", False)),
"paidAccess": paid_access_payload,
"filePath": context.get("file_path"),
"fileName": context.get("file_name"),
}
+29
View File
@@ -34,6 +34,7 @@ from ...utils.civitai_utils import (
)
from ...utils.constants import NSFW_LEVELS
from ...utils.exif_utils import ExifUtils
from ...utils.recipe_open_stats import RecipeOpenStats
from ...recipes.merger import GenParamsMerger
from ...recipes.enrichment import RecipeEnricher
from ...services.websocket_manager import ws_manager as default_ws_manager
@@ -98,6 +99,7 @@ class RecipeHandlerSet:
"download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe,
"record_recipe_open": self.management.record_recipe_open,
"reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
@@ -1458,6 +1460,33 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500)
async def record_recipe_open(self, request: web.Request) -> web.Response:
"""Record that a recipe's detail modal was opened.
Lightweight fire-and-forget endpoint backing the "Recently Opened"
sort. It only writes the timestamp into the separate open-stats file
recipe JSON and EXIF are never touched.
"""
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"]
# Skip recording opens for recipes the scanner no longer knows.
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_json_path:
return web.json_response(
{"success": False, "error": "Recipe not found"}, status=404
)
RecipeOpenStats().record_open(recipe_id)
return web.json_response({"success": True})
except Exception as exc:
self._logger.error("Error recording recipe open: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
+3
View File
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition(
"POST", "/api/lm/recipe/{recipe_id}/opened", "record_recipe_open"
),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
+17 -2
View File
@@ -633,6 +633,13 @@ class BaseModelService(ABC):
except Exception:
hide_early_access = False
# Check user setting for hiding permanent paid updates
hide_paid = False
try:
hide_paid = bool(self.settings.get("hide_paid_updates", False))
except Exception:
hide_paid = False
records = None
resolved: Optional[Dict[int, bool]] = None
if same_base_mode:
@@ -641,7 +648,10 @@ class BaseModelService(ABC):
try:
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
resolved = {
model_id: record.has_update(hide_early_access=hide_early_access)
model_id: record.has_update(
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
for model_id, record in records.items()
}
except Exception as exc:
@@ -663,6 +673,7 @@ class BaseModelService(ABC):
self.model_type,
ordered_ids,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
))
except Exception as exc:
logger.error(
@@ -677,7 +688,10 @@ class BaseModelService(ABC):
if resolved is None:
tasks = [
self.update_service.has_update(
self.model_type, model_id, hide_early_access=hide_early_access
self.model_type,
model_id,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
for model_id in ordered_ids
]
@@ -717,6 +731,7 @@ class BaseModelService(ABC):
threshold_version,
base_model,
hide_early_access=hide_early_access,
hide_paid=hide_paid,
)
else:
flag = default_flag
+9 -2
View File
@@ -21,6 +21,7 @@ from .model_metadata_provider import (
from .downloader import get_downloader
from .errors import RateLimitError, ResourceNotFoundError
from ..utils.civitai_utils import resolve_license_payload
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
logger = logging.getLogger(__name__)
@@ -538,10 +539,16 @@ class CivitaiClient:
return model_versions[0]
def _extract_primary_model_hash(self, version_entry: Dict[str, Any]) -> Optional[str]:
# Prefer the generic "Model" file (most reliable version identity);
# fall back to any other weights-type primary.
for file_info in version_entry.get("files", []):
if file_info.get("type") == "Model" and file_info.get("primary"):
hashes = file_info.get("hashes", {})
model_hash = hashes.get("SHA256")
model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
if model_hash:
return model_hash
for file_info in version_entry.get("files", []):
if file_info.get("type") in MODEL_WEIGHT_FILE_TYPES and file_info.get("primary"):
model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
if model_hash:
return model_hash
return None
+1
View File
@@ -83,6 +83,7 @@ class DownloadCoordinator:
save_dir=payload.get("model_root"),
relative_path=payload.get("relative_path", ""),
use_default_paths=payload.get("use_default_paths", False),
use_save_dir_as_root=payload.get("use_save_dir_as_root", False),
progress_callback=progress_callback,
download_id=download_id,
source=payload.get("source"),
+125 -48
View File
@@ -3,6 +3,7 @@
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
# import cycles. Breaking them would require an architectural refactor.
import copy
import json
import logging
import os
import asyncio
@@ -18,6 +19,7 @@ from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
from ..utils.constants import (
CARD_PREVIEW_WIDTH,
DIFFUSION_MODEL_BASE_MODELS,
MODEL_WEIGHT_FILE_TYPES,
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
VALID_LORA_TYPES,
)
@@ -46,6 +48,11 @@ CIVITAI_DOWNLOAD_URL_PREFIXES = (
)
# File types that are never the intended download target even when CivitAI
# marks them primary — configs/archives/workflows are auxiliary artifacts.
NON_DOWNLOADABLE_PRIMARY_TYPES = ("Config", "Archive", "Workflow", "Training Data")
class DownloadManager:
_instance = None
_lock = asyncio.Lock()
@@ -217,6 +224,7 @@ class DownloadManager:
download_id: str | None = None,
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Download model from Civitai with task tracking and concurrency control
@@ -257,6 +265,7 @@ class DownloadManager:
"save_dir": save_dir,
"relative_path": relative_path,
"use_default_paths": bool(use_default_paths),
"use_save_dir_as_root": bool(use_save_dir_as_root),
"source": source,
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
"progress": 0,
@@ -287,6 +296,7 @@ class DownloadManager:
use_default_paths,
source,
file_params,
use_save_dir_as_root,
)
)
@@ -321,6 +331,7 @@ class DownloadManager:
use_default_paths: bool = False,
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
):
"""Execute download with semaphore to limit concurrency"""
# Update status to waiting
@@ -401,6 +412,7 @@ class DownloadManager:
),
source,
file_params,
use_save_dir_as_root=use_save_dir_as_root,
)
# Update status based on result
@@ -621,6 +633,7 @@ class DownloadManager:
"save_dir": info.get("save_dir"),
"relative_path": info.get("relative_path", ""),
"use_default_paths": bool(info.get("use_default_paths", False)),
"use_save_dir_as_root": bool(info.get("use_save_dir_as_root", False)),
"source": info.get("source"),
"file_params": copy.deepcopy(info.get("file_params")),
"transfer_backend": info.get("transfer_backend", "aria2"),
@@ -643,6 +656,7 @@ class DownloadManager:
"save_dir": record.get("save_dir"),
"relative_path": record.get("relative_path", ""),
"use_default_paths": bool(record.get("use_default_paths", False)),
"use_save_dir_as_root": bool(record.get("use_save_dir_as_root", False)),
"source": record.get("source"),
"file_params": copy.deepcopy(record.get("file_params")),
"progress": record.get("progress", 0),
@@ -1001,6 +1015,7 @@ class DownloadManager:
bool(restored.get("use_default_paths", False)),
restored.get("source"),
restored.get("file_params"),
bool(restored.get("use_save_dir_as_root", False)),
)
)
continue
@@ -1134,6 +1149,7 @@ class DownloadManager:
transfer_backend: str = "python",
source: str | None = None,
file_params: Dict[str, Any] | None = None,
use_save_dir_as_root: bool = False,
) -> Dict[str, Any]:
"""Wrapper for original download_from_civitai implementation"""
try:
@@ -1362,36 +1378,41 @@ class DownloadManager:
# Handle use_default_paths
if use_default_paths:
settings_manager = get_settings_manager()
# Set save_dir based on model type
if model_type == "checkpoint":
if is_diffusion_model:
default_path = settings_manager.get("default_unet_root")
error_msg = "Default unet root path not set in settings"
else:
default_path = settings_manager.get("default_checkpoint_root")
error_msg = "Default checkpoint root path not set in settings"
if not default_path:
return {
"success": False,
"error": error_msg,
}
save_dir = default_path
elif model_type == "lora":
default_path = settings_manager.get("default_lora_root")
if not default_path:
return {
"success": False,
"error": "Default lora root path not set in settings",
}
save_dir = default_path
elif model_type == "embedding":
default_path = settings_manager.get("default_embedding_root")
if not default_path:
return {
"success": False,
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
# With use_save_dir_as_root, an explicitly provided save_dir is kept
# as the base root and the path template is resolved underneath it.
# Otherwise fall back to the configured default root, which keeps the
# classic "download to default root" behavior for regular downloads.
if not save_dir or not use_save_dir_as_root:
# Set save_dir based on model type
if model_type == "checkpoint":
if is_diffusion_model:
default_path = settings_manager.get("default_unet_root")
error_msg = "Default unet root path not set in settings"
else:
default_path = settings_manager.get("default_checkpoint_root")
error_msg = "Default checkpoint root path not set in settings"
if not default_path:
return {
"success": False,
"error": error_msg,
}
save_dir = default_path
elif model_type == "lora":
default_path = settings_manager.get("default_lora_root")
if not default_path:
return {
"success": False,
"error": "Default lora root path not set in settings",
}
save_dir = default_path
elif model_type == "embedding":
default_path = settings_manager.get("default_embedding_root")
if not default_path:
return {
"success": False,
"error": "Default embedding root path not set in settings",
}
save_dir = default_path
# Calculate relative path using template
relative_path = self._calculate_relative_path(version_info, model_type)
@@ -1414,24 +1435,48 @@ class DownloadManager:
# Create directory if it doesn't exist
os.makedirs(save_dir, exist_ok=True)
# Check if this is an early access model
if version_info.get("earlyAccessEndsAt"):
early_access_date = version_info.get("earlyAccessEndsAt", "")
# Convert to a readable date if possible
# Check if this is a paid or early access model
paid_access = version_info.get("paidAccess")
if isinstance(paid_access, str):
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text
try:
from datetime import datetime
date_obj = datetime.fromisoformat(
early_access_date.replace("Z", "+00:00")
)
formatted_date = date_obj.strftime("%Y-%m-%d")
parsed = json.loads(paid_access)
paid_access = parsed if isinstance(parsed, dict) else None
except (TypeError, ValueError):
paid_access = None
if not isinstance(paid_access, dict):
paid_access = None
# An empty DTO ({"permanent": false, "endsAt": null}) is not a gate
if paid_access and not paid_access.get("permanent") and not paid_access.get("endsAt"):
paid_access = None
if version_info.get("earlyAccessEndsAt") or paid_access:
permanent_paid = bool(paid_access.get("permanent")) if paid_access else False
if permanent_paid:
early_access_msg = (
f"This model requires payment (until {formatted_date}). "
"This model requires payment. Please ensure you have "
"purchased access and are logged in to Civitai."
)
except:
early_access_msg = "This model requires payment. "
else:
early_access_date = version_info.get("earlyAccessEndsAt")
if not early_access_date and paid_access:
early_access_date = paid_access.get("endsAt")
if not early_access_date:
early_access_date = ""
# Convert to a readable date if possible
try:
from datetime import datetime
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
date_obj = datetime.fromisoformat(
early_access_date.replace("Z", "+00:00")
)
formatted_date = date_obj.strftime("%Y-%m-%d")
early_access_msg = (
f"This model requires payment (until {formatted_date}). "
)
except Exception:
early_access_msg = "This model requires payment. "
early_access_msg += "Please ensure you have purchased early access and are logged in to Civitai."
logger.warning(
f"Early access model detected: {version_info.get('name', 'Unknown')}"
)
@@ -1486,7 +1531,7 @@ class DownloadManager:
f
for f in files
if f.get("primary")
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
),
None,
)
@@ -1526,21 +1571,52 @@ class DownloadManager:
# Fallback to primary file if no match found
if not file_info:
logger.debug("[download] Looking for primary file as fallback")
# Prefer a weights-type file CivitAI marked primary; then any
# weights-type file (providers without primary flags, e.g.
# civarchive); then trust CivitAI's primary flag regardless of
# type — newer types like 'Enhancement LoRA' are valid primary
# files. Weights files are preferred over non-weights primary
# files so a Config/Archive primary never replaces a Model.
file_info = next(
(
f
for f in files
if f.get("primary") and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
if f.get("primary") and f.get("type") in MODEL_WEIGHT_FILE_TYPES
),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected: id=%s, name=%s",
"[download] Fallback primary file selected (primary + weights): id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
file_info = next(
(f for f in files if f.get("type") in MODEL_WEIGHT_FILE_TYPES),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected (weights type, no primary flag): id=%s, name=%s",
file_info.get("id"), file_info.get("name"),
)
else:
file_info = next(
(
f
for f in files
if f.get("primary")
and f.get("type") not in NON_DOWNLOADABLE_PRIMARY_TYPES
),
None,
)
if file_info:
logger.debug(
"[download] Fallback primary file selected (trusting CivitAI primary flag): id=%s, name=%s, type=%s",
file_info.get("id"), file_info.get("name"), file_info.get("type"),
)
else:
logger.debug("[download] No primary file found in fallback lookup")
if not file_info:
return {"success": False, "error": "No suitable file found in metadata"}
@@ -2761,6 +2837,7 @@ class DownloadManager:
bool(persisted.get("use_default_paths", False)),
persisted.get("source"),
persisted.get("file_params"),
bool(persisted.get("use_save_dir_as_root", False)),
),
)
except Exception as exc:
+121 -7
View File
@@ -6,6 +6,7 @@
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
@@ -74,6 +75,8 @@ class ModelVersionRecord:
sort_index: int = 0
is_early_access: bool = False
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
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)
@dataclass
@@ -107,13 +110,17 @@ class ModelUpdateRecord:
return [version.version_id for version in self.versions if version.is_in_library]
def has_update(
self, hide_early_access: bool = False, hide_non_downloadable: bool = True
self,
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool:
"""Return True when a non-ignored remote version newer than the newest local copy is available.
Args:
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
hide_paid: If True, exclude permanent paid versions from update check.
"""
if self.should_ignore_model:
@@ -129,6 +136,7 @@ class ModelUpdateRecord:
not version.is_in_library
and not version.should_ignore
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
and not (hide_paid and version.is_paid)
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
for version in self.versions
)
@@ -138,6 +146,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
if version.version_id > max_in_library:
@@ -152,6 +162,11 @@ class ModelUpdateRecord:
1. If exact EA end time available (from single version API), use it for precise check
2. Otherwise fallback to basic EA flag (from bulk API)
"""
# Permanent paid versions are not early access; they are filtered by
# hide_paid instead. Only timed gates count as early access.
if version.is_paid and not version.early_access_ends_at:
return False
# Phase 2: Precise check with exact end time
if version.early_access_ends_at:
try:
@@ -178,6 +193,7 @@ class ModelUpdateRecord:
local_base_model: Optional[str],
hide_early_access: bool = False,
hide_non_downloadable: bool = True,
hide_paid: bool = False,
) -> bool:
"""Return True when a newer remote version with the same base model exists.
@@ -186,6 +202,7 @@ class ModelUpdateRecord:
local_base_model: The base model to filter by.
hide_early_access: If True, exclude early access versions from update check.
hide_non_downloadable: If True, exclude versions that don't allow downloads.
hide_paid: If True, exclude permanent paid versions from update check.
"""
if self.should_ignore_model:
@@ -216,6 +233,8 @@ class ModelUpdateRecord:
continue
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
continue
if hide_paid and version.is_paid:
continue
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
continue
version_base = _normalize_base_model(version.base_model)
@@ -252,6 +271,8 @@ class ModelUpdateService:
is_in_library INTEGER NOT NULL DEFAULT 0,
should_ignore INTEGER NOT NULL DEFAULT 0,
usage_control TEXT,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
);
@@ -491,6 +512,14 @@ class ModelUpdateService:
"ALTER TABLE model_update_versions "
"ADD COLUMN usage_control TEXT"
),
"paid_access": (
"ALTER TABLE model_update_versions "
"ADD COLUMN paid_access TEXT"
),
"is_paid": (
"ALTER TABLE model_update_versions "
"ADD COLUMN is_paid INTEGER NOT NULL DEFAULT 0"
),
}
for column, statement in migrations.items():
@@ -592,6 +621,8 @@ class ModelUpdateService:
should_ignore INTEGER NOT NULL DEFAULT 0,
early_access_ends_at TEXT,
is_early_access INTEGER NOT NULL DEFAULT 0,
paid_access TEXT,
is_paid INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (model_id, version_id),
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
)
@@ -611,6 +642,8 @@ class ModelUpdateService:
"should_ignore",
"early_access_ends_at",
"is_early_access",
"paid_access",
"is_paid",
]
defaults = {
"sort_index": "0",
@@ -623,6 +656,8 @@ class ModelUpdateService:
"should_ignore": "0",
"early_access_ends_at": "NULL",
"is_early_access": "0",
"paid_access": "NULL",
"is_paid": "0",
}
select_parts = []
@@ -936,17 +971,30 @@ class ModelUpdateService:
async with self._lock:
return self._get_record(model_type, model_id)
async def has_update(self, model_type: str, model_id: int, hide_early_access: bool = False) -> bool:
async def has_update(
self,
model_type: str,
model_id: int,
hide_early_access: bool = False,
hide_paid: bool = False,
) -> bool:
"""Determine if a model has updates pending."""
record = await self.get_record(model_type, model_id)
return record.has_update(hide_early_access=hide_early_access) if record else False
return (
record.has_update(
hide_early_access=hide_early_access, hide_paid=hide_paid
)
if record
else False
)
async def has_updates_bulk(
self,
model_type: str,
model_ids: Sequence[int],
hide_early_access: bool = False,
hide_paid: bool = False,
) -> Dict[int, bool]:
"""Return update availability for each model id in a single database pass."""
@@ -959,7 +1007,9 @@ class ModelUpdateService:
return {
model_id: (
records[model_id].has_update(hide_early_access=hide_early_access)
records[model_id].has_update(
hide_early_access=hide_early_access, hide_paid=hide_paid
)
if model_id in records
else False
)
@@ -1190,6 +1240,7 @@ class ModelUpdateService:
"earlyAccessEndsAt": _normalize_string(
entry.get("earlyAccessEndsAt")
),
"paidAccess": entry.get("paidAccess"),
}
except RateLimitError:
raise
@@ -1214,6 +1265,17 @@ class ModelUpdateService:
"earlyAccessEndsAt"
):
version["earlyAccessEndsAt"] = extra["earlyAccessEndsAt"]
# Only backfill when the model-level response carries no *active*
# paidAccess signal: a present-but-empty DTO (e.g.
# {"permanent": false, "endsAt": null}) would otherwise block
# the authoritative by-hash data.
extra_paid = ModelUpdateService._normalize_paid_access(
extra.get("paidAccess")
)
if extra_paid and not ModelUpdateService._normalize_paid_access(
version.get("paidAccess")
):
version["paidAccess"] = extra["paidAccess"]
@staticmethod
def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]:
@@ -1464,6 +1526,8 @@ class ModelUpdateService:
early_access_ends_at=remote_version.early_access_ends_at,
is_early_access=remote_version.is_early_access,
usage_control=remote_version.usage_control,
paid_access=remote_version.paid_access,
is_paid=remote_version.is_paid,
)
)
@@ -1564,6 +1628,18 @@ class ModelUpdateService:
is_early_access = availability == "EarlyAccess"
usage_control = _normalize_string(entry.get("usageControl"))
# CivitAI's paidAccess DTO ({"permanent": bool, "endsAt": ISO|null})
# gates versions behind a paid tier while availability stays "Public".
paid_access = self._normalize_paid_access(entry.get("paidAccess"))
paid_access_json = json.dumps(paid_access) if paid_access else None
is_paid = bool(paid_access.get("permanent")) if paid_access else False
if early_access_ends_at is None and paid_access and paid_access.get("endsAt"):
early_access_ends_at = _normalize_string(paid_access.get("endsAt"))
# Only timed gates are early access; permanent paid versions are not
# (consumers filter them via is_paid), so the stored flag stays accurate.
if not is_early_access and paid_access and paid_access.get("endsAt"):
is_early_access = True
return ModelVersionRecord(
version_id=version_id,
name=name,
@@ -1577,8 +1653,36 @@ class ModelUpdateService:
sort_index=index,
is_early_access=is_early_access,
usage_control=usage_control,
paid_access=paid_access_json,
is_paid=is_paid,
)
@staticmethod
def _normalize_paid_access(value) -> Optional[Dict[str, Any]]:
"""Normalize a CivitAI ``paidAccess`` DTO into a mapping.
Accepts a dict, None, or a JSON string (as carried by the by-hash
enrichment path) and returns ``{"permanent": bool, "endsAt": str|None}``
or None when the input carries no paid-access signal.
"""
if value is None:
return None
if isinstance(value, str):
try:
parsed = json.loads(value)
except (TypeError, ValueError):
return None
if not isinstance(parsed, dict):
return None
value = parsed
if not isinstance(value, Mapping):
return None
permanent = bool(value.get("permanent"))
ends_at = _normalize_string(value.get("endsAt"))
if not permanent and ends_at is None:
return None
return {"permanent": permanent, "endsAt": ends_at}
def _extract_size_bytes(self, files) -> Optional[int]:
if not isinstance(files, Iterable):
return None
@@ -1691,7 +1795,7 @@ class ModelUpdateService:
f"""
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,
is_early_access, usage_control
is_early_access, usage_control, paid_access, is_paid
FROM model_update_versions
WHERE model_id IN ({placeholders})
ORDER BY model_id ASC, sort_index ASC, version_id ASC
@@ -1720,6 +1824,8 @@ class ModelUpdateService:
sort_index=_normalize_int(row["sort_index"]) or 0,
is_early_access=bool(row["is_early_access"]),
usage_control=row["usage_control"],
paid_access=row["paid_access"],
is_paid=bool(row["is_paid"]),
)
)
@@ -1771,13 +1877,19 @@ class ModelUpdateService:
(record.model_id,),
)
for version in record.versions:
paid_access_value = (
version.paid_access
if version.paid_access is None
or isinstance(version.paid_access, str)
else json.dumps(version.paid_access)
)
conn.execute(
"""
INSERT INTO model_update_versions (
version_id, model_id, sort_index, name, base_model, released_at,
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
is_early_access, usage_control
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
is_early_access, usage_control, paid_access, is_paid
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
version.version_id,
@@ -1793,6 +1905,8 @@ class ModelUpdateService:
version.early_access_ends_at,
1 if version.is_early_access else 0,
version.usage_control,
paid_access_value,
1 if version.is_paid else 0,
),
)
conn.commit()
+216 -64
View File
@@ -39,14 +39,13 @@ from typing import (
from ..utils.constants import PREVIEW_EXTENSIONS
from ..utils import settings_paths
from .settings_manager import get_settings_manager
logger = logging.getLogger(__name__)
# Undo window in seconds before a staged batch becomes purge-eligible.
PENDING_DELETE_TTL_SECONDS = 30
# Hidden staging directory name placed under each model root (and the settings
# dir for recipes).
PENDING_DELETE_TTL_SECONDS = 20
# Hidden staging directory name placed inside each deleted model's own folder
# (sibling of the model artifacts) and under the settings dir for recipes.
PENDING_DELETE_DIR_NAME = ".lm-pending-delete"
# Manifest file name inside every batch directory.
MANIFEST_FILE_NAME = "manifest.json"
@@ -97,6 +96,11 @@ class PendingDeleteService:
# ServiceRegistry roots during sweeps so undo/purge work even before
# every scanner is registered.
self._known_roots: List[str] = []
# MODEL batches only; recipe batches live in the fixed settings-dir
# parent. Registry access is short critical sections; the lock-free
# reconciliation scan registers concurrently.
self._known_batch_dirs: Dict[str, str] = {}
self._registry_lock = asyncio.Lock()
# ------------------------------------------------------------------
# Public API
@@ -111,19 +115,19 @@ class PendingDeleteService:
original_file_path: str,
cached_entry: Optional[Dict[str, Any]],
) -> Optional[str]:
"""Rename a model's artifacts into a per-root staging batch.
"""Rename a model's artifacts into a sibling-of-model staging batch.
Returns the batch id, or ``None`` when undo is disabled, the staging
root cannot be resolved, or staging failed (caller falls back to a
hard delete).
The batch dir is created inside the model file's OWN directory
(``target_dir``), so staging/undo renames stay within one real
directory - EXDEV is impossible even when the business path traverses
nested symlinks to other volumes. Returns the batch id, or ``None``
when the model root cannot be resolved, or staging failed (caller
falls back to a hard delete).
"""
# LOCK-FREE section: opportunistic purge must never run while holding
# the ops lock (the lock is not re-entrant).
await self._opportunistic_purge()
if not self._undo_enabled():
return None
async with self._ops_lock:
batch_dir: Optional[str] = None
staged_pairs: List[Dict[str, Any]] = []
@@ -148,7 +152,7 @@ class PendingDeleteService:
batch_id = self._new_batch_id()
batch_dir = os.path.join(
os.path.join(root, PENDING_DELETE_DIR_NAME), batch_id
os.path.abspath(target_dir), PENDING_DELETE_DIR_NAME, batch_id
)
os.makedirs(batch_dir, exist_ok=True)
@@ -176,14 +180,16 @@ class PendingDeleteService:
)
self._write_manifest_atomic(batch_dir, manifest)
self._remember_root(root)
await self._remember_batch(batch_id, batch_dir)
# Arm the per-batch purge timer. Safe inside the lock: task
# creation does not await, and purge_batch re-reads the
# manifest's expires_at at fire time, so stale timers no-op.
self._arm_purge_timer(batch_id)
logger.info(
"Staged model delete batch %s with %d file(s)",
"Staged model delete batch %s with %d file(s): %s",
batch_id,
len(staged_pairs),
staged_pairs[0]["original"] if staged_pairs else None,
)
return batch_id
except OSError as exc:
@@ -209,14 +215,11 @@ class PendingDeleteService:
) -> Optional[str]:
"""Copy a recipe JSON (and, when it exists, its image) into staging.
Returns the batch id, or ``None`` when undo is disabled / staging
failed. Missing or shared preview images are skipped.
Returns the batch id, or ``None`` when staging failed. Missing or
shared preview images are skipped.
"""
await self._opportunistic_purge()
if not self._undo_enabled():
return None
async with self._ops_lock:
batch_dir: Optional[str] = None
staged_pairs: List[Dict[str, Any]] = []
@@ -246,9 +249,10 @@ class PendingDeleteService:
self._write_manifest_atomic(batch_dir, manifest)
self._arm_purge_timer(batch_id)
logger.info(
"Staged recipe delete batch %s with %d file(s)",
"Staged recipe delete batch %s with %d file(s): %s",
batch_id,
len(staged_pairs),
staged_pairs[0]["original"] if staged_pairs else None,
)
return batch_id
except OSError as exc:
@@ -296,7 +300,7 @@ class PendingDeleteService:
# Track (entry, original_staged_path, loser_dir) for rollback.
moved: List[Tuple[Dict[str, Any], str, str]] = []
processed_losers: List[str] = []
processed_losers: List[Tuple[str, str]] = [] # (loser_id, loser_dir)
try:
for loser_id in batch_ids[1:]:
@@ -332,7 +336,7 @@ class PendingDeleteService:
entry["staged"] = os.path.abspath(new_staged)
winner_manifest["entries"].append(entry)
moved.append((entry, original_staged, loser_dir))
processed_losers.append(loser_dir)
processed_losers.append((loser_id, loser_dir))
except OSError as exc:
logger.warning(
"Merge of %s failed after moving files: %s; rolling back",
@@ -357,10 +361,15 @@ class PendingDeleteService:
self._rollback_merge_moves(moved)
return None
# All moves committed: remove loser dirs (must be empty by now).
for loser_dir in processed_losers:
# All moves committed: remove loser dirs (must be empty by now)
# and drop them from the registry. Skipped losers (missing /
# corrupted / same-dir) stay registered so the sweep still
# quarantines them, exactly as before the registry existed.
for loser_id, loser_dir in processed_losers:
self._remove_manifest(loser_dir)
self._remove_empty_dir(loser_dir)
await self._forget_batch(loser_id)
await self._remember_batch(winner_id, winner_dir)
# Arm a fresh purge timer for the winner with the re-anchored
# expiry (the winner's original timer fires at the OLD expiry and
@@ -438,33 +447,101 @@ class PendingDeleteService:
# Remove the manifest + batch dir only after all entries restored.
self._remove_manifest(batch_dir)
self._remove_empty_dir(batch_dir)
await self._forget_batch(batch_id)
logger.info("Restored pending-delete batch %s", batch_id)
return self._undo_result(manifest)
async def purge_expired(self) -> int:
"""Purge every expired batch across ALL model roots and the recipe dir.
async def purge_expired(self, scan_roots: bool = False) -> int:
"""Purge every expired batch.
Lock-free by design: enumerates staging parents (all scanner types via
the ServiceRegistry plus the global recipe staging dir) and delegates
each batch to :meth:`purge_batch`, which acquires the ops lock. Never
call this while holding the ops lock.
Default (registry-only): iterates a SNAPSHOT of the in-process MODEL
batch registry plus a shallow check of the fixed recipe staging
parent - cheap, no tree walk per delete. With ``scan_roots=True``
(startup sweep only) a reconciliation pass re-discovers every batch
on disk under the model roots and registers it FIRST, so crash
leftovers and externally created batches are covered too.
Lock-free by design: delegates each batch to :meth:`purge_batch`,
which acquires the ops lock. Never call this while holding the ops
lock.
"""
purged = 0
for parent in await self._get_all_staging_parents():
if not os.path.isdir(parent):
if scan_roots:
await self._reconcile_scan_roots()
# MODEL batches: snapshot so purge_batch can remove entries
# mid-iteration without a dict-changed-size error.
batch_ids: List[str] = [
batch_id for batch_id, _dir in await self._registered_batch_dirs()
]
# RECIPE batches: fixed settings-dir parent, shallow check as before.
recipe_parent = self._recipe_staging_parent()
for name in self._list_dir_names(recipe_parent):
if name.endswith(ORPHANED_SUFFIX):
# Quarantine is terminal - never re-rename or delete.
continue
for name in self._list_dir_names(parent):
if name.endswith(ORPHANED_SUFFIX):
# Quarantine is terminal - never re-rename or delete.
continue
try:
await self.purge_batch(name)
purged += 1
except Exception as exc: # defensive - sweep must not crash
logger.warning("Failed to purge batch %s: %s", name, exc)
if name not in batch_ids:
batch_ids.append(name)
for batch_id in batch_ids:
try:
await self.purge_batch(batch_id)
purged += 1
except Exception as exc: # defensive - sweep must not crash
logger.warning("Failed to purge batch %s: %s", batch_id, exc)
return purged
async def _reconcile_scan_roots(self) -> None:
"""Register every pending-delete batch found under the model roots.
Runs at startup (``purge_expired(scan_roots=True)``) to re-discover
batches left over from a previous process or created externally.
Registers ALL non-orphaned batch dirs regardless of manifest validity:
malformed/manifest-less dirs must reach ``_purge_batch_dir`` so it can
QUARANTINE them (preserving the pre-registry sweep semantics). The
walk only descends into dirs literally named ``.lm-pending-delete``,
so false positives are structurally limited.
"""
from .model_scanner import _is_excluded_dir
for root in await self._get_all_model_roots():
if not os.path.isdir(root):
continue
visited: Set[str] = set()
for dirpath, dirnames, _files in os.walk(
root, followlinks=True, topdown=True
):
real_dir = os.path.realpath(dirpath)
if real_dir in visited:
# Symlink cycle: prune descent and move on.
dirnames[:] = []
continue
visited.add(real_dir)
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
# The current dir IS a staging parent (reachable only when
# a model root itself is one): register its batches.
await self._register_batch_candidates(dirpath)
dirnames[:] = []
continue
next_dirs: List[str] = []
for name in dirnames:
if name == PENDING_DELETE_DIR_NAME:
await self._register_batch_candidates(
os.path.join(dirpath, name)
)
elif _is_excluded_dir(name):
continue
else:
next_dirs.append(name)
dirnames[:] = next_dirs
async def _register_batch_candidates(self, staging_parent: str) -> None:
"""Register every non-orphaned batch subdir of a staging parent."""
for name in self._list_dir_names(staging_parent):
if name.endswith(ORPHANED_SUFFIX):
# Quarantine is terminal - never re-register.
continue
await self._remember_batch(name, os.path.join(staging_parent, name))
async def purge_batch(self, batch_id: str) -> None:
"""Purge one batch. Silent no-op for missing/undone/not-yet-expired.
@@ -476,7 +553,10 @@ class PendingDeleteService:
batch_dir = await self._find_batch_dir(batch_id)
if not batch_dir:
return
self._purge_batch_dir(batch_dir)
if self._purge_batch_dir(batch_dir):
# Both purge and quarantine remove the batch dir (quarantine
# renames it to *.orphaned), so the registry entry is stale.
await self._forget_batch(batch_id)
# ------------------------------------------------------------------
# Internals
@@ -488,18 +568,34 @@ class PendingDeleteService:
except Exception as exc: # defensive - staging/undo must still proceed
logger.warning("Opportunistic pending-delete purge failed: %s", exc)
def _undo_enabled(self) -> bool:
try:
return bool(get_settings_manager().get("delete_undo_enabled", True))
except Exception as exc: # defensive - default to enabled
logger.warning("Failed to read delete_undo_enabled setting: %s", exc)
return True
def _remember_root(self, root: str) -> None:
"""Record a root the service has staged into (in-process registry)."""
if root and root not in self._known_roots:
self._known_roots.append(root)
async def _remember_batch(self, batch_id: str, batch_dir: str) -> None:
"""Register a MODEL batch in the in-process registry (idempotent).
Short critical section (dict mutation only, no I/O while holding the
lock) so the lock-free reconciliation scan can register concurrently.
"""
async with self._registry_lock:
self._known_batch_dirs[batch_id] = batch_dir
async def _forget_batch(self, batch_id: str) -> None:
"""Remove a MODEL batch from the in-process registry (idempotent)."""
async with self._registry_lock:
self._known_batch_dirs.pop(batch_id, None)
async def _registered_batch_dirs(self) -> List[Tuple[str, str]]:
"""Return a SNAPSHOT of (batch_id, batch_dir) registry pairs.
The snapshot lets purge iterate safely while purge_batch removes
entries mid-loop (no dict-changed-size error).
"""
async with self._registry_lock:
return list(self._known_batch_dirs.items())
def _find_model_root(self, scanner: Any, original_file_path: Optional[str]) -> Optional[str]:
"""Return the configured root containing ``original_file_path``."""
finder = getattr(scanner, "_find_root_for_file", None)
@@ -818,18 +914,6 @@ class PendingDeleteService:
settings_paths.get_settings_dir(create=True), PENDING_DELETE_DIR_NAME
)
async def _get_all_staging_parents(self) -> List[str]:
"""Model staging parents for every scanner type + the recipe parent."""
parents: List[str] = []
for root in await self._get_all_model_roots():
parent = os.path.join(root, PENDING_DELETE_DIR_NAME)
if parent not in parents:
parents.append(parent)
recipe_parent = self._recipe_staging_parent()
if recipe_parent not in parents:
parents.append(recipe_parent)
return parents
async def _get_all_model_roots(self) -> List[str]:
"""Collect every configured model root across all scanner types.
@@ -878,13 +962,81 @@ class PendingDeleteService:
return roots
async def _find_batch_dir(self, batch_id: str) -> Optional[str]:
"""Locate a batch directory across every staging parent."""
"""Locate a batch directory.
Registry lookup first (fast path; stale entries are forgotten when
their dir vanished); then a targeted scan of the model roots for a
batch dir named exactly ``batch_id`` under a ``.lm-pending-delete``
parent (restart / externally created batches; manifest verification
applies so random uuid-named user dirs are never registered); finally
the fixed recipe staging parent. Returns ``None`` (404 semantics)
when not found.
"""
if not batch_id:
return None
for parent in await self._get_all_staging_parents():
candidate = os.path.join(parent, batch_id)
if os.path.isdir(candidate):
# 1) Registry fast path.
async with self._registry_lock:
known = self._known_batch_dirs.get(batch_id)
if known is not None:
if os.path.isdir(known):
return known
await self._forget_batch(batch_id) # stale entry - dir is gone
# 2) Targeted scan fallback across the model roots.
for root in await self._get_all_model_roots():
if not os.path.isdir(root):
continue
candidate = await self._scan_root_for_batch(root, batch_id)
if candidate is not None:
await self._remember_batch(batch_id, candidate)
return candidate
# 3) Recipe batches: fixed settings-dir parent, shallow check.
candidate = os.path.join(self._recipe_staging_parent(), batch_id)
if os.path.isdir(candidate):
return candidate
return None
async def _scan_root_for_batch(self, root: str, batch_id: str) -> Optional[str]:
"""Search one model root for a batch dir named exactly ``batch_id``.
Walks the root (``followlinks=True``) with a realpath cycle guard,
looking for ``.lm-pending-delete`` parents whose subdir matches
``batch_id`` AND has a parseable manifest. The manifest check prevents
random uuid-named user dirs from being treated as batches (a batch
with no parseable manifest cannot be undone anyway).
"""
from .model_scanner import _is_excluded_dir
visited: Set[str] = set()
for dirpath, dirnames, _files in os.walk(root, followlinks=True, topdown=True):
real_dir = os.path.realpath(dirpath)
if real_dir in visited:
# Symlink cycle: prune descent and move on.
dirnames[:] = []
continue
visited.add(real_dir)
if os.path.basename(dirpath) == PENDING_DELETE_DIR_NAME:
candidate = os.path.join(dirpath, batch_id)
if (
os.path.isdir(candidate)
and self._read_manifest(candidate) is not None
):
return candidate
dirnames[:] = []
continue
next_dirs: List[str] = []
for name in dirnames:
if name == PENDING_DELETE_DIR_NAME:
candidate = os.path.join(dirpath, name, batch_id)
if (
os.path.isdir(candidate)
and self._read_manifest(candidate) is not None
):
return candidate
continue
if _is_excluded_dir(name):
continue
next_dirs.append(name)
dirnames[:] = next_dirs
return None
def _list_dir_names(self, parent: str) -> List[str]:
+253 -48
View File
@@ -8,11 +8,13 @@ import asyncio
import json
import logging
import os
import random
import time
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
from ..config import config
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
from ..utils.file_utils import calculate_autov3
from ..utils.recipe_open_stats import RecipeOpenStats
from .recipe_cache import RecipeCache
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
from natsort import natsorted
@@ -34,6 +36,11 @@ logger = logging.getLogger(__name__)
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
# Known weight-file extensions stripped by _normalize_filename_key. Names are
# stored extensionless on both sides, so splitext would misread dotted stems
# ("my.mix" -> "my") and silently collide distinct models.
_WEIGHT_FILE_EXTS = (".safetensors", ".ckpt", ".pt", ".pth", ".gguf", ".bin", ".safebin", ".sft")
class RecipeScanner:
"""Service for scanning and managing recipe images"""
@@ -114,6 +121,12 @@ class RecipeScanner:
self._rematch_autov3_cache: dict[str, dict[str, Any]] | None = None
self._rematch_autov3_versions: tuple[int, int] | None = None
self._rematch_autov3_lock = asyncio.Lock()
# Normalized filename -> [items] map for the L4 rematch fallback,
# rebuilt only when either model scanner's cache_version changes.
# Mirrors the build_local_hash_cache version pattern.
self._local_filename_cache: dict[str, list[dict[str, Any]]] | None = None
self._local_filename_cache_versions: tuple[int, int] | None = None
self._local_filename_cache_lock = asyncio.Lock()
self._initialized = True
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
@@ -160,6 +173,70 @@ class RecipeScanner:
self._local_hash_cache_versions = versions
return cache
@staticmethod
def _normalize_filename_key(name: str) -> str:
"""Normalize a file name to a lookup key (basename, lowercase).
Only known weight-file extensions are stripped names are stored
extensionless on both sides, so splitext would misread dotted stems
("my.mix" -> "my") and collide distinct models.
"""
if not name:
return ""
basename = os.path.basename(name.replace("\\", "/"))
lower = basename.lower()
for ext in _WEIGHT_FILE_EXTS:
if lower.endswith(ext):
basename = basename[: -len(ext)]
break
return basename.strip().lower()
async def _build_local_filename_cache(self) -> dict[str, list[dict[str, Any]]]:
"""Build a version-cached map of normalized file names to local items.
Keys are lowercase basenames without extension. Values are lists of
items (lora + checkpoint, type-blind) sharing that name. Only items
with a sha256 are indexed matching a pending or failed download
(empty sha256) would leave the entry without a usable hash. The dict
is reused while both scanners' cache_version values are unchanged;
concurrent callers share a single build via the lock.
"""
async with self._local_filename_cache_lock:
lora_scanner = self._lora_scanner
checkpoint_scanner = self._checkpoint_scanner
versions = (
lora_scanner.cache_version if lora_scanner is not None else 0,
checkpoint_scanner.cache_version
if checkpoint_scanner is not None
else 0,
)
if (
self._local_filename_cache is not None
and self._local_filename_cache_versions == versions
):
return self._local_filename_cache
cache: dict[str, list[dict[str, Any]]] = {}
for scanner in (lora_scanner, checkpoint_scanner):
if scanner is None:
continue
data = await scanner.get_cached_data()
for item in data.raw_data:
if not isinstance(item, dict):
continue
if not (item.get("sha256") or "").lower():
continue
file_path = item.get("file_path") or ""
file_name = item.get("file_name") or ""
key = self._normalize_filename_key(file_name or file_path)
if not key:
continue
cache.setdefault(key, []).append(item)
self._local_filename_cache = cache
self._local_filename_cache_versions = versions
return cache
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
"""Return True when a recipe entry is eligible for local re-matching."""
if not isinstance(entry, dict):
@@ -168,7 +245,10 @@ class RecipeScanner:
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
)
has_identifier = (
entry.get("hash") or entry.get("modelVersionId") or entry.get("id")
entry.get("hash")
or entry.get("modelVersionId")
or entry.get("id")
or entry.get("file_name")
)
return bool(unresolved and has_identifier)
@@ -219,6 +299,97 @@ class RecipeScanner:
self._rematch_autov3_versions = versions
return cache
def _is_type_compatible(self, item: dict[str, Any], *, is_checkpoint: bool) -> bool:
"""Return True when a local item's type matches the entry kind.
The L1 hash cache and the L4 filename cache merge lora and checkpoint
items and are type-blind, so a match must be verified against the
entry kind before it is accepted.
"""
sub_type = (item.get("sub_type") or "").lower()
if sub_type:
valid = (
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
)
return sub_type in valid
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
if civitai_type:
normalized = civitai_type.lower()
if is_checkpoint:
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
normalized, normalized
)
valid = VALID_CHECKPOINT_SUB_TYPES
else:
valid = VALID_LORA_TYPES
return normalized in valid
return True
@staticmethod
def _has_positive_type_evidence(item: dict[str, Any]) -> bool:
"""Return True when the item carries an explicit type marker.
Lora raw items rarely carry ``sub_type`` (it is only written when
metadata provides it), while checkpoint items always do so for
checkpoint slots a type-less candidate is a red flag, not the norm.
"""
if (item.get("sub_type") or "").lower():
return True
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
return bool(civitai_type)
def _match_rematch_entry_filename(
self,
entry: dict[str, Any],
recipe_base_model: Optional[str],
filename_cache: dict[str, list[dict[str, Any]]],
*,
is_checkpoint: bool,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""Match a recipe entry against local models by file name (L4).
Conservative fallback used only after the hash (L1), version-index
(L2) and computed-autov3 (L3) tiers all failed. Candidates share the
entry's normalized file name; a candidate is accepted only when BOTH
the recipe base model and the candidate's base model are known and
equal (unknown on either side rejects never guess on missing
metadata), the type gate passes, and exactly one candidate survives
(ambiguity is a miss). Checkpoint slots additionally require positive
type evidence: lora raw items often lack ``sub_type`` while
checkpoints always carry it, so a type-less candidate is a red flag
there an unknown-type lora must not be bound into a checkpoint
slot.
Returns:
Tuple of (matched item, "L4") or ``(None, None)``.
"""
entry_name = self._normalize_filename_key(entry.get("file_name") or "")
if not entry_name:
return (None, None)
recipe_base = (recipe_base_model or "").strip().lower()
matched: list[dict[str, Any]] = []
for candidate in filename_cache.get(entry_name, []):
candidate_base = (candidate.get("base_model") or "").strip().lower()
if not recipe_base or not candidate_base:
continue
if recipe_base != candidate_base:
continue
if is_checkpoint and not self._has_positive_type_evidence(candidate):
continue
if not self._is_type_compatible(candidate, is_checkpoint=is_checkpoint):
continue
matched.append(candidate)
if len(matched) != 1:
return (None, None)
return (matched[0], "L4")
async def _match_rematch_entry(
self,
entry: dict[str, Any],
@@ -245,19 +416,23 @@ class RecipeScanner:
autov3_cache: dict[str, Any],
*,
is_checkpoint: bool,
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
recipe_base_model: Optional[str] = None,
) -> Tuple[Optional[dict[str, Any]], Optional[str]]:
"""Match a recipe entry against local models across three levels.
"""Match a recipe entry against local models across four levels.
L1 looks the stored hash up in the type-blind local hash cache; L2
falls back to the version index via ``modelVersionId`` or ``id``; L3
resolves 12-char hashes through the computed AutoV3 cache. Matched
items are type-verified against the entry kind before being returned.
resolves 12-char hashes through the computed AutoV3 cache; L4
(conservative) falls back to the file name when a filename cache is
provided. Matched items are type-verified against the entry kind
before being returned.
Returns:
Tuple of (matched item, match level) where level is "L1", "L2" or
"L3" or ``(None, None)`` when no usable match exists. A missing
local match is an expected outcome (the model may simply not be
present locally), not an error.
Tuple of (matched item, match level) where level is "L1", "L2",
"L3" or "L4" or ``(None, None)`` when no usable match exists. A
missing local match is an expected outcome (the model may simply
not be present locally), not an error.
"""
entry_hash = (entry.get("hash") or "").lower()
@@ -277,33 +452,20 @@ class RecipeScanner:
item = autov3_cache.get(entry_hash)
level = "L3" if item is not None else None
if item is None and filename_cache is not None:
item, level = self._match_rematch_entry_filename(
entry,
recipe_base_model,
filename_cache,
is_checkpoint=is_checkpoint,
)
level = "L4" if item is not None else None
if item is None:
return (None, None)
# Type gate: the L1 cache merges lora and checkpoint items and is
# type-blind, so a match must be verified against the entry kind.
sub_type = (item.get("sub_type") or "").lower()
if sub_type:
valid = (
VALID_CHECKPOINT_SUB_TYPES if is_checkpoint else VALID_LORA_TYPES
)
if sub_type not in valid:
return (None, None)
else:
civitai_type = (
(item.get("civitai") or {}).get("model", {}) or {}
).get("type", "")
if civitai_type:
normalized = civitai_type.lower()
if is_checkpoint:
normalized = _CHECKPOINT_MODEL_TYPE_ALIASES.get(
normalized, normalized
)
valid = VALID_CHECKPOINT_SUB_TYPES
else:
valid = VALID_LORA_TYPES
if normalized not in valid:
return (None, None)
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
return (None, None)
return (item, level)
@@ -615,10 +777,11 @@ class RecipeScanner:
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built
BEFORE acquiring the mutation lock both are read-only snapshots and
the version-cached hash dict would otherwise rebuild mid-run if a scan
bumps a scanner's cache_version while we hold the lock.
Match snapshots (local hash cache, computed autov3 cache, filename
cache) are built BEFORE acquiring the mutation lock all three are
read-only snapshots and the version-cached dicts would otherwise
rebuild mid-run if a scan bumps a scanner's cache_version while we
hold the lock.
Args:
recipe_id: ID of the recipe to rematch
@@ -634,6 +797,7 @@ class RecipeScanner:
"""
local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache()
filename_cache = await self._build_local_filename_cache()
async with self._mutation_lock:
# Get raw recipe from cache directly to avoid formatted fields
@@ -647,7 +811,7 @@ class RecipeScanner:
try:
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache
recipe, local_cache, autov3_cache, filename_cache
)
except RecipePersistenceError as exc:
logger.error(
@@ -704,6 +868,7 @@ class RecipeScanner:
recipe: Dict[str, Any],
local_cache: dict[str, dict[str, Any]],
autov3_cache: dict[str, dict[str, Any]],
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
) -> Tuple[int, int, Dict[str, Any]]:
"""Rematch a single recipe's lora/checkpoint entries against local models.
@@ -717,6 +882,8 @@ class RecipeScanner:
recipe: The recipe dictionary to rematch (modified in-place)
local_cache: L1 hash cache snapshot (build_local_hash_cache)
autov3_cache: L3 computed-autov3 cache snapshot
filename_cache: L4 filename cache snapshot, or None to disable
the filename fallback
Returns:
Tuple of (rematched_entries, errors, details). The errors element
@@ -742,7 +909,13 @@ class RecipeScanner:
if not self._is_rematch_candidate(entry):
continue
item, level = await self._match_rematch_entry_with_level(
entry, local_cache, autov3_cache, is_checkpoint=False
entry,
local_cache,
autov3_cache,
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model=entry.get("baseModel")
or recipe.get("base_model"),
)
if item is None:
details["unresolved"].append(
@@ -768,7 +941,13 @@ class RecipeScanner:
if isinstance(checkpoint, dict):
if self._is_rematch_candidate(checkpoint):
item, level = await self._match_rematch_entry_with_level(
checkpoint, local_cache, autov3_cache, is_checkpoint=True
checkpoint,
local_cache,
autov3_cache,
is_checkpoint=True,
filename_cache=filename_cache,
recipe_base_model=checkpoint.get("baseModel")
or recipe.get("base_model"),
)
if item is None:
details["unresolved"].append(
@@ -830,12 +1009,13 @@ class RecipeScanner:
) -> Dict[str, Any]:
"""Rematch every recipe's deleted lora/checkpoint entries locally.
Match snapshots (local hash cache + computed autov3 cache) are built
ONCE before the loop both are read-only and the version-cached hash
dict would otherwise rebuild mid-run if a scan bumps a scanner's
cache_version while the mutation lock is held. ``_schedule_resort`` is
called exactly once after the loop: it spawns an asyncio task per call,
so per-recipe calls would race one resort task per recipe.
Match snapshots (local hash cache, computed autov3 cache, filename
cache) are built ONCE before the loop all three are read-only and
the version-cached dicts would otherwise rebuild mid-run if a scan
bumps a scanner's cache_version while the mutation lock is held.
``_schedule_resort`` is called exactly once after the loop: it spawns
an asyncio task per call, so per-recipe calls would race one resort
task per recipe.
Args:
progress_callback: Optional callback for progress updates
@@ -856,6 +1036,7 @@ class RecipeScanner:
# Match snapshots built once and shared by every recipe in the loop.
local_cache = await self.build_local_hash_cache()
autov3_cache = await self._build_rematch_autov3_cache()
filename_cache = await self._build_local_filename_cache()
async with self._mutation_lock:
cache = await self.get_cached_data()
@@ -923,7 +1104,7 @@ class RecipeScanner:
)
rematched, _errors, details = await self._rematch_single_recipe(
recipe, local_cache, autov3_cache
recipe, local_cache, autov3_cache, filename_cache
)
if rematched > 0:
matched_recipes += 1
@@ -2781,7 +2962,11 @@ class RecipeScanner:
Args:
page: Current page number (1-based)
page_size: Number of items per page
sort_by: Sort method ('name' or 'date')
sort_by: Sort method ('name', 'date', 'loras_count', 'opened',
or 'random' with an optional seed like 'random:abc123'; the
part after 'random:' is the shuffle seed, not a direction).
'opened' hides recipes that were never opened it is a
"recently opened" view, not a plain reorder
search: Search term
filters: Dictionary of filters to apply
search_options: Dictionary of search options to apply
@@ -2962,7 +3147,7 @@ class RecipeScanner:
]
# Apply sorting if not already handled by pre-sorted cache
if ":" in sort_by or sort_field == "loras_count":
if ":" in sort_by or sort_field in ("loras_count", "random", "opened"):
field, order = (sort_by.split(":") + ["desc"])[:2]
reverse = order.lower() == "desc"
@@ -2981,10 +3166,30 @@ class RecipeScanner:
),
reverse=reverse,
)
elif field == "opened":
# "Recently Opened" view: recipes never opened are hidden.
# The open stats live outside recipe metadata; see
# RecipeOpenStats.
opened_map = RecipeOpenStats().get_opened_map()
filtered_data = [
item
for item in filtered_data
if opened_map.get(str(item.get("id", ""))) is not None
]
filtered_data.sort(
key=lambda x: opened_map.get(str(x.get("id", "")), 0),
reverse=reverse,
)
elif field == "loras_count":
filtered_data.sort(
key=lambda x: len(x.get("loras", [])), reverse=reverse
)
elif field == "random":
# Seeded random shuffle: same seed -> same order (stable
# pagination across requests), matching the model pages.
seed = order if order.lower() not in ("asc", "desc") else None
rng = random.Random(seed or "random")
rng.shuffle(filtered_data)
# Calculate pagination
total_items = len(filtered_data)
-1
View File
@@ -111,7 +111,6 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"backup_retention_count": 5,
"use_new_license_icons": True,
"group_by_model": False,
"delete_undo_enabled": True,
# AI / LLM provider configuration (BYOK)
"llm_provider": "openai", # "openai" | "ollama" | "custom"
"llm_api_key": "",
+14
View File
@@ -62,6 +62,20 @@ MODEL_FILE_EXTENSIONS = {
".gguf",
}
# CivitAI ModelFile.type values eligible as the main download file.
# Mirrors CivitAI's getPrimaryFile() (model-helpers.ts): weight types are
# preferred, but any file CivitAI marks `primary` is accepted — newer types
# like 'Enhancement LoRA' (Anima/AIR image-editing LoRAs) are valid primary
# files despite not being in the traditional weights allowlist.
MODEL_WEIGHT_FILE_TYPES = (
"Model",
"Pruned Model",
"Negative",
"UNet",
"Diffusion Model",
"Enhancement LoRA",
)
# Valid sub-types for each scanner type
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
+161
View File
@@ -0,0 +1,161 @@
"""Track recipe modal open timestamps for the "Recently Opened" sort.
The data is deliberately kept OUTSIDE the recipe metadata files: recording an
open must be cheap and must never rewrite recipe JSON or EXIF (which the
generic metadata update path does). A tiny JSON map of
``recipe_id -> unix timestamp`` lives under
``{settings_dir}/stats/recipe_last_opened.json`` and is written atomically on
a short debounce.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import time
from ..utils.settings_paths import get_settings_dir
logger = logging.getLogger(__name__)
class RecipeOpenStats:
"""Persist the last time each recipe was opened in the recipe modal."""
STATS_FILENAME: str = "recipe_last_opened.json"
SAVE_DELAY: float = 1.0 # seconds of debounce between consecutive writes
_instance: "RecipeOpenStats | None" = None
_opened: dict[str, float]
_file_mtime: float | None
_dirty: bool
_lock: asyncio.Lock
_save_task: "asyncio.Task[None] | None"
_stats_file_path: str
_initialized: bool
def __new__(cls) -> "RecipeOpenStats":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if getattr(self, "_initialized", False):
return
self._opened = {}
self._file_mtime = None
self._dirty = False
self._lock = asyncio.Lock()
self._save_task = None
self._stats_file_path = self._get_stats_file_path()
self._load_stats()
self._initialized = True
def _get_stats_file_path(self) -> str:
settings_dir = get_settings_dir(create=True)
return os.path.join(settings_dir, "stats", self.STATS_FILENAME)
def _load_stats(self) -> None:
"""Load the opened map from disk, tolerating corrupt/absent files.
The mtime is recorded even when parsing fails so a corrupt file is
not re-read (and re-logged) on every lookup.
"""
if not os.path.exists(self._stats_file_path):
return
try:
mtime = os.path.getmtime(self._stats_file_path)
except OSError:
return
try:
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
raw = json.load(file_obj)
if isinstance(raw, dict):
self._opened = {
str(key): float(value)
for key, value in raw.items()
if isinstance(value, (int, float))
}
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error loading recipe open stats: %s", exc)
self._opened = {}
self._file_mtime = mtime
def get_opened_map(self) -> dict[str, float]:
"""Return a copy of ``recipe_id -> last opened timestamp``.
Refreshes from disk when the file changed since the last load so a
second server process (or manual edit) is picked up without restart.
"""
try:
if os.path.exists(self._stats_file_path):
mtime = os.path.getmtime(self._stats_file_path)
if self._file_mtime is None or mtime != self._file_mtime:
self._load_stats()
except OSError:
pass
return dict(self._opened)
def record_open(self, recipe_id: str) -> None:
"""Mark a recipe as opened now; persists shortly in the background."""
if not recipe_id:
return
self._opened[str(recipe_id)] = time.time()
self._dirty = True
if self._save_task is None or self._save_task.done():
self._save_task = asyncio.create_task(self._delayed_save())
async def _delayed_save(self) -> None:
"""Debounced writer: batches rapid consecutive opens into one write."""
await asyncio.sleep(self.SAVE_DELAY)
_ = await self.save_stats()
async def save_stats(self, force: bool = False) -> bool:
"""Persist the opened map atomically if dirty (or when forced).
The on-disk map is merged in first so a second process sharing the
settings dir does not lose its entries; the larger timestamp wins
per recipe.
"""
if not force and not self._dirty:
return False
async with self._lock:
if not force and not self._dirty:
return False
try:
merged = self._merge_with_disk()
os.makedirs(os.path.dirname(self._stats_file_path), exist_ok=True)
temp_path = f"{self._stats_file_path}.tmp"
with open(temp_path, "w", encoding="utf-8") as file_obj:
json.dump(merged, file_obj, indent=2)
os.replace(temp_path, self._stats_file_path)
self._opened = merged
self._file_mtime = os.path.getmtime(self._stats_file_path)
self._dirty = False
return True
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error saving recipe open stats: %s", exc, exc_info=True)
return False
def _merge_with_disk(self) -> dict[str, float]:
"""Merge the in-memory map with the current on-disk map."""
disk: dict[str, float] = {}
try:
if os.path.exists(self._stats_file_path):
with open(self._stats_file_path, "r", encoding="utf-8") as file_obj:
raw = json.load(file_obj)
if isinstance(raw, dict):
disk = {
str(key): float(value)
for key, value in raw.items()
if isinstance(value, (int, float))
}
except Exception as exc: # pragma: no cover - defensive logging path
logger.error("Error reading recipe open stats for merge: %s", exc)
merged = dict(disk)
for key, value in self._opened.items():
merged[key] = max(value, disk.get(key, 0.0))
return merged
+36
View File
@@ -323,6 +323,42 @@ def model_patcher_to_name(model_patcher: Any) -> Optional[str]:
return _abs_model_path_to_name(abs_path)
def sampler_object_to_name(sampler: Any) -> Optional[str]:
"""Extract a ComfyUI-style sampler name from a SAMPLER (KSAMPLER) object.
Standard outputs (KSamplerSelect, most built-in sampler nodes) round-trip
losslessly via the underlying sampler function's ``__name__``
(``sample_euler`` -> ``euler``). A few edge cases need special-casing
because the function name diverges from the ``SAMPLER_NAMES`` entry:
- ``dpm_fast`` / ``dpm_adaptive`` are local closures inside
``comfy.samplers.ksampler`` (``dpm_fast_function`` / ``dpm_adaptive_function``)
- ``uni_pc`` / ``uni_pc_bh2`` use ``sample_unipc`` / ``sample_unipc_bh2``
``ddim`` is constructed by ComfyUI as ``euler`` with random inpaint, so
the original ``ddim`` name is unrecoverable (extracts as ``euler``).
Custom sampler nodes that pass non-``sample_*`` functions return None.
Returns None when the name cannot be recovered.
"""
sampler_function = getattr(sampler, "sampler_function", None)
func_name = getattr(sampler_function, "__name__", None)
if not isinstance(func_name, str) or not func_name:
return None
if func_name == "dpm_fast_function":
return "dpm_fast"
if func_name == "dpm_adaptive_function":
return "dpm_adaptive"
if func_name.startswith("sample_"):
name = func_name[len("sample_"):]
if name == "unipc":
return "uni_pc"
if name == "unipc_bh2":
return "uni_pc_bh2"
return name or None
return None
def _abs_model_path_to_name(abs_path: str) -> str:
"""Convert an absolute model path to a ComfyUI-style relative name.
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-lora-manager"
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
version = "1.2.0"
version = "1.2.1"
license = {file = "LICENSE"}
dependencies = [
"aiohttp",
+5
View File
@@ -41,6 +41,11 @@
border-color: var(--lora-accent);
}
.model-card.drag-over {
outline: 2px dashed var(--lora-accent);
outline-offset: -2px;
}
.model-card:focus-visible {
outline: 2px solid var(--lora-accent);
outline-offset: 2px;
@@ -447,6 +447,19 @@
border-color: color-mix(in oklch, #F59F00 45%, transparent);
}
/* Paid badge - violet tone (#845EF7) to distinguish from early-access amber */
.version-badge-paid {
background: color-mix(in oklch, #845EF7 25%, transparent);
color: #7048E8;
border-color: color-mix(in oklch, #845EF7 55%, transparent);
}
[data-theme="dark"] .version-badge-paid {
background: color-mix(in oklch, #845EF7 20%, transparent);
color: #9775FA;
border-color: color-mix(in oklch, #845EF7 45%, transparent);
}
.version-meta-ea {
color: #E67700;
font-weight: 600;
@@ -911,6 +911,93 @@
outline: none;
}
/* Recipes layout segmented control with visual previews */
.layout-options-control {
width: 100%;
display: flex;
justify-content: flex-end;
}
.layout-options {
display: flex;
gap: 6px;
width: 100%;
}
.layout-option {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px;
border-radius: var(--border-radius-sm);
border: 1px solid var(--border-color);
background-color: var(--lora-surface);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.layout-option:hover,
.layout-option:focus-visible {
border-color: var(--lora-accent);
outline: none;
}
.layout-option.active {
border-color: var(--lora-accent);
background-color: rgba(from var(--lora-accent) r g b / 0.12);
color: var(--lora-accent);
}
.layout-option-label {
font-size: 0.85em;
white-space: nowrap;
}
.layout-option-preview {
width: 72px;
height: 44px;
padding: 4px;
border-radius: var(--border-radius-xs);
background-color: var(--card-bg);
border: 1px solid var(--border-color);
box-sizing: border-box;
}
.layout-option-preview span {
background: currentColor;
opacity: 0.4;
border-radius: 1px;
}
.layout-preview-grid {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 3px;
}
.layout-preview-masonry {
display: flex;
gap: 3px;
align-items: flex-start;
}
.layout-preview-masonry span {
flex: 1;
height: 100%;
}
.layout-preview-masonry span:nth-child(2) {
height: 60%;
}
.layout-preview-masonry span:nth-child(3) {
height: 80%;
}
/* Range Slider Control */
.range-control {
width: 100%;
+18
View File
@@ -107,6 +107,24 @@
white-space: nowrap;
}
.toast-close-btn {
flex-shrink: 0;
padding: 0 4px;
background: transparent;
color: var(--text-color);
border: none;
border-radius: 4px;
font-size: 1.1em;
line-height: 1;
opacity: 0.5;
cursor: pointer;
transition: opacity 0.2s ease;
}
.toast-close-btn:hover {
opacity: 1;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.toast {
+28
View File
@@ -168,6 +168,34 @@
border-color: var(--lora-accent);
}
/* Recipes layout toggle (grid / masonry) — segmented control in the toolbar */
.layout-toggle-group {
display: flex;
gap: 0;
}
.layout-toggle-group .layout-toggle-btn {
min-width: 36px;
width: 36px;
padding: 4px 0;
border-radius: 0;
}
.layout-toggle-group .layout-toggle-btn:first-child {
border-radius: var(--border-radius-xs) 0 0 var(--border-radius-xs);
border-right: none;
}
.layout-toggle-group .layout-toggle-btn:last-child {
border-radius: 0 var(--border-radius-xs) var(--border-radius-xs) 0;
}
.layout-toggle-group .layout-toggle-btn:hover,
.layout-toggle-group .layout-toggle-btn:focus-visible {
transform: none;
box-shadow: var(--shadow-xs);
}
/* Keyboard shortcut indicator styling */
.shortcut-key {
display: inline-flex;
+3 -2
View File
@@ -203,7 +203,7 @@ export class BaseModelApiClient {
}
const batchId = data.batch_id || null;
if (!batchId) {
// Not staged (undo disabled or staging failed): keep the legacy toast.
// Not staged (staging failed): keep the legacy toast.
// When staged, the caller shows the undo action toast instead.
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
@@ -1233,7 +1233,7 @@ export class BaseModelApiClient {
}
}
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null) {
async downloadModel(modelId, versionId, modelRoot, relativePath, useDefaultPaths = false, downloadId, source = null, fileParams = null, useSaveDirAsRoot = false) {
try {
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
method: 'POST',
@@ -1244,6 +1244,7 @@ export class BaseModelApiClient {
model_root: modelRoot,
relative_path: relativePath,
use_default_paths: useDefaultPaths,
use_save_dir_as_root: useSaveDirAsRoot,
download_id: downloadId,
...(source ? { source } : {}),
...(fileParams ? { file_params: fileParams } : {})
@@ -1,6 +1,7 @@
import { BaseContextMenu } from './BaseContextMenu.js';
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
import { isModelWeightFile } from '../../utils/modelFileTypes.js';
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js';
@@ -255,7 +256,7 @@ export class RecipeContextMenu extends BaseContextMenu {
loras: validLoras.map(lora => {
const civitaiInfo = lora.civitaiInfo;
const modelFile = civitaiInfo.files ?
civitaiInfo.files.find(file => file.type === 'Model') : null;
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
return {
// Basic lora info
@@ -1,7 +1,6 @@
// Duplicates Manager Component
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { RecipeCard } from './RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js';
@@ -449,7 +448,6 @@ export class DuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('duplicateDeleteModal');
armDeleteButton(document.getElementById('duplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
@@ -1,7 +1,6 @@
// Model Duplicates Manager Component for LoRAs and Checkpoints
import { showToast, showActionToast } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { translate } from '../utils/i18nHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { formatDate } from '../utils/formatters.js';
@@ -703,7 +702,6 @@ export class ModelDuplicatesManager {
// Use the modal manager to show the confirmation modal
modalManager.showModal('modelDuplicateDeleteModal');
armDeleteButton(document.getElementById('modelDuplicateDeleteModal'));
} catch (error) {
console.error('Error preparing delete:', error);
showToast('toast.duplicates.deleteError', { message: error.message }, 'error');
+1 -9
View File
@@ -9,7 +9,6 @@ import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
import { translate } from '../utils/i18nHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
class RecipeCard {
constructor(recipe, clickHandler) {
@@ -366,7 +365,7 @@ class RecipeCard {
</div>
<div class="delete-info">
<h3>${this.recipe.title}</h3>
<p>This action cannot be undone.</p>
<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>
@@ -378,13 +377,8 @@ class RecipeCard {
`;
// Show the modal with custom content and setup callbacks
let deleteArmTimer = null;
modalManager.showModal('deleteModal', deleteModalContent, () => {
// This is the onClose callback
if (deleteArmTimer) {
clearTimeout(deleteArmTimer);
deleteArmTimer = null;
}
const deleteModal = document.getElementById('deleteModal');
const deleteBtn = deleteModal.querySelector('.delete-btn');
deleteBtn.textContent = 'Delete';
@@ -404,8 +398,6 @@ class RecipeCard {
cancelBtn.onclick = () => modalManager.closeModal('deleteModal');
deleteBtn.onclick = () => this.confirmDeleteRecipe();
deleteArmTimer = armDeleteButton(deleteModal);
} catch (error) {
console.error('Error showing delete confirmation:', error);
showToast('toast.recipes.deleteConfirmationError', {}, 'error');
+10 -1
View File
@@ -1,5 +1,6 @@
// Recipe Modal Component
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { translate } from '../utils/i18nHelpers.js';
import { state } from '../state/index.js';
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
@@ -305,6 +306,14 @@ class RecipeModal {
modalManager.showModal('recipeModal');
if (this.recipeId) {
// Fire-and-forget: record this open for the "Recently Opened"
// sort. Tracking must never disturb the modal, so failures are
// swallowed.
fetch(`/api/lm/recipe/${encodeURIComponent(this.recipeId)}/opened`, {
method: 'POST',
keepalive: true,
}).catch(() => {});
const hydrationRequestId = ++this.recipeHydrationRequestId;
const requestEditVersions = this.captureLocalEditVersions();
this.hydrateRecipeDetails(
@@ -1412,7 +1421,7 @@ class RecipeModal {
loras: validLoras.map(lora => {
const civitaiInfo = lora.civitaiInfo;
const modelFile = civitaiInfo.files ?
civitaiInfo.files.find(file => file.type === 'Model') : null;
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
return {
// Basic lora info
+9 -47
View File
@@ -4,7 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
import { sidebarManager } from '../SidebarManager.js';
import { initSortDropdown } from './SortDropdown.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './SortDropdown.js';
/**
* PageControls class - Unified control management for model pages
@@ -108,20 +108,20 @@ export class PageControls {
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
initSortDropdown(sortSelect);
this.applySortToSelect(this.pageState.sortBy);
applySortToSelect(this.pageState.sortBy);
sortSelect.addEventListener('change', async (e) => {
let value = e.target.value;
if (value.startsWith('random')) {
// Every pick of Random reshuffles the list: generate a
// fresh seed so the backend keeps a stable order across
// paginated requests.
value = this._randomizeSortValue();
value = randomizeSortValue();
}
this.pageState.sortBy = value;
this.saveSortPreference(value);
// Reset the seeded Random option when switching away from
// Random, or re-apply the fresh seed when picking it again.
this.applySortToSelect(value);
applySortToSelect(value);
await this.resetAndReload();
});
}
@@ -322,44 +322,6 @@ export class PageControls {
}
}
/**
* Apply a sort value to the native sort <select>, keeping the Random
* option's value in sync when the persisted value carries a seed
* (e.g. "random:abc123"). Must be used instead of assigning
* sortSelect.value directly whenever the value may be a seeded random
* sort, otherwise the native select has no matching option.
* @param {string} sortValue - Sort value like "name:asc" or "random:<seed>"
*/
applySortToSelect(sortValue) {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
}
sortSelect.value = sortValue;
}
/**
* Generate a fresh seeded random sort value ("random:<seed>") and keep
* the native <select> in sync so its value matches the persisted sort
* string and the dropdown shows the selected label.
* @returns {string} The new sort value, e.g. "random:abc123xyz"
*/
_randomizeSortValue() {
const seed = Math.random().toString(36).slice(2, 12);
const value = `random:${seed}`;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = value;
}
sortSelect.value = value;
}
return value;
}
/**
* Load sort preference from storage
*/
@@ -374,7 +336,7 @@ export class PageControls {
// Handle legacy format conversion
const convertedSort = this.convertLegacySortFormat(savedSort);
this.pageState.sortBy = convertedSort;
this.applySortToSelect(convertedSort);
applySortToSelect(convertedSort);
}
}
@@ -568,7 +530,7 @@ export class PageControls {
this.pageState.sortBy = restoredSort;
this.saveSortPreference(restoredSort);
this._removeVlmSortOption();
this.applySortToSelect(restoredSort);
applySortToSelect(restoredSort);
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
sortSelect.disabled = false;
@@ -620,7 +582,7 @@ export class PageControls {
const savedGroupedSort = getStorageItem(groupedKey);
if (savedGroupedSort) {
this.pageState.sortBy = savedGroupedSort;
this.applySortToSelect(savedGroupedSort);
applySortToSelect(savedGroupedSort);
}
} else {
// Leaving group mode: persist current sort for next time, restore non-group sort
@@ -628,7 +590,7 @@ export class PageControls {
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
if (savedNormalSort) {
this.pageState.sortBy = savedNormalSort;
this.applySortToSelect(savedNormalSort);
applySortToSelect(savedNormalSort);
}
}
}
@@ -913,7 +875,7 @@ export class PageControls {
}
if (sortSelect) {
this.applySortToSelect(this.pageState.sortBy);
applySortToSelect(this.pageState.sortBy);
}
if (searchInput) {
searchInput.value = this.pageState.filters?.search || '';
@@ -18,6 +18,44 @@
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
/**
* Apply a sort value to the page's native sort <select>, keeping the Random
* option's value in sync when the persisted value carries a seed
* (e.g. "random:abc123"). Must be used instead of assigning
* sortSelect.value directly whenever the value may be a seeded random
* sort, otherwise the native select has no matching option.
* @param {string} sortValue - Sort value like "name:asc" or "random:<seed>"
*/
export function applySortToSelect(sortValue) {
const sortSelect = document.getElementById('sortSelect');
if (!sortSelect) return;
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = String(sortValue).startsWith('random') ? sortValue : 'random';
}
sortSelect.value = sortValue;
}
/**
* Generate a fresh seeded random sort value ("random:<seed>") and keep the
* native <select> in sync so its value matches the persisted sort string and
* the dropdown shows the selected label.
* @returns {string} The new sort value, e.g. "random:abc123xyz"
*/
export function randomizeSortValue() {
const seed = Math.random().toString(36).slice(2, 12);
const value = `random:${seed}`;
const sortSelect = document.getElementById('sortSelect');
if (sortSelect) {
const randomOpt = sortSelect.querySelector('option[value="random"], option[value^="random:"]');
if (randomOpt) {
randomOpt.value = value;
}
sortSelect.value = value;
}
return value;
}
/**
* Initialize a decoupled sort dropdown around a native <select>.
* Idempotent: safe to call more than once on the same element.
+40
View File
@@ -741,6 +741,46 @@ export function createModelCard(model, modelType) {
configureModelCardVideo(videoElement, autoplayOnHover);
}
// Dropping an image/video onto the card replaces the model preview via the
// existing replace-preview endpoint (overwrites file on disk, refreshes card).
const preventDragDefaults = (event) => {
event.preventDefault();
event.stopPropagation();
};
['dragenter', 'dragover'].forEach((eventName) => {
card.addEventListener(eventName, (event) => {
preventDragDefaults(event);
card.classList.add('drag-over');
});
});
card.addEventListener('dragleave', (event) => {
preventDragDefaults(event);
card.classList.remove('drag-over');
});
card.addEventListener('drop', (event) => {
preventDragDefaults(event);
card.classList.remove('drag-over');
const files = event.dataTransfer?.files;
if (!files || files.length === 0) return;
const file = files[0];
// Keep in sync with the accept list of the preview file picker (image/* + video/mp4).
if (!file.type.startsWith('image/') && file.type !== 'video/mp4') {
showToast('toast.api.previewDropInvalid', { name: file.name || '' }, 'error');
return;
}
const filePath = card.dataset.filepath;
if (!filePath) return;
// uploadPreview handles loading state, card refresh and error toasts internally.
getModelApiClient().uploadPreview(filePath, file);
});
return card;
}
@@ -182,6 +182,10 @@ function isEarlyAccessActive(version) {
}
}
function isPaidPermanent(version) {
return version && version.isPaid === true;
}
function isDownloadAllowed(version) {
if (!version.usageControl) {
return true;
@@ -342,6 +346,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
const strategy = state?.global?.settings?.version_grouping;
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
const hidePaid = state?.global?.settings?.hide_paid_updates;
if (!sameBaseMode) {
return Boolean(record?.hasUpdate);
@@ -388,6 +393,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
if (hideEarlyAccess && isEarlyAccessActive(version)) {
return false;
}
if (hidePaid && isPaidPermanent(version)) {
return false;
}
if (!isDownloadAllowed(version)) {
return false;
}
@@ -469,6 +477,7 @@ function renderRow(version, options) {
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
const paidBadgeLabel = translate('modals.model.versions.badges.paid', {}, 'Paid');
const ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
@@ -522,6 +531,16 @@ function renderRow(version, options) {
}));
}
if (isPaidPermanent(version)) {
badges.push(buildBadge(paidBadgeLabel, 'paid', {
title: translate(
'modals.model.versions.badges.paidTooltip',
{},
'This version requires payment to download'
),
}));
}
if (!isDownloadAllowed(version)) {
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
@@ -564,6 +583,12 @@ function renderRow(version, options) {
{},
'This version is only available for on-site generation on Civitai'
);
} else if (isPaidPermanent(version)) {
downloadTitle = translate(
'modals.model.versions.actions.downloadPaidTooltip',
{},
'Download this paid version from Civitai'
);
} else if (isEarlyAccess) {
downloadTitle = translate(
'modals.model.versions.actions.downloadEarlyAccessTooltip',
@@ -1307,15 +1332,41 @@ export function initVersionsTab({
});
}
async function resolveDownloadPathFromCurrentVersion() {
function getCurrentInLibraryVersion() {
if (!normalizedCurrentVersionId || !controller.record?.versions) {
return null;
}
const currentVersion = controller.record.versions.find(
return controller.record.versions.find(
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
);
if (!currentVersion?.filePath) {
) || null;
}
function getDownloadPathTemplate() {
try {
const singularType = modelType.replace(/s$/, '');
const templates = state.global?.settings?.download_path_templates;
return (templates && templates[singularType]) || '';
} catch (error) {
return '';
}
}
function shouldResolveTemplatePath(targetVersion, pathInfo) {
if (!getDownloadPathTemplate() || !pathInfo?.modelRoot) {
return false;
}
const currentVersion = getCurrentInLibraryVersion();
const currentBase = normalizeBaseModelName(currentVersion?.baseModel);
const targetBase = normalizeBaseModelName(targetVersion?.baseModel);
if (!currentBase || !targetBase || currentBase === targetBase) {
return false;
}
return true;
}
async function resolveDownloadPathFromCurrentVersion() {
const currentVersion = getCurrentInLibraryVersion();
if (!currentVersion) {
return null;
}
@@ -1372,10 +1423,13 @@ export function initVersionsTab({
try {
const pathInfo = await resolveDownloadPathFromCurrentVersion();
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
versionName: version.name || `#${version.versionId}`,
modelRoot: pathInfo?.modelRoot || '',
targetFolder: pathInfo?.targetFolder || '',
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
useDefaultPaths: resolveTemplatePath ? true : null,
useSaveDirAsRoot: resolveTemplatePath,
});
if (success) {
-2
View File
@@ -1,7 +1,6 @@
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { armDeleteButton } from '../utils/modalUtils.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
@@ -630,7 +629,6 @@ export class BulkManager {
}
modalManager.showModal('bulkDeleteModal');
armDeleteButton(document.getElementById('bulkDeleteModal'));
}
async confirmBulkDelete() {
+13 -9
View File
@@ -3,6 +3,7 @@ import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
import { state } from '../state/index.js';
import { LoadingManager } from './LoadingManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { isModelWeightFile } from '../utils/modelFileTypes.js';
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { translate } from '../utils/i18nHelpers.js';
@@ -557,8 +558,7 @@ export class DownloadManager {
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
// Count model-type files per version
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
const fileSize = version.modelSizeKB ?
(version.modelSizeKB / 1024).toFixed(2) :
@@ -685,7 +685,7 @@ export class DownloadManager {
if (!version) return;
this.currentVersion = version;
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
document.getElementById('versionStep').style.display = 'none';
document.getElementById('fileSelectionStep').style.display = 'block';
@@ -747,7 +747,7 @@ export class DownloadManager {
return;
}
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
@@ -912,6 +912,7 @@ export class DownloadManager {
modelRoot = '',
targetFolder = '',
useDefaultPaths = false,
useSaveDirAsRoot = false,
source = null,
fileParams = null,
closeModal = false,
@@ -923,7 +924,7 @@ export class DownloadManager {
}
const displayName = versionName || `#${versionId}`;
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, source, fileParams, closeModal: false };
const retryParams = { modelId, versionId, versionName, modelRoot, targetFolder, useDefaultPaths, useSaveDirAsRoot, source, fileParams, closeModal: false };
let ws = null;
let updateProgress = () => { };
let cancelled = false;
@@ -995,7 +996,8 @@ export class DownloadManager {
useDefaultPaths,
downloadId,
source,
fileParams
fileParams,
useSaveDirAsRoot
);
if (cancelled) {
@@ -1809,7 +1811,9 @@ export class DownloadManager {
versionName = '',
source = null,
modelRoot = '',
targetFolder = ''
targetFolder = '',
useDefaultPaths = null,
useSaveDirAsRoot = false
} = {}) {
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
@@ -1824,14 +1828,14 @@ export class DownloadManager {
this.modelId = modelId ? modelId.toString() : null;
this.source = source;
const useDefaultPaths = !modelRoot;
return this.executeDownloadWithProgress({
modelId,
versionId,
versionName,
modelRoot: modelRoot || '',
targetFolder: targetFolder || '',
useDefaultPaths,
useDefaultPaths: useDefaultPaths ?? !modelRoot,
useSaveDirAsRoot,
source,
closeModal: false,
});
+55 -18
View File
@@ -1017,11 +1017,8 @@ export class SettingsManager {
displayDensitySelect.value = state.global.settings.display_density || 'default';
}
// Set recipes layout setting
const recipesLayoutSelect = document.getElementById('recipesLayout');
if (recipesLayoutSelect) {
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
}
// Set recipes layout setting (segmented control active state)
this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid');
// Set card info display setting
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
@@ -1064,6 +1061,12 @@ export class SettingsManager {
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
}
// Set hide paid updates setting
const hidePaidUpdatesCheckbox = document.getElementById('hidePaidUpdates');
if (hidePaidUpdatesCheckbox) {
hidePaidUpdatesCheckbox.checked = state.global.settings.hide_paid_updates || false;
}
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
@@ -1111,12 +1114,6 @@ export class SettingsManager {
includeTriggerWordsCheckbox.checked = state.global.settings.include_trigger_words || false;
}
// Set delete undo setting (defaults to enabled)
const deleteUndoEnabledCheckbox = document.getElementById('deleteUndoEnabled');
if (deleteUndoEnabledCheckbox) {
deleteUndoEnabledCheckbox.checked = state.global.settings.delete_undo_enabled ?? true;
}
// Set lora syntax format
const loraSyntaxFormatSelect = document.getElementById('loraSyntaxFormat');
if (loraSyntaxFormatSelect) {
@@ -2294,19 +2291,18 @@ export class SettingsManager {
: element.value;
try {
// Recipes layout has its own shared entry point used by both the
// settings modal segmented control and the recipes page toolbar toggle
if (settingKey === 'recipes_layout') {
return this.saveRecipesLayout(element.value);
}
// Update frontend state with mapped keys
await this.saveSetting(settingKey, value);
// Apply frontend settings immediately
this.applyFrontendSettings();
// Dispatch layout change event; the scroller instance is about to be rebuilt,
// so calculateLayout() must NOT run on the old instance here
if (settingKey === 'recipes_layout') {
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
return;
}
// Recalculate layout when display density changes
if (settingKey === 'display_density' && state.virtualScroller) {
state.virtualScroller.calculateLayout();
@@ -2334,6 +2330,47 @@ export class SettingsManager {
}
}
/**
* Save the recipes page layout (grid | masonry) and rebuild the scroller.
* Shared entry point for the settings modal segmented control and the
* recipes page toolbar toggle; both stay in sync via
* updateRecipesLayoutControls().
*/
async saveRecipesLayout(value) {
if (value !== 'grid' && value !== 'masonry') {
return;
}
// Update frontend state with mapped keys
await this.saveSetting('recipes_layout', value);
// Apply frontend settings immediately
this.applyFrontendSettings();
// Dispatch layout change event; the scroller instance is about to be rebuilt,
// so calculateLayout() must NOT run on the old instance here
window.dispatchEvent(new CustomEvent('lm:recipes-layout-changed'));
this.updateRecipesLayoutControls(value);
}
/**
* Sync the active state of every recipes layout control
* (settings modal segmented control and recipes page toolbar toggle).
*/
updateRecipesLayoutControls(value) {
document.querySelectorAll('[data-recipes-layout]').forEach((control) => {
const active = control.dataset.recipesLayout === value;
control.classList.toggle('active', active);
if (control.hasAttribute('aria-pressed')) {
control.setAttribute('aria-pressed', String(active));
}
if (control.hasAttribute('aria-checked')) {
control.setAttribute('aria-checked', String(active));
}
});
}
async saveRangeSetting(elementId, displayId, settingKey) {
const element = document.getElementById(elementId);
if (!element) return;
+38 -4
View File
@@ -10,7 +10,7 @@ import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
import { initSortDropdown } from './components/controls/SortDropdown.js';
import { initSortDropdown, applySortToSelect, randomizeSortValue } from './components/controls/SortDropdown.js';
class RecipePageControls {
constructor() {
@@ -245,10 +245,20 @@ class RecipeManager {
this.pageState.sortBy = savedSort;
}
initSortDropdown(sortSelect);
sortSelect.value = this.pageState.sortBy || 'date:desc';
applySortToSelect(this.pageState.sortBy || 'date:desc');
sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value;
setStorageItem('recipes_sort', sortSelect.value);
let value = sortSelect.value;
if (value.startsWith('random')) {
// Every pick of Random reshuffles the list: generate a
// fresh seed so the backend keeps a stable order across
// paginated requests.
value = randomizeSortValue();
}
this.pageState.sortBy = value;
setStorageItem('recipes_sort', value);
// Reset the seeded Random option when switching away from
// Random, or re-apply the fresh seed when picking it again.
applySortToSelect(value);
refreshVirtualScroll();
});
}
@@ -272,6 +282,30 @@ class RecipeManager {
});
}
// Layout toggle (grid / masonry) — shares the recipes_layout setting with
// the settings modal segmented control; active states stay in sync via
// settingsManager.updateRecipesLayoutControls() after each save
const layoutToggleBtns = document.querySelectorAll('.layout-toggle-btn');
if (layoutToggleBtns.length) {
const currentLayout = state.global.settings?.recipes_layout || 'grid';
layoutToggleBtns.forEach((btn) => {
const isActive = btn.dataset.recipesLayout === currentLayout;
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-pressed', String(isActive));
btn.addEventListener('click', async () => {
const layout = btn.dataset.recipesLayout;
if ((state.global.settings?.recipes_layout || 'grid') === layout) {
return;
}
try {
await window.settingsManager?.saveRecipesLayout(layout);
} catch (error) {
console.error('Failed to switch recipes layout:', error);
}
});
});
}
// Rebuild the scroller on layout switch; in duplicates mode defer until
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
// the old instance while initializeVirtualScroll skips duplicates mode)
+1 -1
View File
@@ -49,6 +49,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
version_grouping: 'same_base',
hide_early_access_updates: false,
hide_paid_updates: false,
auto_organize_exclusions: [],
metadata_refresh_skip_paths: [],
skip_previously_downloaded_model_versions: false,
@@ -58,7 +59,6 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
strip_lora_on_copy: false,
use_new_license_icons: true,
group_by_model: false,
delete_undo_enabled: true,
llm_provider: 'openai',
llm_api_key: '',
llm_api_base: '',
+11 -4
View File
@@ -646,10 +646,17 @@ export class MasonryScroller {
const pageType = state.currentPageType;
if (pageType === 'recipes') {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
if (String(getCurrentPageState().sortBy).startsWith('opened')) {
placeholderText = `
<p>No recently opened recipes</p>
<p>Recipes you open will appear here.</p>
`;
} else {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
}
} else if (pageType === 'loras') {
placeholderText = `
<p>No LoRAs found</p>
+11 -4
View File
@@ -699,10 +699,17 @@ export class VirtualScroller {
const pageType = state.currentPageType;
if (pageType === 'recipes') {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
if (String(getCurrentPageState().sortBy).startsWith('opened')) {
placeholderText = `
<p>No recently opened recipes</p>
<p>Recipes you open will appear here.</p>
`;
} else {
placeholderText = `
<p>No recipes found</p>
<p>Add recipe images to your recipes folder to see them here.</p>
`;
}
} else if (pageType === 'loras') {
placeholderText = `
<p>No LoRAs found</p>
+1 -30
View File
@@ -3,31 +3,11 @@ import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { showActionToast } from './uiHelpers.js';
import { translate } from './i18nHelpers.js';
import { handleUndoDelete } from './undoHelpers.js';
import { state } from '../state/index.js';
import { formatFileSize } from '../components/shared/utils.js';
const DELETE_BUTTON_ARM_DELAY_MS = 1500;
let pendingDeletePath = null;
let pendingDeleteName = null;
let pendingExcludePath = null;
let pendingDeleteArmTimer = null;
// Delay-activates every delete button inside a delete-confirmation modal so a
// misclick in the first moments after opening cannot confirm the deletion.
// Returns the pending timeout id so callers can cancel it when the modal closes early.
export function armDeleteButton(modalElement, delayMs = DELETE_BUTTON_ARM_DELAY_MS) {
if (!modalElement) return null;
const deleteButtons = modalElement.querySelectorAll('.delete-btn');
if (!deleteButtons.length) return null;
deleteButtons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
deleteButtons.forEach((button) => { button.disabled = false; });
}, delayMs);
}
export function showDeleteModal(filePath) {
pendingDeletePath = filePath;
@@ -41,10 +21,6 @@ export function showDeleteModal(filePath) {
const modal = modalManager.getModal('deleteModal').element;
const modelInfo = modal.querySelector('.delete-model-info');
const undoEnabled = state.global?.settings?.delete_undo_enabled;
const warningKey = undoEnabled
? 'modals.deleteModel.recoverableWarning'
: 'modals.deleteModel.permanentWarning';
const fileSize = card?.dataset.file_size;
const sizeLine = fileSize
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
@@ -55,11 +31,10 @@ export function showDeleteModal(filePath) {
<br>
<strong>File:</strong> ${filePath}
<br>
${translate(warningKey)}${sizeLine}
${translate('modals.deleteModel.recoverableWarning')}${sizeLine}
`;
modalManager.showModal('deleteModal');
pendingDeleteArmTimer = armDeleteButton(modal);
}
export async function confirmDelete() {
@@ -90,10 +65,6 @@ export async function confirmDelete() {
export function closeDeleteModal() {
modalManager.closeModal('deleteModal');
if (pendingDeleteArmTimer) {
clearTimeout(pendingDeleteArmTimer);
pendingDeleteArmTimer = null;
}
pendingDeletePath = null;
pendingDeleteName = null;
}
+15
View File
@@ -0,0 +1,15 @@
// CivitAI ModelFile.type values eligible as the main download file.
// Mirrors the backend constant MODEL_WEIGHT_FILE_TYPES (py/utils/constants.py).
// Keep both lists in sync when CivitAI introduces new file types.
export const MODEL_WEIGHT_FILE_TYPES = [
'Model',
'Pruned Model',
'Negative',
'UNet',
'Diffusion Model',
'Enhancement LoRA',
];
export function isModelWeightFile(type) {
return MODEL_WEIGHT_FILE_TYPES.includes(type);
}
+39 -4
View File
@@ -236,11 +236,11 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
* @param {Object} [options]
* @param {string} [options.actionText] - Label for the action button (button omitted when empty)
* @param {Function} [options.onAction] - Callback invoked at most once on button click
* @param {number} [options.durationMs=30000] - How long the toast stays visible
* @param {number} [options.durationMs=20000] - How long the toast stays visible
* @param {boolean} [options.countdown=true] - Show a ticking `(N)s` countdown
*/
export function showActionToast(key, params = {}, type = 'info', options = {}) {
const { actionText, onAction, durationMs = 30000, countdown = true } = options;
const { actionText, onAction, durationMs = 20000, countdown = true } = options;
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
const message = isPlainMessage ? key : translate(key, params);
@@ -295,6 +295,20 @@ export function showActionToast(key, params = {}, type = 'info', options = {}) {
}
}, 1000);
}
// Manual close button: hides the toast early without firing onAction. The
// backend undo window keeps running and the batch is purged when it expires.
const closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.className = 'toast-close-btn';
closeBtn.textContent = '×';
closeBtn.setAttribute('aria-label', translate('common.actions.close'));
closeBtn.addEventListener('click', (event) => {
event.preventDefault();
clearCountdown();
dismiss();
});
toast.append(closeBtn);
}
export function restoreFolderFilter() {
@@ -1092,6 +1106,9 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
if (!isNodeEnabled(node)) {
return false;
}
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1100,7 +1117,15 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast('uiHelpers.workflow.noMatchingNodes', {}, 'warning');
showToast(
translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
),
{},
'warning'
);
return false;
}
@@ -1152,6 +1177,11 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
if (!isNodeEnabled(node)) {
return false;
}
// A node whose text widget is backed by a connected input cannot have its
// text changed via the widget — execution reads the linked input.
if (node.capabilities?.text_widget_connected === true) {
return false;
}
return (
node.capabilities?.has_text_widget === true ||
node.marker_role === "send_prompt_target"
@@ -1160,7 +1190,12 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
const nodeKeys = Object.keys(textNodes);
if (nodeKeys.length === 0) {
showToast(options.missingNodesMessage || 'uiHelpers.workflow.noMatchingNodes', {}, 'warning');
const defaultHint = translate(
'uiHelpers.workflow.noPromptTargets',
{},
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
showToast(options.missingNodesMessage || defaultHint, {}, 'warning');
return false;
}
+18 -5
View File
@@ -48,17 +48,20 @@
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
</optgroup>
{% endif %}
{% if page_id != 'recipes' %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
</optgroup>
{% endif %}
{% if page_id == 'recipes' %}
<optgroup label="{{ t('recipes.controls.sort.opened', default='Recently Opened') }}">
<option value="opened:desc">{{ t('recipes.controls.sort.openedDesc', default='Recently opened') }}</option>
</optgroup>
{% endif %}
<optgroup label="{{ t('loras.controls.sort.random', default='Random') }}">
<option value="random">{{ t('loras.controls.sort.randomAction', default='Randomize (shuffle)') }}</option>
</optgroup>
</select>
</div>
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
@@ -131,6 +134,16 @@
</div>
<div class="controls-right">
{% if page_id == 'recipes' %}
<div class="control-group layout-toggle-group" role="group" aria-label="{{ t('recipes.controls.layout.title') }}" title="{{ t('recipes.controls.layout.title') }}">
<button type="button" class="layout-toggle-btn" data-recipes-layout="grid" aria-pressed="false" title="{{ t('recipes.controls.layout.grid') }}" aria-label="{{ t('recipes.controls.layout.grid') }}">
<i class="fas fa-th-large" aria-hidden="true"></i>
</button>
<button type="button" class="layout-toggle-btn" data-recipes-layout="masonry" aria-pressed="false" title="{{ t('recipes.controls.layout.masonry') }}" aria-label="{{ t('recipes.controls.layout.masonry') }}">
<i class="fas fa-columns" aria-hidden="true"></i>
</button>
</div>
{% endif %}
<div class="control-group doctor-control-group">
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
<i class="fas fa-stethoscope"></i>
+30 -22
View File
@@ -629,16 +629,22 @@
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="recipesLayout">
<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 select-control">
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
</select>
<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>
@@ -811,22 +817,6 @@
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="deleteUndoEnabled">
{{ t('settings.deleteUndoEnabled') }}
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="deleteUndoEnabled"
onchange="settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
</div>
@@ -1279,6 +1269,24 @@
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="hidePaidUpdates">
{{ t('settings.hidePaidUpdates.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.hidePaidUpdates.help') }}"></i>
</label>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input type="checkbox" id="hidePaidUpdates"
onchange="settingsManager.saveToggleSetting('hidePaidUpdates', 'hide_paid_updates')">
<span class="toggle-slider"></span>
</label>
</div>
</div>
</div>
</div>
<!-- Example Images -->
@@ -1667,7 +1667,7 @@ describe('AutoComplete widget interactions', () => {
expect(input.value).toBe('looking_to_the_side,');
});
it('shows /af command for loras when active-filters autocomplete is off (default)', async () => {
it('shows /activefilters command for loras when active-filters autocomplete is off (default)', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
@@ -1682,8 +1682,6 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).not.toContain('/noaf');
expect(commandNames).toContain('/activefilters');
expect(commandNames).not.toContain('/noactivefilters');
});
@@ -1710,11 +1708,11 @@ describe('AutoComplete widget interactions', () => {
await Promise.resolve();
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/af');
expect(commandNames).toContain('/activefilters');
expect(previewTooltipMock.show).not.toHaveBeenCalled();
});
it('shows /noaf command for loras when active-filters autocomplete is on', async () => {
it('shows /noactivefilters command for loras when active-filters autocomplete is on', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.lora_active_filters_autocomplete') {
return true;
@@ -1736,8 +1734,6 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
const commandNames = autoComplete.items.map((item) => item.command);
expect(commandNames).toContain('/noaf');
expect(commandNames).not.toContain('/af');
expect(commandNames).toContain('/noactivefilters');
expect(commandNames).not.toContain('/activefilters');
});
@@ -1766,7 +1762,7 @@ describe('AutoComplete widget interactions', () => {
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
});
it('toggles the active-filters setting when /af is accepted', async () => {
it('toggles the active-filters setting when /activefilters is accepted', async () => {
const input = document.createElement('textarea');
input.value = '/';
input.selectionStart = input.value.length;
@@ -1782,7 +1778,7 @@ describe('AutoComplete widget interactions', () => {
input.dispatchEvent(new Event('input', { bubbles: true }));
const afItem = autoComplete.items.find((item) => item.command === '/af');
const afItem = autoComplete.items.find((item) => item.command === '/activefilters');
expect(afItem).toBeDefined();
// Simulate the input being cleared after the command is accepted so the
@@ -123,7 +123,6 @@ vi.mock('../../../static/js/state/index.js', () => ({
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
showExcludeModal: vi.fn(),
showDeleteModal: vi.fn(),
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/managers/MoveManager.js', () => ({
@@ -27,16 +27,7 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
armDeleteButton: (modalElement) => {
if (!modalElement) return null;
const buttons = modalElement.querySelectorAll('.delete-btn');
buttons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
buttons.forEach((button) => { button.disabled = false; });
}, 1500);
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
recreateVirtualScroll: recreateVirtualScrollMock,
@@ -340,41 +331,3 @@ describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
);
});
});
describe('DuplicatesManager deleteSelectedDuplicates delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
setCurrentPageType('recipes');
setupDom();
document.body.insertAdjacentHTML('beforeend', `
<div id="duplicateDeleteModal" class="modal delete-modal">
<div class="delete-model-info"><p><span id="duplicateDeleteCount">0</span></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`);
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.modalManager;
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const manager = new DuplicatesManager({});
manager.selectedForDeletion.add('r1');
await manager.deleteSelectedDuplicates();
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('duplicateDeleteModal');
const deleteBtn = document.querySelector('#duplicateDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,182 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
MODEL_CARD_MODULE,
STATE_MODULE,
UI_HELPERS_MODULE,
I18N_MODULE,
API_CONFIG_MODULE,
API_FACTORY_MODULE,
} = vi.hoisted(() => ({
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
}));
const showToastMock = vi.fn();
const uploadPreviewMock = vi.fn();
vi.mock(STATE_MODULE, () => ({
state: {
settings: {
blur_mature_content: false,
model_name_display: 'model_name',
},
global: {
settings: {
model_name_display: 'model_name',
group_by_model: false,
display_density: 'default',
model_card_footer_action: 'replace_preview',
},
},
pages: {
loras: {
previewVersions: new Map(),
sortBy: 'name',
},
},
bulkMode: false,
selectedLoras: new Set(),
},
getCurrentPageState: vi.fn(() => ({
sortBy: 'name',
previewVersions: new Map(),
})),
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: showToastMock,
openCivitai: vi.fn(),
openHuggingFace: vi.fn(),
copyToClipboard: vi.fn(),
copyLoraSyntax: vi.fn(),
sendLoraToWorkflow: vi.fn(),
sendEmbeddingToWorkflow: vi.fn(),
openExampleImagesFolder: vi.fn(),
buildLoraSyntax: vi.fn(),
sendModelPathToWorkflow: vi.fn(),
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(API_CONFIG_MODULE, () => ({
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(() => ({ uploadPreview: uploadPreviewMock })),
}));
describe('ModelCard drag & drop preview upload', () => {
let createModelCard;
beforeEach(async () => {
showToastMock.mockReset();
uploadPreviewMock.mockReset();
({ createModelCard } = await import(MODEL_CARD_MODULE));
});
function createCard() {
const model = {
sha256: 'abc123',
file_path: '/models/test_lora.safetensors',
model_name: 'Test LoRA',
file_name: 'test_lora',
folder: 'models',
modified: 1234567890,
file_size: 1024,
usage_count: 0,
notes: '',
base_model: 'SD1.5',
favorite: false,
exclude: false,
hf_url: '',
update_available: false,
skip_metadata_refresh: false,
preview_url: '',
preview_nsfw_level: 0,
tags: [],
civitai: {},
sub_type: 'lora',
};
return createModelCard(model, 'loras');
}
function dispatchDrop(card, files) {
const event = new Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'dataTransfer', { value: { files } });
card.dispatchEvent(event);
return event;
}
it('uploads the dropped image as the model preview', () => {
const card = createCard();
const file = new File(['data'], 'preview.png', { type: 'image/png' });
dispatchDrop(card, [file]);
expect(uploadPreviewMock).toHaveBeenCalledTimes(1);
expect(uploadPreviewMock).toHaveBeenCalledWith('/models/test_lora.safetensors', file);
});
it('supports MP4 video files', () => {
const card = createCard();
const file = new File(['data'], 'preview.mp4', { type: 'video/mp4' });
dispatchDrop(card, [file]);
expect(uploadPreviewMock).toHaveBeenCalledTimes(1);
});
it('rejects unsupported file types with a toast', () => {
const card = createCard();
const file = new File(['data'], 'notes.txt', { type: 'text/plain' });
dispatchDrop(card, [file]);
expect(uploadPreviewMock).not.toHaveBeenCalled();
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.previewDropInvalid',
{ name: 'notes.txt' },
'error'
);
});
it('ignores drops without files', () => {
const card = createCard();
dispatchDrop(card, []);
expect(uploadPreviewMock).not.toHaveBeenCalled();
});
it('prevents browser default and highlights the card while dragging over', () => {
const card = createCard();
const dragOverEvent = new Event('dragover', { bubbles: true, cancelable: true });
card.dispatchEvent(dragOverEvent);
expect(dragOverEvent.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(true);
const dragLeaveEvent = new Event('dragleave', { bubbles: true, cancelable: true });
card.dispatchEvent(dragLeaveEvent);
expect(dragLeaveEvent.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(false);
});
it('clears the highlight when the drop completes', () => {
const card = createCard();
const file = new File(['data'], 'preview.png', { type: 'image/png' });
const event = dispatchDrop(card, [file]);
expect(event.defaultPrevented).toBe(true);
expect(card.classList.contains('drag-over')).toBe(false);
});
});
@@ -18,16 +18,7 @@ vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: resetAndReloadMock,
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({
armDeleteButton: (modalElement) => {
if (!modalElement) return null;
const buttons = modalElement.querySelectorAll('.delete-btn');
buttons.forEach((button) => { button.disabled = true; });
return setTimeout(() => {
buttons.forEach((button) => { button.disabled = false; });
}, 1500);
},
}));
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
const { state } = await import('../../../static/js/state/index.js');
@@ -365,36 +356,3 @@ describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
);
});
});
describe('ModelDuplicatesManager deleteSelectedDuplicates delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.modalManager;
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const manager = await createManager();
document.body.insertAdjacentHTML('beforeend', `
<div id="modelDuplicateDeleteModal" class="modal delete-modal">
<div class="delete-model-info"><p><span id="modelDuplicateDeleteCount">0</span></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`);
manager.selectedForDeletion.add(carPath);
await manager.deleteSelectedDuplicates();
expect(globalThis.modalManager.showModal).toHaveBeenCalledWith('modelDuplicateDeleteModal');
const deleteBtn = document.querySelector('#modelDuplicateDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,210 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
MODEL_VERSIONS_MODULE,
API_FACTORY_MODULE,
DOWNLOAD_MANAGER_MODULE,
UI_HELPERS_MODULE,
STATE_MODULE,
I18N_HELPERS_MODULE,
UTILS_MODULE,
} = vi.hoisted(() => ({
MODEL_VERSIONS_MODULE: new URL('../../../static/js/components/shared/ModelVersionsTab.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
DOWNLOAD_MANAGER_MODULE: new URL('../../../static/js/managers/DownloadManager.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
I18N_HELPERS_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UTILS_MODULE: new URL('../../../static/js/components/shared/utils.js', import.meta.url).pathname,
}));
const downloadVersionWithDefaults = vi.fn();
vi.mock(DOWNLOAD_MANAGER_MODULE, () => ({
downloadManager: {
downloadVersionWithDefaults,
},
}));
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
openCivitaiUrl: vi.fn(),
}));
const stateMock = {
global: {
settings: {
autoplay_on_hover: false,
version_grouping: 'any',
download_path_templates: {
lora: '{base_model}/{first_tag}',
checkpoint: '{base_model}/{first_tag}',
embedding: '{base_model}/{first_tag}',
},
},
},
};
vi.mock(STATE_MODULE, () => ({
state: stateMock,
}));
vi.mock(I18N_HELPERS_MODULE, () => ({
translate: vi.fn((_, __, fallback) => fallback ?? ''),
}));
vi.mock(UTILS_MODULE, () => ({
formatFileSize: vi.fn(() => '1 MB'),
}));
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
}));
const LORA_ROOT = '/models/loras';
function buildRecord(targetBaseModel = 'Anima') {
return {
success: true,
record: {
shouldIgnore: false,
inLibraryVersionIds: [10],
versions: [
{
versionId: 10,
name: 'v1.0',
baseModel: 'Illustrious',
sizeBytes: 1024,
isInLibrary: true,
shouldIgnore: false,
filePath: `${LORA_ROOT}/Illustrious/works/file.safetensors`,
},
{
versionId: 11,
name: 'v1.1',
baseModel: targetBaseModel,
sizeBytes: 2048,
isInLibrary: false,
shouldIgnore: false,
},
],
},
};
}
async function renderAndClickDownload({ currentVersionId = 10, record = null } = {}) {
const { initVersionsTab } = await import(MODEL_VERSIONS_MODULE);
const controller = initVersionsTab({
modalId: 'model-versions-modal',
modelType: 'loras',
modelId: 123,
currentVersionId,
});
await controller.load();
const downloadButton = document.querySelector(
'.model-version-row[data-version-id="11"] [data-version-action="download"]'
);
downloadButton?.click();
await new Promise(resolve => setTimeout(resolve, 0));
return controller;
}
describe('ModelVersionsTab update download path resolution', () => {
let getModelApiClient;
let fetchModelUpdateVersions;
let fetchModelRoots;
beforeEach(async () => {
vi.resetModules();
downloadVersionWithDefaults.mockReset();
downloadVersionWithDefaults.mockResolvedValue(true);
document.body.innerHTML = `
<div id="model-versions-modal">
<div id="versions-tab">
<div class="model-versions-tab"></div>
</div>
</div>
`;
stateMock.global.settings.version_grouping = 'any';
stateMock.global.settings.download_path_templates.lora = '{base_model}/{first_tag}';
({ getModelApiClient } = await import(API_FACTORY_MODULE));
fetchModelUpdateVersions = vi.fn();
fetchModelRoots = vi.fn();
fetchModelRoots.mockResolvedValue({ roots: [LORA_ROOT] });
getModelApiClient.mockReturnValue({
fetchModelUpdateVersions,
fetchModelRoots,
setModelUpdateIgnore: vi.fn(),
setVersionUpdateIgnore: vi.fn(),
deleteModel: vi.fn(),
});
});
afterEach(() => {
document.body.innerHTML = '';
});
it('keeps the current folder when the target version has the same base model', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord('Illustrious'));
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: 'Illustrious/works',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
it('resolves the template path when the target base model differs and a template is configured', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: '',
useDefaultPaths: true,
useSaveDirAsRoot: true,
})
);
});
it('keeps the current folder when the target base model differs but no template is configured', async () => {
stateMock.global.settings.download_path_templates.lora = '';
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload();
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: LORA_ROOT,
targetFolder: 'Illustrious/works',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
it('falls back to default paths when no local version exists', async () => {
fetchModelUpdateVersions.mockResolvedValue(buildRecord());
await renderAndClickDownload({ currentVersionId: null });
expect(downloadVersionWithDefaults).toHaveBeenCalledWith(
'loras', 123, 11,
expect.objectContaining({
modelRoot: '',
targetFolder: '',
useDefaultPaths: null,
useSaveDirAsRoot: false,
})
);
});
});
@@ -1,4 +1,5 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { applySortToSelect } from '../../../static/js/components/controls/SortDropdown.js';
const resetAndReloadMock = vi.fn();
const getModelApiClientMock = vi.fn();
@@ -190,7 +191,7 @@ describe('Random sort option', () => {
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
controls.applySortToSelect('name:desc');
applySortToSelect('name:desc');
expect(sortSelect.value).toBe('name:desc');
expect(randomOpt.value).toBe('random');
@@ -1,160 +0,0 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const {
RECIPE_CARD_MODULE,
UI_HELPERS_MODULE,
RECIPE_API_MODULE,
MODEL_CARD_MODULE,
MODAL_MANAGER_MODULE,
BULK_MANAGER_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
API_FACTORY_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
RECIPE_CARD_MODULE: new URL('../../../static/js/components/RecipeCard.js', import.meta.url).pathname,
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
RECIPE_API_MODULE: new URL('../../../static/js/api/recipeApi.js', import.meta.url).pathname,
MODEL_CARD_MODULE: new URL('../../../static/js/components/shared/ModelCard.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
const showModalMock = vi.fn();
const closeModalMock = vi.fn();
vi.mock(UI_HELPERS_MODULE, () => ({
showToast: vi.fn(),
showActionToast: vi.fn(),
copyToClipboard: vi.fn(),
sendLoraToWorkflow: vi.fn(),
}));
vi.mock(RECIPE_API_MODULE, () => ({
updateRecipeMetadata: vi.fn(),
}));
vi.mock(MODEL_CARD_MODULE, () => ({
configureModelCardVideo: vi.fn(),
}));
vi.mock(MODAL_MANAGER_MODULE, () => ({
modalManager: {
showModal: showModalMock,
closeModal: closeModalMock,
},
}));
vi.mock(BULK_MANAGER_MODULE, () => ({
bulkManager: {},
}));
vi.mock(I18N_MODULE, () => ({
translate: vi.fn((key) => key),
}));
vi.mock(UNDO_HELPERS_MODULE, () => ({
handleUndoDelete: vi.fn(),
}));
// modalUtils.js is intentionally NOT mocked — its real armDeleteButton drives
// the delay-activate behavior under test. Its own imports are mocked below.
vi.mock(API_FACTORY_MODULE, () => ({
getModelApiClient: vi.fn(),
resetAndReload: vi.fn(),
}));
describe('RecipeCard delete confirmation delay-activate', () => {
let capturedOnClose;
beforeEach(async () => {
vi.useFakeTimers();
showModalMock.mockReset();
closeModalMock.mockReset();
capturedOnClose = null;
document.body.innerHTML = '<div id="deleteModal" class="modal delete-modal"></div>';
showModalMock.mockImplementation((id, content, onClose) => {
if (content) {
document.getElementById(id).innerHTML = content;
}
capturedOnClose = onClose;
});
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
window.recipeManager = { loadRecipes: vi.fn() };
const { state } = await import(STATE_MODULE);
state.virtualScroller = { removeItemByFilePath: vi.fn() };
});
afterEach(() => {
vi.useRealTimers();
delete global.fetch;
delete window.recipeManager;
document.body.innerHTML = '';
});
async function createCard() {
const { RecipeCard } = await import(RECIPE_CARD_MODULE);
const card = Object.create(RecipeCard.prototype);
card.recipe = { id: 'recipe-1', title: 'My Recipe', file_path: '/recipes/r1.json', file_url: '/preview.png' };
return card;
}
it('opens with a disabled delete button that ignores clicks until 1500ms elapse', async () => {
const card = await createCard();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(global.fetch).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
deleteBtn.click();
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipe/recipe-1',
expect.objectContaining({ method: 'DELETE' })
);
});
it('clears the pending arm timer when the modal closes during the countdown', async () => {
const card = await createCard();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(700);
capturedOnClose();
expect(deleteBtn.disabled).toBe(false);
expect(vi.getTimerCount()).toBe(0);
});
it('re-arms a full 1500ms countdown when the modal is reopened', async () => {
const card = await createCard();
card.showDeleteConfirmation();
vi.advanceTimersByTime(1400);
capturedOnClose();
card.showDeleteConfirmation();
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -0,0 +1,246 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
const { APP_MODULE, API_MODULE, STYLES_MODULE, REGISTRY_MODULE, appMock, apiMock, registeredExtensions } =
vi.hoisted(() => {
const registeredExtensions = [];
const appMock = {
graph: null,
registerExtension: (ext) => registeredExtensions.push(ext),
};
const apiMock = {
clientId: "client-1",
initialClientId: null,
addEventListener: vi.fn(),
};
return {
APP_MODULE: new URL("../../../scripts/app.js", import.meta.url).pathname,
API_MODULE: new URL("../../../scripts/api.js", import.meta.url).pathname,
STYLES_MODULE: new URL("../../../web/comfyui/lm_styles_loader.js", import.meta.url).pathname,
REGISTRY_MODULE: new URL("../../../web/comfyui/workflow_registry.js", import.meta.url).pathname,
appMock,
apiMock,
registeredExtensions,
};
});
vi.mock(APP_MODULE, () => ({ app: appMock }));
vi.mock(API_MODULE, () => ({ api: apiMock }));
vi.mock(STYLES_MODULE, () => ({ ensureLmStyles: vi.fn() }));
function createTextEncodeNode({ linked = false, id = 1 } = {}) {
const textWidget = { name: "text", type: "customtext", value: "old prompt", callback: null };
return {
id,
comfyClass: "CLIPTextEncode",
title: "CLIP Text Encode",
mode: 0,
properties: {},
widgets: [textWidget, { name: "clip", type: "combo" }],
widgets_values: ["old prompt", "clip-1"],
inputs: [
{ name: "text", type: "STRING", widget: textWidget, link: linked ? 101 : null },
{ name: "clip", type: "CLIP", link: null },
],
setDirtyCanvas: vi.fn(),
graph: null,
};
}
function createSubgraph({ id = "sub-1", nodes = [] } = {}) {
const graph = {
id,
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function createGraph({ nodes = [], subgraphs = [] } = {}) {
const graph = {
id: "root",
_nodes: nodes,
_subgraphs: new Map(),
getNodeById: vi.fn((nodeId) => nodes.find((n) => n.id === nodeId) ?? null),
events: { addEventListener: vi.fn() },
};
for (const subgraph of subgraphs) {
graph._subgraphs.set(subgraph.id, subgraph);
}
for (const node of nodes) {
node.graph = graph;
}
return graph;
}
function lastRegisterPayload(fetchMock) {
const calls = fetchMock.mock.calls.filter(
([url]) => url === "/api/lm/register-nodes"
);
expect(calls.length).toBeGreaterThan(0);
return JSON.parse(calls[calls.length - 1][1].body);
}
describe("LoraManager.WorkflowRegistry", () => {
let extension;
let fetchMock;
beforeEach(async () => {
vi.resetModules();
registeredExtensions.length = 0;
appMock.graph = null;
apiMock.addEventListener.mockClear();
fetchMock = vi.fn().mockResolvedValue({ ok: true });
global.fetch = fetchMock;
await import(REGISTRY_MODULE);
extension = registeredExtensions.find(
(ext) => ext.name === "LoraManager.WorkflowRegistry"
);
expect(extension).toBeDefined();
});
afterEach(() => {
delete global.fetch;
});
describe("refreshRegistry", () => {
it("registers an unconnected CLIPTextEncode as a text target", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode()] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(true);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(false);
});
it("excludes a CLIPTextEncode whose text input is connected", async () => {
appMock.graph = createGraph({ nodes: [createTextEncodeNode({ linked: true })] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].capabilities.has_text_widget).toBe(false);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("registers connection state for nodes inside subgraphs", async () => {
const inner = createTextEncodeNode({ linked: true, id: 7 });
const subgraph = createSubgraph({ id: "sub-1", nodes: [inner] });
appMock.graph = createGraph({ subgraphs: [subgraph] });
await extension.refreshRegistry(true);
const body = lastRegisterPayload(fetchMock);
expect(body.nodes).toHaveLength(1);
expect(body.nodes[0].graph_id).toBe("sub-1");
expect(body.nodes[0].node_id).toBe(7);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
it("re-registers when text_widget_connected changes (fingerprint)", async () => {
const node = createTextEncodeNode();
appMock.graph = createGraph({ nodes: [node] });
await extension.refreshRegistry(true);
await extension.refreshRegistry();
expect(
fetchMock.mock.calls.filter(([url]) => url === "/api/lm/register-nodes")
).toHaveLength(1);
node.inputs[0].link = 101;
await extension.refreshRegistry();
const body = lastRegisterPayload(fetchMock);
expect(body.nodes[0].capabilities.text_widget_connected).toBe(true);
});
});
describe("applyWidgetUpdate (inject_text)", () => {
it("updates the widget value when the text input is not connected", async () => {
const node = createTextEncodeNode();
const callback = vi.fn();
node.widgets[0].callback = callback;
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
await extension.applyWidgetUpdate({
node_id: 1,
action: "inject_text",
value: "hello",
mode: "replace",
});
expect(node.widgets[0].value).toBe("hello");
expect(node.widgets_values[0]).toBe("hello");
expect(callback).toHaveBeenCalledWith("hello");
});
it("skips inject_text when the target widget is connected and self-heals the registry", async () => {
const node = createTextEncodeNode({ linked: true });
appMock.graph = createGraph({ nodes: [node] });
extension.flashWidget = vi.fn();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
await extension.applyWidgetUpdate({
node_id: 1,
graph_id: "root",
action: "inject_text",
value: "new prompt",
mode: "replace",
});
expect(node.widgets[0].value).toBe("old prompt");
expect(node.widgets_values[0]).toBe("old prompt");
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("connected to an input"),
expect.anything(),
expect.anything()
);
await vi.waitFor(() => {
expect(
fetchMock.mock.calls.some(([url]) => url === "/api/lm/register-nodes")
).toBe(true);
});
warnSpy.mockRestore();
});
});
describe("setup link-change hooks", () => {
it("hooks root events, existing subgraphs, and future subgraphs", () => {
const subgraph = createSubgraph({ id: "sub-1", nodes: [] });
const graph = createGraph({ subgraphs: [subgraph] });
appMock.graph = graph;
extension.setup();
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
expect(graph.events.addEventListener).toHaveBeenCalledWith(
"subgraph-created",
expect.any(Function)
);
expect(subgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
const createdHandler = graph.events.addEventListener.mock.calls.find(
([name]) => name === "subgraph-created"
)[1];
const laterSubgraph = createSubgraph({ id: "sub-2", nodes: [] });
createdHandler({ subgraph: laterSubgraph });
expect(laterSubgraph.events.addEventListener).toHaveBeenCalledWith(
"node:slot-links:changed",
expect.any(Function)
);
});
});
});
@@ -332,41 +332,3 @@ describe('BulkManager.confirmBulkDelete undo flows', () => {
expect(resetAndReloadMock).not.toHaveBeenCalled();
});
});
describe('BulkManager.showBulkDeleteModal delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
stateStub.currentPageType = 'loras';
stateStub.selectedModels.clear();
stateStub.selectedModels.add('/models/a.safetensors');
document.body.innerHTML = `
<div id="bulkDeleteModal" class="modal delete-modal">
<h2></h2>
<p class="delete-message"></p>
<div class="delete-model-info"><p></p></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(() => {
vi.useRealTimers();
document.body.innerHTML = '';
});
it('opens with the delete button disabled and enables it after 1500ms', async () => {
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
const bulk = new BulkManager();
bulk.showBulkDeleteModal();
const deleteBtn = document.querySelector('#bulkDeleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
deleteBtn.click();
expect(bulkDeleteModelsMock).not.toHaveBeenCalled();
vi.advanceTimersByTime(1500);
expect(deleteBtn.disabled).toBe(false);
});
});
@@ -1,228 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
modalManager: {
closeModal: vi.fn(),
},
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
showToast: vi.fn(),
}));
vi.mock('../../../static/js/state/index.js', () => {
const settings = {};
return {
state: {
global: {
settings,
},
loadingManager: {
showSimpleLoading: vi.fn(),
hide: vi.fn(),
},
},
createDefaultSettings: () => ({
language: 'en',
delete_undo_enabled: true,
}),
};
});
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
resetAndReload: vi.fn(),
}));
vi.mock('../../../static/js/utils/constants.js', () => ({
DOWNLOAD_PATH_TEMPLATES: {},
DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {},
DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style',
checkpoint: 'base, guide',
embedding: 'hint',
},
getMappableBaseModelsDynamic: () => [],
}));
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
translate: (_key, _params, fallback) => fallback ?? '',
}));
vi.mock('../../../static/js/i18n/index.js', () => ({
i18n: {
getCurrentLocale: () => 'en',
setLanguage: vi.fn().mockResolvedValue(),
},
}));
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
configureModelCardVideo: vi.fn(),
}));
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
import { showToast } from '../../../static/js/utils/uiHelpers.js';
import { state } from '../../../static/js/state/index.js';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const createManager = () => {
state.global.settings = {};
const initSettingsSpy = vi
.spyOn(SettingsManager.prototype, 'initializeSettings')
.mockResolvedValue();
const initializeSpy = vi
.spyOn(SettingsManager.prototype, 'initialize')
.mockImplementation(() => {});
const manager = new SettingsManager();
initSettingsSpy.mockRestore();
initializeSpy.mockRestore();
return manager;
};
const appendDeleteUndoCheckbox = () => {
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'deleteUndoEnabled';
document.body.appendChild(checkbox);
return checkbox;
};
const stubLoadSettingsSubloaders = (manager) => {
vi.spyOn(manager, 'loadMetadataArchiveSettings').mockResolvedValue();
vi.spyOn(manager, 'loadBackupSettings').mockResolvedValue();
vi.spyOn(manager, 'loadLibraries').mockResolvedValue();
vi.spyOn(manager, 'loadLoraRoots').mockResolvedValue();
vi.spyOn(manager, 'loadCheckpointRoots').mockResolvedValue();
vi.spyOn(manager, 'loadUnetRoots').mockResolvedValue();
vi.spyOn(manager, 'loadEmbeddingRoots').mockResolvedValue();
};
beforeEach(() => {
document.body.innerHTML = '';
vi.clearAllMocks();
});
afterEach(() => {
delete global.fetch;
});
describe('SettingsManager delete undo toggle', () => {
it('renders the checkbox markup with a resolvable i18n label', () => {
const template = readFileSync(
resolve(repoRoot, 'templates/components/modals/settings_modal.html'),
'utf8',
);
const locales = JSON.parse(
readFileSync(resolve(repoRoot, 'locales/en.json'), 'utf8'),
);
// The label must resolve to real text, not fall back to the raw key.
expect(locales.settings.deleteUndoEnabled).toBe(
'Keep deleted items recoverable for 30 seconds (undo)',
);
expect(template).toContain('id="deleteUndoEnabled"');
expect(template).toContain("t('settings.deleteUndoEnabled')");
expect(template).toContain(
"settingsManager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled')",
);
});
it('restores the checkbox as unchecked when the saved setting is false', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
checkbox.checked = true;
stubLoadSettingsSubloaders(manager);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
state.global.settings = { delete_undo_enabled: false };
await manager.loadSettingsToUI();
expect(checkbox.checked).toBe(false);
});
it('restores the checkbox as checked when the saved setting is true or absent', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
stubLoadSettingsSubloaders(manager);
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
state.global.settings = { delete_undo_enabled: true };
await manager.loadSettingsToUI();
expect(checkbox.checked).toBe(true);
checkbox.checked = false;
state.global.settings = {};
await manager.loadSettingsToUI();
expect(checkbox.checked).toBe(true);
});
it('saves delete_undo_enabled to the backend when the checkbox is toggled', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
checkbox.checked = false;
state.global.settings = { delete_undo_enabled: true };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
expect(state.global.settings.delete_undo_enabled).toBe(false);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ delete_undo_enabled: false }),
});
expect(showToast).toHaveBeenCalledWith(
'toast.settings.settingsUpdated',
{ setting: 'delete undo enabled' },
'success',
);
});
it('saves delete_undo_enabled as true when re-enabled', async () => {
const manager = createManager();
const checkbox = appendDeleteUndoCheckbox();
checkbox.checked = true;
state.global.settings = { delete_undo_enabled: false };
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ success: true }),
});
await manager.saveToggleSetting('deleteUndoEnabled', 'delete_undo_enabled');
expect(state.global.settings.delete_undo_enabled).toBe(true);
expect(global.fetch).toHaveBeenCalledWith('/api/lm/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ delete_undo_enabled: true }),
});
});
});
@@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => {
dispatchSpy.mockRestore();
delete state.virtualScroller;
});
it('saveRecipesLayout persists, dispatches the layout event, and syncs controls', async () => {
const manager = createManager();
const gridBtn = document.createElement('button');
gridBtn.dataset.recipesLayout = 'grid';
gridBtn.setAttribute('aria-pressed', 'false');
const masonryBtn = document.createElement('button');
masonryBtn.dataset.recipesLayout = 'masonry';
masonryBtn.setAttribute('aria-pressed', 'false');
masonryBtn.setAttribute('role', 'radio');
masonryBtn.setAttribute('aria-checked', 'false');
document.body.appendChild(gridBtn);
document.body.appendChild(masonryBtn);
const calculateLayout = vi.fn();
state.virtualScroller = { calculateLayout };
const dispatchSpy = vi.spyOn(window, 'dispatchEvent');
await manager.saveRecipesLayout('masonry');
expect(state.global.settings.recipes_layout).toBe('masonry');
expect(masonryBtn.classList.contains('active')).toBe(true);
expect(masonryBtn.getAttribute('aria-pressed')).toBe('true');
expect(masonryBtn.getAttribute('aria-checked')).toBe('true');
expect(gridBtn.classList.contains('active')).toBe(false);
expect(gridBtn.getAttribute('aria-pressed')).toBe('false');
const layoutEvent = dispatchSpy.mock.calls
.map(([event]) => event)
.find(event => event.type === 'lm:recipes-layout-changed');
expect(layoutEvent).toBeInstanceOf(CustomEvent);
expect(calculateLayout).not.toHaveBeenCalled();
expect(showToast).not.toHaveBeenCalled();
dispatchSpy.mockRestore();
delete state.virtualScroller;
});
it('ignores invalid recipes layout values', async () => {
const manager = createManager();
await manager.saveRecipesLayout('bogus');
expect(state.global.settings.recipes_layout).toBeUndefined();
});
});
@@ -34,7 +34,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
@@ -26,7 +26,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/api/apiConfig.js', () => ({
-1
View File
@@ -36,7 +36,6 @@ vi.mock('../../../static/js/utils/modalUtils.js', () => ({
closeDeleteModal: closeDeleteModalMock,
confirmExclude: confirmExcludeMock,
closeExcludeModal: closeExcludeModalMock,
armDeleteButton: vi.fn(),
}));
vi.mock('../../../static/js/components/ModelDuplicatesManager.js', () => ({
@@ -0,0 +1,236 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderRecipesPage } from '../utils/pageFixtures.js';
import { applySortToSelect } from '../../../static/js/components/controls/SortDropdown.js';
const initializeAppMock = vi.fn();
const initializePageFeaturesMock = vi.fn();
const getCurrentPageStateMock = vi.fn();
const getSessionItemMock = vi.fn();
const removeSessionItemMock = vi.fn();
const getStorageItemMock = vi.fn();
const setStorageItemMock = vi.fn();
const removeStorageItemMock = vi.fn();
const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn();
let importManagerInstance;
let recipeModalInstance;
let duplicatesManagerInstance;
const ImportManagerMock = vi.fn(() => importManagerInstance);
const RecipeModalMock = vi.fn(() => recipeModalInstance);
const DuplicatesManagerMock = vi.fn(() => duplicatesManagerInstance);
vi.mock('../../../static/js/core.js', () => ({
appCore: {
initialize: initializeAppMock,
initializePageFeatures: initializePageFeaturesMock,
},
}));
vi.mock('../../../static/js/managers/ImportManager.js', () => ({
ImportManager: ImportManagerMock,
}));
vi.mock('../../../static/js/components/RecipeModal.js', () => ({
RecipeModal: RecipeModalMock,
}));
vi.mock('../../../static/js/state/index.js', () => ({
getCurrentPageState: getCurrentPageStateMock,
state: {
currentPageType: 'recipes',
global: { settings: {} },
virtualScroller: {
removeItemByFilePath: vi.fn(),
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
},
},
}));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
getSessionItem: getSessionItemMock,
removeSessionItem: removeSessionItemMock,
getStorageItem: getStorageItemMock,
setStorageItem: setStorageItemMock,
removeStorageItem: removeStorageItemMock,
}));
vi.mock('../../../static/js/components/ContextMenu/index.js', () => ({
RecipeContextMenu: vi.fn(),
}));
vi.mock('../../../static/js/components/DuplicatesManager.js', () => ({
DuplicatesManager: DuplicatesManagerMock,
}));
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
refreshVirtualScroll: refreshVirtualScrollMock,
recreateVirtualScroll: vi.fn(),
}));
vi.mock('../../../static/js/api/recipeApi.js', () => ({
refreshRecipes: refreshRecipesMock,
RecipeSidebarApiClient: vi.fn(() => ({
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
fetchUnifiedFolderTree: vi.fn().mockResolvedValue({ success: true, tree: {} }),
fetchModelFolders: vi.fn().mockResolvedValue({ success: true, folders: [] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
moveBulkModels: vi.fn(),
moveSingleModel: vi.fn(),
})),
}));
vi.mock('../../../static/js/components/SidebarManager.js', () => ({
sidebarManager: {
setHostPageControls: vi.fn(),
initialize: vi.fn(async () => {}),
refresh: vi.fn(async () => {}),
cleanup: vi.fn(),
},
}));
function renderSortSelect() {
const sortSelectElement = document.createElement('select');
sortSelectElement.id = 'sortSelect';
sortSelectElement.innerHTML = `
<option value="date:desc">Newest</option>
<option value="name:asc">Name A-Z</option>
<option value="random">Randomize (shuffle)</option>
`;
document.body.appendChild(sortSelectElement);
return sortSelectElement;
}
describe('RecipeManager Random sort', () => {
let RecipeManager;
let pageState;
beforeEach(async () => {
vi.resetModules();
vi.clearAllMocks();
importManagerInstance = { showImportModal: vi.fn() };
recipeModalInstance = { showRecipeDetails: vi.fn() };
duplicatesManagerInstance = {
findDuplicates: vi.fn(),
selectLatestDuplicates: vi.fn(),
deleteSelectedDuplicates: vi.fn(),
confirmDeleteDuplicates: vi.fn(),
exitDuplicateMode: vi.fn(),
};
pageState = {
sortBy: 'date:desc',
searchOptions: undefined,
customFilter: undefined,
duplicatesMode: false,
};
getCurrentPageStateMock.mockImplementation(() => pageState);
initializeAppMock.mockResolvedValue(undefined);
initializePageFeaturesMock.mockResolvedValue(undefined);
refreshVirtualScrollMock.mockImplementation(() => {});
refreshRecipesMock.mockResolvedValue('refreshed');
getSessionItemMock.mockImplementation(() => null);
removeSessionItemMock.mockImplementation(() => {});
getStorageItemMock.mockImplementation(() => null);
setStorageItemMock.mockImplementation(() => {});
renderRecipesPage();
});
afterEach(() => {
document.body.innerHTML = '';
delete window.recipeManager;
delete window.importManager;
});
async function createManager() {
({ RecipeManager } = await import('../../../static/js/recipes.js'));
const manager = new RecipeManager();
await manager.initialize();
return manager;
}
it('generates a seeded sort value when Random is picked', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(setStorageItemMock).toHaveBeenCalledWith('recipes_sort', pageState.sortBy);
expect(randomOpt.value).toBe(pageState.sortBy);
expect(sortSelect.value).toBe(pageState.sortBy);
expect(refreshVirtualScrollMock).toHaveBeenCalled();
});
it('reshuffles with a fresh seed every time Random is picked again', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
const firstSeed = pageState.sortBy;
sortSelect.value = randomOpt.value;
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toMatch(/^random:[a-z0-9]+$/);
expect(pageState.sortBy).not.toBe(firstSeed);
});
it('restores a persisted seeded random sort on load', async () => {
const sortSelect = renderSortSelect();
const savedSort = 'random:persistedseed';
getStorageItemMock.mockImplementation((key) =>
key === 'recipes_sort' ? savedSort : null
);
await createManager();
expect(pageState.sortBy).toBe(savedSort);
expect(sortSelect.value).toBe(savedSort);
expect(sortSelect.querySelector('option[value="random:persistedseed"]')).not.toBeNull();
});
it('applies a non-random sort back to the plain random option', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
applySortToSelect('name:asc');
expect(sortSelect.value).toBe('name:asc');
expect(randomOpt.value).toBe('random');
});
it('resets the seeded option when switching away from Random via the change handler', async () => {
const sortSelect = renderSortSelect();
const randomOpt = sortSelect.querySelector('option[value="random"]');
await createManager();
sortSelect.value = 'random';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(randomOpt.value).toMatch(/^random:[a-z0-9]+$/);
sortSelect.value = 'name:asc';
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
await Promise.resolve();
expect(pageState.sortBy).toBe('name:asc');
expect(sortSelect.value).toBe('name:asc');
expect(randomOpt.value).toBe('random');
});
});
+33
View File
@@ -163,6 +163,7 @@ describe('RecipeManager', () => {
afterEach(() => {
delete window.recipeManager;
delete window.importManager;
delete window.settingsManager;
});
it('initializes page controls, restores filters, and wires sort interactions', async () => {
@@ -227,6 +228,38 @@ describe('RecipeManager', () => {
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
});
it('wires the layout toggle and reflects the saved recipes layout setting', async () => {
const gridBtn = document.createElement('button');
gridBtn.className = 'layout-toggle-btn';
gridBtn.dataset.recipesLayout = 'grid';
gridBtn.setAttribute('aria-pressed', 'false');
const masonryBtn = document.createElement('button');
masonryBtn.className = 'layout-toggle-btn';
masonryBtn.dataset.recipesLayout = 'masonry';
masonryBtn.setAttribute('aria-pressed', 'false');
document.body.appendChild(gridBtn);
document.body.appendChild(masonryBtn);
const saveRecipesLayoutMock = vi.fn().mockResolvedValue();
window.settingsManager = { saveRecipesLayout: saveRecipesLayoutMock };
const manager = new RecipeManager();
await manager.initialize();
// Initial state follows the saved setting (default grid)
expect(gridBtn.classList.contains('active')).toBe(true);
expect(gridBtn.getAttribute('aria-pressed')).toBe('true');
expect(masonryBtn.classList.contains('active')).toBe(false);
// Clicking the inactive option saves the new layout
masonryBtn.dispatchEvent(new Event('click', { bubbles: true }));
expect(saveRecipesLayoutMock).toHaveBeenCalledWith('masonry');
// Clicking the already-active option is a no-op
gridBtn.dispatchEvent(new Event('click', { bubbles: true }));
expect(saveRecipesLayoutMock).toHaveBeenCalledTimes(1);
});
it('skips loading when duplicates mode is active and refreshes otherwise', async () => {
const manager = new RecipeManager();
+1 -12
View File
@@ -66,25 +66,14 @@ describe('translate() with real en.json locale', () => {
it('resolves delete-model modal keys with interpolation', () => {
installWindowI18n();
expect(translate('modals.deleteModel.permanentWarning')).toBe(
'This will permanently delete the file from disk.',
);
expect(translate('modals.deleteModel.recoverableWarning')).toBe(
'This will permanently delete the file after 30 seconds unless you undo.',
'This will permanently delete the file after 20 seconds unless you undo.',
);
expect(translate('modals.deleteModel.freesSpace', { size: '1.2 MB' })).toBe(
'Frees 1.2 MB',
);
});
it('resolves the delete-undo settings label', () => {
installWindowI18n();
expect(translate('settings.deleteUndoEnabled')).toBe(
'Keep deleted items recoverable for 30 seconds (undo)',
);
});
it('returns the raw key when no translation exists (fallback contract)', () => {
installWindowI18n();
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
@@ -324,6 +324,18 @@ describe('MasonryScroller', () => {
expect(placeholder.textContent).toContain('No recipes found');
});
it('shows the recently-opened empty placeholder under the opened sort', async () => {
getCurrentPageState().sortBy = 'opened:desc';
const { scroller, grid } = track(createScroller({ items: [] }));
await scroller.initialize();
const placeholder = grid.querySelector('#virtualScrollPlaceholder');
expect(placeholder).not.toBeNull();
expect(placeholder.textContent).toContain('No recently opened recipes');
getCurrentPageState().sortBy = '';
});
it('dispose removes classes, spacer and event listeners', () => {
const { scroller, grid } = track(createScroller());
+2 -102
View File
@@ -1,4 +1,4 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
import { describe, it, beforeEach, expect, vi } from 'vitest';
const {
MODAL_UTILS_MODULE,
@@ -7,7 +7,6 @@ const {
UI_HELPERS_MODULE,
I18N_MODULE,
UNDO_HELPERS_MODULE,
STATE_MODULE,
} = vi.hoisted(() => ({
MODAL_UTILS_MODULE: new URL('../../../static/js/utils/modalUtils.js', import.meta.url).pathname,
MODAL_MANAGER_MODULE: new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname,
@@ -15,7 +14,6 @@ const {
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,
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
}));
const deleteModelMock = vi.fn();
@@ -136,75 +134,6 @@ describe('modalUtils confirmDelete undo flow', () => {
});
});
describe('modalUtils armDeleteButton delay-activate', () => {
beforeEach(() => {
vi.useFakeTimers();
deleteModelMock.mockReset();
showModalMock.mockReset();
closeModalMock.mockReset();
document.body.innerHTML = `
<div class="model-card" data-filepath="/models/foo.safetensors" data-name="Foo Model"></div>
<div id="deleteModal">
<div class="delete-model-info"></div>
<button class="cancel-btn">Cancel</button>
<button class="delete-btn">Delete</button>
</div>
`;
});
afterEach(() => {
vi.useRealTimers();
});
it('opens with the delete button disabled and enables it after exactly 1500ms', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
it('clicking the disabled delete button fires nothing', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
deleteBtn.click();
expect(deleteBtn.disabled).toBe(true);
expect(deleteModelMock).not.toHaveBeenCalled();
});
it('closing during the countdown clears the timer and reopening re-arms a full 1500ms', async () => {
const { showDeleteModal, closeDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
const deleteBtn = document.querySelector('#deleteModal .delete-btn');
vi.advanceTimersByTime(1400);
closeDeleteModal();
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
// Reopen — the stale timer must not enable the button early
showDeleteModal('/models/foo.safetensors');
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1499);
expect(deleteBtn.disabled).toBe(true);
vi.advanceTimersByTime(1);
expect(deleteBtn.disabled).toBe(false);
});
});
describe('modalUtils showDeleteModal warning copy and size line', () => {
beforeEach(() => {
showModalMock.mockReset();
@@ -220,19 +149,11 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
`;
});
afterEach(async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
});
function modelInfoHtml() {
return document.querySelector('#deleteModal .delete-model-info').innerHTML;
}
it('shows the recoverable warning when delete_undo_enabled is truthy', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = true;
it('always shows the recoverable warning', async () => {
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
@@ -240,27 +161,6 @@ describe('modalUtils showDeleteModal warning copy and size line', () => {
expect(modelInfoHtml()).not.toContain('modals.deleteModel.permanentWarning');
});
it('shows the permanent warning when delete_undo_enabled is falsy', async () => {
const { state } = await import(STATE_MODULE);
state.global.settings.delete_undo_enabled = false;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
expect(modelInfoHtml()).not.toContain('modals.deleteModel.recoverableWarning');
});
it('falls back to the neutral permanent warning when the setting is unavailable', async () => {
const { state } = await import(STATE_MODULE);
delete state.global.settings.delete_undo_enabled;
const { showDeleteModal } = await import(MODAL_UTILS_MODULE);
showDeleteModal('/models/foo.safetensors');
expect(modelInfoHtml()).toContain('modals.deleteModel.permanentWarning');
});
it('appends a formatted "Frees {size}" line when the card carries a file size', async () => {
translateMock.mockImplementation((key, params) =>
params && params.size ? `${key} ${params.size}` : key
+263 -3
View File
@@ -133,14 +133,14 @@ describe('UI helper DOM utilities', () => {
const countdown = toast.querySelector('.toast-countdown');
expect(countdown).not.toBeNull();
expect(countdown.textContent).toBe('(30s)');
expect(countdown.textContent).toBe('(20s)');
// Ticking one second updates the countdown text
vi.advanceTimersByTime(1000);
expect(countdown.textContent).toBe('(29s)');
expect(countdown.textContent).toBe('(19s)');
// Drain remaining timers so no state leaks into other tests
vi.advanceTimersByTime(30000);
vi.advanceTimersByTime(20000);
});
it('invokes onAction once and dismisses immediately when the button is clicked', async () => {
@@ -186,6 +186,34 @@ describe('UI helper DOM utilities', () => {
expect(onAction).toHaveBeenCalledTimes(1);
});
it('dismisses the toast via the close button without firing onAction', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
const { showActionToast } = await import(UI_HELPERS_MODULE);
const onAction = vi.fn();
showActionToast('toast.undo.deleted', {}, 'success', {
actionText: 'Undo',
onAction,
});
const toast = document.querySelector('.toast-container .toast');
const countdown = toast.querySelector('.toast-countdown');
toast.querySelector('.toast-close-btn').click();
expect(onAction).not.toHaveBeenCalled();
expect(toast.classList.contains('show')).toBe(false);
// Advancing past the full duration must not tick the countdown further,
// throw, or re-dismiss the already-dismissed toast
vi.advanceTimersByTime(60000);
expect(countdown.textContent).toBe('(20s)');
toast.dispatchEvent(new Event('transitionend', { bubbles: true }));
expect(document.querySelector('.toast-container .toast')).toBeNull();
});
it('dismisses the toast when the countdown reaches zero', async () => {
vi.useFakeTimers();
translateMock.mockReturnValue('Deleted Demo Model');
@@ -315,6 +343,238 @@ describe('UI helper DOM utilities', () => {
]);
});
it('excludes prompt targets whose text widget is connected to an input', async () => {
const registryResponse = {
success: true,
data: {
node_count: 4,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Free Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
'root:3': {
id: 3,
graph_id: 'root',
graph_name: null,
title: 'Marked But Wired',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: true,
widget_names: ['seed'],
},
},
'root:4': {
id: 4,
graph_id: 'root',
graph_name: null,
title: 'Free Text 2',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: false,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Free Text', '#4 Free Text 2']);
});
it('returns false when the only prompt target has its text widget connected', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(false);
expect(document.querySelectorAll('#nodeSelector .node-item').length).toBe(0);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Mark as');
expect(toast.textContent).toContain('Send Prompt Target');
});
it('shows the mark-as hint when no embedding target is available', async () => {
const registryResponse = {
success: true,
data: {
node_count: 1,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Wired Text',
type: 'CLIPTextEncode',
mode: 0,
marker_role: null,
capabilities: {
has_text_widget: true,
text_widget_connected: true,
widget_names: ['text', 'clip'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
translateMock.mockReturnValue(
'No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target'
);
const { sendEmbeddingToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendEmbeddingToWorkflow('embeddingcode');
expect(result).toBe(false);
const toast = document.querySelector('.toast-container .toast');
expect(toast).not.toBeNull();
expect(toast.textContent).toContain('Send Prompt Target');
});
it('keeps unconnected marker targets in the prompt candidate list', async () => {
const registryResponse = {
success: true,
data: {
node_count: 2,
nodes: {
'root:1': {
id: 1,
graph_id: 'root',
graph_name: null,
title: 'Marked Target',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
'root:2': {
id: 2,
graph_id: 'root',
graph_name: null,
title: 'Marked Target 2',
type: 'KSampler',
mode: 0,
marker_role: 'send_prompt_target',
capabilities: {
has_text_widget: false,
text_widget_connected: false,
widget_names: ['seed'],
},
},
},
},
};
global.fetch = vi.fn().mockResolvedValue({
json: async () => registryResponse,
});
document.body.innerHTML = '<div id="nodeSelector"></div>';
const { sendPromptToWorkflow } = await import(UI_HELPERS_MODULE);
const result = await sendPromptToWorkflow('a cat');
expect(result).toBe(true);
const nodeLabels = Array.from(
document.querySelectorAll('#nodeSelector .node-item[data-node-id] span')
).map((span) => span.textContent.trim());
expect(nodeLabels).toEqual(['#1 Marked Target', '#2 Marked Target 2']);
});
it('opens Civitai links using the preferred host and registers the first-use banner once', async () => {
const openSpy = vi.fn();
globalThis.window.open = openSpy;
@@ -471,6 +471,482 @@ def test_conditioning_provenance_recovers_combined_controlnet_prompts(
assert params["negative_prompt"] == "low quality"
def test_conditioning_provenance_recovers_transformed_switched_prompts(
metadata_registry, monkeypatch
):
prompt_graph = {
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "expected positive", "clip": ["clip", 0]},
},
"encode_other_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wrong positive", "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "expected negative", "clip": ["clip", 0]},
},
"encode_other_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "wrong negative", "clip": ["clip", 0]},
},
"enhancer": {
"class_type": "KreaSeedVarianceEnhancer",
"inputs": {"conditioning": ["encode_pos", 0]},
},
"zero_out": {
"class_type": "ConditioningZeroOut",
"inputs": {"conditioning": ["encode_neg", 0]},
},
"positive_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_pos", 0],
"on_true": ["enhancer", 0],
},
},
"negative_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_neg", 0],
"on_true": ["zero_out", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["positive_switch", 0],
"negative": ["negative_switch", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
positive_conditioning = object()
other_positive_conditioning = object()
negative_conditioning = object()
other_negative_conditioning = object()
enhanced_conditioning = object()
zeroed_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-transformed-switch")
metadata_registry.set_current_prompt(prompt)
for node_id, text, conditioning in (
("encode_pos", "expected positive", positive_conditioning),
("encode_other_pos", "wrong positive", other_positive_conditioning),
("encode_neg", "expected negative", negative_conditioning),
("encode_other_neg", "wrong negative", other_negative_conditioning),
):
metadata_registry.record_node_execution(
node_id, "CLIPTextEncode", {"text": text}, None
)
metadata_registry.update_node_execution(
node_id, "CLIPTextEncode", [(conditioning,)]
)
metadata_registry.record_node_execution(
"enhancer",
"KreaSeedVarianceEnhancer",
{"conditioning": positive_conditioning},
None,
return_types=("CONDITIONING", "STRING"),
)
metadata_registry.update_node_execution(
"enhancer",
"KreaSeedVarianceEnhancer",
[(enhanced_conditioning, "diagnostics")],
return_types=("CONDITIONING", "STRING"),
)
metadata_registry.record_node_execution(
"zero_out",
"ConditioningZeroOut",
{"conditioning": negative_conditioning},
None,
return_types=("CONDITIONING",),
)
metadata_registry.update_node_execution(
"zero_out",
"ConditioningZeroOut",
[(zeroed_conditioning,)],
return_types=("CONDITIONING",),
)
metadata_registry.record_node_execution(
"positive_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": other_positive_conditioning,
"on_true": enhanced_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"positive_switch", "ComfySwitchNode", [(enhanced_conditioning,)]
)
metadata_registry.record_node_execution(
"negative_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": other_negative_conditioning,
"on_true": zeroed_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"negative_switch", "ComfySwitchNode", [(zeroed_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": enhanced_conditioning,
"negative": zeroed_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-transformed-switch")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "expected positive"
assert params["negative_prompt"] == "expected negative"
def test_conditioning_provenance_identity_switch_between_encoders(
metadata_registry, monkeypatch
):
"""Lock identity-preserving switches placed directly between encoders.
A switch returns the selected input conditioning verbatim, so provenance
must be recovered through object identity without any transform metadata.
"""
prompt_graph = {
"encode_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "chosen positive", "clip": ["clip", 0]},
},
"encode_other_pos": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "unchosen positive", "clip": ["clip", 0]},
},
"positive_switch": {
"class_type": "ComfySwitchNode",
"inputs": {
"switch": True,
"on_false": ["encode_other_pos", 0],
"on_true": ["encode_pos", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["positive_switch", 0],
"negative": ["encode_other_pos", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
chosen_conditioning = object()
unchosen_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-identity-switch")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "CLIPTextEncode", {"text": "chosen positive"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "CLIPTextEncode", [(chosen_conditioning,)]
)
metadata_registry.record_node_execution(
"encode_other_pos", "CLIPTextEncode", {"text": "unchosen positive"}, None
)
metadata_registry.update_node_execution(
"encode_other_pos", "CLIPTextEncode", [(unchosen_conditioning,)]
)
metadata_registry.record_node_execution(
"positive_switch",
"ComfySwitchNode",
{
"switch": True,
"on_false": unchosen_conditioning,
"on_true": chosen_conditioning,
},
None,
)
metadata_registry.update_node_execution(
"positive_switch", "ComfySwitchNode", [(chosen_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": chosen_conditioning,
"negative": unchosen_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-identity-switch")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "chosen positive"
assert params["negative_prompt"] == "unchosen positive"
def test_conditioning_provenance_ignores_scalar_conditioning_fields(
metadata_registry, monkeypatch
):
"""Scalar fields like ``conditioning_strength`` must not be collected as
conditioning objects for unregistered transform nodes."""
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-scalar-filter")
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
input_conditioning = object()
metadata_registry.record_node_execution(
"strength_node",
"SomeStrengthTransform",
{"conditioning": input_conditioning, "conditioning_strength": 0.8},
None,
return_types=("CONDITIONING",),
)
metadata = metadata_registry.get_metadata("prompt-scalar-filter")
assert metadata[PROMPTS]["strength_node"]["orig_conditionings"] == [
input_conditioning
]
def test_conditioning_provenance_selector_with_conditioning_named_inputs(
metadata_registry, monkeypatch
):
"""An identity selector whose inputs use ``conditioning*`` names must not
leak the unselected branch's prompt."""
prompt_graph = {
"encode_a": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "AAA", "clip": ["clip", 0]},
},
"encode_b": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "BBB", "clip": ["clip", 0]},
},
"selector": {
"class_type": "ConditioningSelector",
"inputs": {
"conditioning_a": ["encode_a", 0],
"conditioning_b": ["encode_b", 0],
},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["selector", 0],
"negative": ["encode_b", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
conditioning_a = object()
conditioning_b = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-selector")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_a", "CLIPTextEncode", {"text": "AAA"}, None
)
metadata_registry.update_node_execution(
"encode_a", "CLIPTextEncode", [(conditioning_a,)]
)
metadata_registry.record_node_execution(
"encode_b", "CLIPTextEncode", {"text": "BBB"}, None
)
metadata_registry.update_node_execution(
"encode_b", "CLIPTextEncode", [(conditioning_b,)]
)
metadata_registry.record_node_execution(
"selector",
"ConditioningSelector",
{"conditioning_a": conditioning_a, "conditioning_b": conditioning_b},
None,
return_types=("CONDITIONING",),
)
metadata_registry.update_node_execution(
"selector", "ConditioningSelector", [(conditioning_a,)],
return_types=("CONDITIONING",),
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": conditioning_a,
"negative": conditioning_b,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-selector")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "AAA"
assert params["negative_prompt"] == "BBB"
def test_conditioning_provenance_uses_conditioning_output_slot(
metadata_registry, monkeypatch
):
"""Unregistered nodes whose CONDITIONING output is not the first slot
must still be tracked through the correct output position.
The graph's conditioning chain ends at an unexecuted phantom node so the
topology fallback in extract_generation_params cannot mask a runtime
provenance failure.
"""
prompt_graph = {
"diag_node": {
"class_type": "DiagThenCond",
"inputs": {"conditioning": ["phantom_source", 0]},
},
"sampler": {
"class_type": "ClownsharKSampler_Beta",
"inputs": {
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": ["diag_node", 1],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
input_conditioning = object()
transformed_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("prompt-output-slot")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "CLIPTextEncode", {"text": "AAA"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "CLIPTextEncode", [(input_conditioning,)]
)
metadata_registry.record_node_execution(
"diag_node",
"DiagThenCond",
{"conditioning": input_conditioning},
None,
return_types=("STRING", "CONDITIONING"),
)
metadata_registry.update_node_execution(
"diag_node",
"DiagThenCond",
[("diagnostics", transformed_conditioning)],
return_types=("STRING", "CONDITIONING"),
)
metadata_registry.record_node_execution(
"sampler",
"ClownsharKSampler_Beta",
{
"seed": 123,
"steps": 8,
"cfg": 1.0,
"sampler_name": "linear/euler",
"scheduler": "beta57",
"denoise": 1.0,
"positive": transformed_conditioning,
"negative": input_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("prompt-output-slot")
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "AAA"
def test_conditioning_provenance_recovers_kj_set_get_prompts(
metadata_registry, monkeypatch
):
@@ -897,6 +1373,97 @@ def test_metadata_overwrite_extractor_empty_inputs(metadata_registry):
metadata_registry.clear_metadata()
def _make_ksampler(func_name: str):
"""Build a duck-typed comfy.samplers.KSAMPLER stub with a named function."""
def _sampler_function(*args, **kwargs):
pass
_sampler_function.__name__ = func_name
return SimpleNamespace(sampler_function=_sampler_function)
def test_metadata_overwrite_extractor_sampler_union(metadata_registry):
"""Wired SAMPLER objects should be converted to sampler names."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler"]
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = _make_ksampler("sample_euler")
MetadataOverwriteExtractor.extract("ow-sampler-1", inputs, None, metadata)
params = metadata[OVERWRITE]["ow-sampler-1"]["parameters"]
assert params["sampler"] == "euler"
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_sampler_union_special_cases(metadata_registry):
"""Sampler functions whose names diverge from SAMPLER_NAMES entries."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler2")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler2"]
cases = [
("dpm_fast_function", "dpm_fast"),
("dpm_adaptive_function", "dpm_adaptive"),
("sample_unipc", "uni_pc"),
("sample_unipc_bh2", "uni_pc_bh2"),
("sample_dpmpp_2m_sde", "dpmpp_2m_sde"),
]
for i, (func_name, expected) in enumerate(cases):
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = _make_ksampler(func_name)
MetadataOverwriteExtractor.extract(f"ow-sampler-{i}", inputs, None, metadata)
params_by_node = {node_id: entry["parameters"] for node_id, entry in metadata[OVERWRITE].items()}
for i, (func_name, expected) in enumerate(cases):
assert params_by_node[f"ow-sampler-{i}"]["sampler"] == expected, func_name
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_sampler_union_unrecognized_skipped(metadata_registry):
"""Unrecognized sampler functions should skip the field, not crash."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler3")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler3"]
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = _make_ksampler("my_custom_sampler_function")
MetadataOverwriteExtractor.extract("ow-sampler-unrec", inputs, None, metadata)
assert not metadata[OVERWRITE]
metadata_registry.clear_metadata()
def test_metadata_overwrite_extractor_sampler_union_no_sampler_function(metadata_registry):
"""Objects without a sampler_function (e.g. old KUNASampler classes) are skipped."""
from py.metadata_collector.constants import CLIP_SKIP_SENTINEL
metadata_registry.start_collection("prompt-ow-sampler4")
metadata = metadata_registry.prompt_metadata["prompt-ow-sampler4"]
inputs: Dict[str, Any] = {key: "" for key in METADATA_OVERWRITE_FIELDS}
inputs.update({"seed": 0, "steps": 0, "cfg_scale": 0.0, "clip_skip": CLIP_SKIP_SENTINEL})
inputs["sampler"] = SimpleNamespace()
MetadataOverwriteExtractor.extract("ow-sampler-nofn", inputs, None, metadata)
assert not metadata[OVERWRITE]
metadata_registry.clear_metadata()
def test_extract_generation_params_applies_overwrite(metadata_registry, populated_registry, monkeypatch):
"""overwrite values should replace inferred params in extract_generation_params."""
import py.metadata_collector.metadata_processor as mp
@@ -1046,3 +1613,213 @@ def test_fill_missing_metadata_fills_overwrite_for_muted_node(metadata_registry)
assert "ow-1" not in metadata.get(OVERWRITE, {})
metadata_registry.clear_metadata()
def test_krea_two_stage_sampler_prompt_and_params_collected(
metadata_registry, monkeypatch
):
"""KreaTwoStageSampler should be recognized as the primary sampler and
contribute the prompt, canonical sampling params, and final resolution."""
prompt_graph = {
"encode_pos": {
"class_type": "PromptLM",
"inputs": {"text": "krea masterpiece", "clip": ["clip", 0]},
},
"encode_neg": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "low quality", "clip": ["clip", 0]},
},
"sampler": {
"class_type": "KreaTwoStageSampler",
"inputs": {
"seed": 42,
"handoff_percent": 16.67,
"stage1_steps": 52,
"stage1_cfg": 4.0,
"stage1_sampler_name": "euler",
"stage1_scheduler": "simple",
"stage2_steps": 12,
"stage2_cfg": 1.0,
"stage2_sampler_name": "euler",
"stage2_scheduler": "simple",
"final_width": 2048,
"final_height": 2048,
"upscale_method": "bislerp",
"positive": ["encode_pos", 0],
"negative": ["encode_neg", 0],
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
},
}
prompt = SimpleNamespace(original_prompt=prompt_graph)
pos_conditioning = object()
neg_conditioning = object()
monkeypatch.setattr(metadata_processor, "standalone_mode", False)
metadata_registry.start_collection("krea-two-stage")
metadata_registry.set_current_prompt(prompt)
metadata_registry.record_node_execution(
"encode_pos", "PromptLM", {"text": "krea masterpiece"}, None
)
metadata_registry.update_node_execution(
"encode_pos", "PromptLM", [(pos_conditioning, "krea masterpiece")]
)
metadata_registry.record_node_execution(
"encode_neg", "CLIPTextEncode", {"text": "low quality"}, None
)
metadata_registry.update_node_execution(
"encode_neg", "CLIPTextEncode", [(neg_conditioning,)]
)
metadata_registry.record_node_execution(
"sampler",
"KreaTwoStageSampler",
{
"seed": 42,
"handoff_percent": 16.67,
"stage1_steps": 52,
"stage1_cfg": 4.0,
"stage1_sampler_name": "euler",
"stage1_scheduler": "simple",
"stage2_steps": 12,
"stage2_cfg": 1.0,
"stage2_sampler_name": "euler",
"stage2_scheduler": "simple",
"final_width": 2048,
"final_height": 2048,
"upscale_method": "bislerp",
"positive": pos_conditioning,
"negative": neg_conditioning,
"latent_image": {
"samples": types.SimpleNamespace(shape=(1, 4, 16, 16))
},
},
None,
)
metadata = metadata_registry.get_metadata("krea-two-stage")
sampler_data = metadata[SAMPLING]["sampler"]
assert sampler_data["is_sampler"] is True
parameters = sampler_data["parameters"]
assert parameters["seed"] == 42
assert parameters["steps"] == 64
assert parameters["cfg"] == 4.0
assert parameters["sampler_name"] == "euler"
assert parameters["scheduler"] == "simple"
assert parameters["stage1_steps"] == 52
assert parameters["stage2_cfg"] == 1.0
assert metadata[SIZE]["sampler"] == {
"width": 2048,
"height": 2048,
"node_id": "sampler",
}
prompt_results = MetadataProcessor.match_conditioning_to_prompts(
metadata, "sampler"
)
assert prompt_results["prompt"] == "krea masterpiece"
assert prompt_results["negative_prompt"] == "low quality"
params = MetadataProcessor.extract_generation_params(metadata)
assert params["prompt"] == "krea masterpiece"
assert params["negative_prompt"] == "low quality"
assert params["seed"] == 42
assert params["steps"] == 64
assert params["cfg_scale"] == 4.0
assert params["sampler"] == "euler"
assert params["scheduler"] == "simple"
assert params["size"] == "2048x2048"
def test_krea_three_stage_sampler_uses_stage1_canonical_fields(metadata_registry):
"""KreaThreeStageSampler reuses stage 1 settings for stage 3, so canonical
fields map from stage 1 and the total counts both sampling stages."""
metadata_registry.start_collection("krea-three-stage")
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
metadata_registry.record_node_execution(
"sampler",
"KreaThreeStageSampler",
{
"seed": 7,
"handoff_percent": 16.67,
"stage3_handoff_percent": 83.33,
"stage1_steps": 52,
"stage1_cfg": 4.0,
"stage1_sampler_name": "euler",
"stage1_scheduler": "simple",
"stage2_steps": 12,
"stage2_cfg": 1.0,
"stage2_sampler_name": "euler",
"stage2_scheduler": "simple",
"final_width": 1024,
"final_height": 2048,
"upscale_method": "bislerp",
"positive": object(),
"negative": object(),
"latent_image": {"samples": types.SimpleNamespace(shape=(1, 4, 8, 16))},
},
None,
)
metadata = metadata_registry.get_metadata("krea-three-stage")
sampler_data = metadata[SAMPLING]["sampler"]
assert sampler_data["is_sampler"] is True
parameters = sampler_data["parameters"]
assert parameters["seed"] == 7
assert parameters["stage3_handoff_percent"] == 83.33
assert parameters["steps"] == 64
assert parameters["cfg"] == 4.0
assert parameters["sampler_name"] == "euler"
assert parameters["scheduler"] == "simple"
# Final resolution takes precedence over the latent dimensions (64x128).
assert metadata[SIZE]["sampler"] == {
"width": 1024,
"height": 2048,
"node_id": "sampler",
}
def test_krea_dual_resolution_selector_extracts_size_from_outputs(
metadata_registry,
):
"""KreaDualResolutionSelector computes dimensions at runtime, so the base
resolution is recorded from its outputs in the update phase."""
metadata_registry.start_collection("krea-selector")
metadata_registry.set_current_prompt(SimpleNamespace(original_prompt={}))
metadata_registry.record_node_execution(
"selector",
"KreaDualResolutionSelector",
{
"aspect_ratio": "1:1",
"base_megapixels": 1.0,
"final_megapixels": 2.0,
"multiple": 16,
"random_seed": 123,
},
None,
return_types=("INT", "INT", "INT", "INT", "INT"),
)
metadata_registry.update_node_execution(
"selector",
"KreaDualResolutionSelector",
[(1024, 1024, 2048, 2048, 123)],
return_types=("INT", "INT", "INT", "INT", "INT"),
)
metadata = metadata_registry.get_metadata("krea-selector")
assert metadata[SIZE]["selector"] == {
"width": 1024,
"height": 1024,
"node_id": "selector",
}
@@ -0,0 +1,179 @@
"""Tests for the Random Checkpoint/Unet Loader nodes' base-model filtering and
random-selection behavior.
"""
import pytest
from py.nodes.random_checkpoint_loader import RandomCheckpointLoaderLM
from py.nodes.random_unet_loader import RandomUNETLoaderLM
class _FakeCache:
def __init__(self, raw_data):
self.raw_data = raw_data
class _FakeScanner:
def __init__(self, raw_data, model_roots):
self._raw_data = raw_data
self._model_roots = model_roots
async def get_cached_data(self, force_refresh=False):
return _FakeCache(self._raw_data)
def get_model_roots(self):
return self._model_roots
@pytest.fixture
def base_model_library(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
illustrious = tmp_path / "illustrious.safetensors"
illustrious.write_bytes(b"x")
flux = tmp_path / "flux.safetensors"
flux.write_bytes(b"x")
missing = tmp_path / "missing.safetensors" # referenced but never created
raw_data = [
{
"sub_type": "checkpoint",
"file_path": str(illustrious),
"base_model": "Illustrious",
},
{"sub_type": "checkpoint", "file_path": str(flux), "base_model": "Flux.1 D"},
{
"sub_type": "checkpoint",
"file_path": str(missing),
"base_model": "SDXL 1.0",
},
{
"sub_type": "diffusion_model",
"file_path": str(flux),
"base_model": "Flux.1 D",
},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
return tmp_path
def test_checkpoint_names_drop_deleted_files(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
existing = tmp_path / "keep.safetensors"
existing.write_bytes(b"x")
deleted = tmp_path / "deleted.safetensors" # referenced but never created
raw_data = [
{"sub_type": "checkpoint", "file_path": str(existing)},
{"sub_type": "checkpoint", "file_path": str(deleted)},
# Wrong type must stay excluded by the sub_type filter.
{"sub_type": "diffusion_model", "file_path": str(existing)},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
assert RandomCheckpointLoaderLM._get_checkpoint_names() == ["keep.safetensors"]
def test_unet_names_drop_deleted_files(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
existing = tmp_path / "keep.safetensors"
existing.write_bytes(b"x")
deleted = tmp_path / "deleted.safetensors"
raw_data = [
{"sub_type": "diffusion_model", "file_path": str(existing)},
{"sub_type": "diffusion_model", "file_path": str(deleted)},
{"sub_type": "checkpoint", "file_path": str(existing)},
]
async def _fake_scanner():
return _FakeScanner(raw_data, [str(tmp_path)])
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _fake_scanner)
assert RandomUNETLoaderLM._get_unet_names() == ["keep.safetensors"]
def test_checkpoint_names_empty_when_scanner_fails(tmp_path, monkeypatch):
from py.services.service_registry import ServiceRegistry
def _boom():
raise RuntimeError("scanner not available")
monkeypatch.setattr(ServiceRegistry, "get_checkpoint_scanner", _boom)
assert RandomCheckpointLoaderLM._get_checkpoint_names() == []
def test_checkpoint_available_base_models(base_model_library):
# "SDXL 1.0" is excluded because its file no longer exists on disk.
assert RandomCheckpointLoaderLM._get_available_base_models() == [
"Any",
"Flux.1 D",
"Illustrious",
]
def test_checkpoint_names_filtered_by_base_model(base_model_library):
assert RandomCheckpointLoaderLM._get_checkpoint_names("Illustrious") == [
"illustrious.safetensors"
]
assert RandomCheckpointLoaderLM._get_checkpoint_names("Any") == [
"flux.safetensors",
"illustrious.safetensors",
]
def test_unet_available_base_models(base_model_library):
assert RandomUNETLoaderLM._get_available_base_models() == ["Any", "Flux.1 D"]
def test_load_checkpoint_random_selection_uses_pool(base_model_library, monkeypatch):
from py.nodes import random_checkpoint_loader as random_checkpoint_loader_module
monkeypatch.setattr(
random_checkpoint_loader_module,
"get_checkpoint_info_absolute",
lambda name: (str(base_model_library / name), {"file_path": name}),
)
monkeypatch.setattr(
random_checkpoint_loader_module.comfy.sd,
"load_checkpoint_guess_config",
lambda *a, **k: ("MODEL", "CLIP", "VAE", None),
raising=False,
)
node = RandomCheckpointLoaderLM()
result = node.load_checkpoint(
"ignored.safetensors", select_at_random=True, base_model="Illustrious"
)
# Only one checkpoint matches "Illustrious", so the random pick is deterministic here.
assert result[3] == "illustrious.safetensors"
def test_load_checkpoint_random_selection_raises_when_pool_empty(base_model_library):
node = RandomCheckpointLoaderLM()
with pytest.raises(FileNotFoundError, match="No checkpoints found"):
node.load_checkpoint(
"ignored.safetensors", select_at_random=True, base_model="SDXL 1.0"
)
def test_checkpoint_is_changed_forces_rerun_when_random():
assert RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=True, base_model="Any"
) != RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=True, base_model="Any"
)
assert RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=False, base_model="Any"
) == RandomCheckpointLoaderLM.IS_CHANGED(
"a.safetensors", select_at_random=False, base_model="Any"
)
+100
View File
@@ -593,3 +593,103 @@ async def test_fetch_missing_license_data_filters_model_ids(monkeypatch):
assert len(payload["updated"]) == 1
assert provider_calls == [[20]]
assert len(saved) == 1
def test_serialize_version_permanent_paid_is_not_early_access():
"""Permanent paid versions (is_paid, no end date) must not be flagged as
early access, mirroring _is_early_access_active in the update service."""
version = ModelVersionRecord(
version_id=7, name="v7", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control="Download",
paid_access=json.dumps({"permanent": True, "endsAt": None}), is_paid=True,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is False
assert serialized["isPaid"] is True
assert serialized["paidAccess"] == {"permanent": True, "endsAt": None}
def test_serialize_version_timed_paid_is_early_access():
"""Timed paid gates (endsAt in the future) stay flagged as early access."""
version = ModelVersionRecord(
version_id=8, name="v8", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at="2099-01-01T00:00:00.000Z", is_early_access=True,
usage_control="Download",
paid_access=json.dumps({"permanent": False, "endsAt": "2099-01-01T00:00:00.000Z"}),
is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["isEarlyAccess"] is True
assert serialized["isPaid"] is False
def test_serialize_version_malformed_paid_access_does_not_crash():
"""A malformed paid_access row must degrade to None instead of failing
the whole versions-list response."""
version = ModelVersionRecord(
version_id=10, name="v10", base_model=None, released_at=None, size_bytes=None,
preview_url=None, is_in_library=False, should_ignore=False,
early_access_ends_at=None, is_early_access=True, usage_control=None,
paid_access="{not json", is_paid=False,
)
serialized = ModelUpdateHandler._serialize_version(version, None)
assert serialized["paidAccess"] is None
assert serialized["isEarlyAccess"] is True
async def test_enrich_early_access_details_skips_permanent_paid(monkeypatch):
"""Permanent paid versions must not trigger per-version CivitAI fetches in
_enrich_early_access_details: they are not early access and can never get
an end time, so enriching them is wasted API traffic."""
record = ModelUpdateRecord(
model_type="lora",
model_id=1,
versions=[
ModelVersionRecord(
version_id=100, name="paid", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access='{"permanent": true, "endsAt": null}', is_paid=True,
),
ModelVersionRecord(
version_id=200, name="ea", base_model=None, released_at=None,
size_bytes=None, preview_url=None, is_in_library=False,
should_ignore=False, early_access_ends_at=None,
is_early_access=True, usage_control="Download",
paid_access=None, is_paid=False,
),
],
last_checked_at=1.0,
should_ignore_model=False,
)
fetched: list[int] = []
async def fake_version_info(version_id: str):
fetched.append(int(version_id))
return {"earlyAccessEndsAt": "2099-01-01T00:00:00.000Z"}, None
provider = SimpleNamespace(get_model_version_info=fake_version_info)
async def metadata_selector(name):
assert name == "civitai_api"
return provider
handler = ModelUpdateHandler(
service=DummyService(SimpleNamespace(raw_data=[], version_index={})),
update_service=SimpleNamespace(),
metadata_provider_selector=metadata_selector,
settings_service=SimpleNamespace(get=lambda *_: False),
logger=logging.getLogger(__name__),
)
enriched = await handler._enrich_early_access_details(record)
# Only the timed EA version (200) is fetched; the permanent paid one (100) is skipped.
assert fetched == [200]
enriched_map = {v.version_id: v for v in enriched.versions}
assert enriched_map[200].early_access_ends_at == "2099-01-01T00:00:00.000Z"
assert enriched_map[100].early_access_ends_at is None
+6 -2
View File
@@ -82,7 +82,9 @@ class StubUpdateService:
self.bulk_calls = []
self.bulk_error = bulk_error
async def has_updates_bulk(self, model_type, model_ids, hide_early_access: bool = False):
async def has_updates_bulk(
self, model_type, model_ids, hide_early_access: bool = False, hide_paid: bool = False
):
self.bulk_calls.append((model_type, list(model_ids)))
if self.bulk_error:
raise RuntimeError("bulk failure")
@@ -94,7 +96,9 @@ class StubUpdateService:
results[model_id] = result
return results
async def has_update(self, model_type, model_id, hide_early_access: bool = False):
async def has_update(
self, model_type, model_id, hide_early_access: bool = False, hide_paid: bool = False
):
self.calls.append((model_type, model_id))
result = self.decisions.get(model_id, False)
if isinstance(result, Exception):
+218 -4
View File
@@ -123,10 +123,7 @@ def metadata_provider(monkeypatch):
class DummyProvider:
def __init__(self):
self.calls = []
async def get_model_version(self, model_id, model_version_id):
self.calls.append((model_id, model_version_id))
return {
self.payload = {
"id": 42,
"model": {"type": "LoRA", "tags": ["fantasy"]},
"baseModel": "BaseModel",
@@ -141,6 +138,10 @@ def metadata_provider(monkeypatch):
],
}
async def get_model_version(self, model_id, model_version_id):
self.calls.append((model_id, model_version_id))
return self.payload
provider = DummyProvider()
monkeypatch.setattr(
download_manager,
@@ -233,6 +234,217 @@ async def test_successful_download_uses_defaults(
assert captured["download_urls"] == ["https://example.invalid/file.safetensors"]
@pytest.mark.asyncio
async def test_download_accepts_enhancement_lora_primary_file(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A version whose only file has type 'Enhancement LoRA' (Anima/AIR
image-editing LoRAs) must download previously failed with
"No suitable file found in metadata" because the type was missing from
the primary-file weights allowlist."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 3219121,
"model": {"type": "LORA", "tags": ["style"]},
"baseModel": "Anima",
"creator": {"username": "Deskup"},
"files": [
{
"id": 3100968,
"type": "Enhancement LoRA",
"primary": True,
"name": "deskup-anima-edit-general.safetensors",
"sizeKB": 358501.13,
"downloadUrl": "https://example.invalid/deskup-anima-edit-general.safetensors",
}
],
}
captured = {}
async def fake_execute_download(self, **kwargs):
captured.update(
{
"download_urls": kwargs["download_urls"],
"model_type": kwargs["model_type"],
}
)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_id=2850692,
model_version_id=3219121,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["model_type"] == "lora"
assert captured["download_urls"] == [
"https://example.invalid/deskup-anima-edit-general.safetensors"
]
@pytest.mark.asyncio
async def test_download_falls_back_to_civitai_primary_flag_regardless_of_type(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""If no weights-type file exists, trust CivitAI's `primary` flag on any
file mirrors CivitAI's getPrimaryFile() which never excludes a file by
type."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 77,
"model": {"type": "LORA", "tags": ["concept"]},
"baseModel": "Anima",
"creator": {"username": "Author"},
"files": [
{
"id": 100,
"type": "Other",
"primary": True,
"name": "custom-type-lora.safetensors",
"downloadUrl": "https://example.invalid/custom-type-lora.safetensors",
}
],
}
captured = {}
async def fake_execute_download(self, **kwargs):
captured["download_urls"] = kwargs["download_urls"]
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=77,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["download_urls"] == [
"https://example.invalid/custom-type-lora.safetensors"
]
@pytest.mark.asyncio
async def test_download_prefers_weights_file_over_non_weights_primary(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""A Config/Archive-type primary must never replace an existing weights
file the weights file wins even without the primary flag."""
manager = DownloadManager()
metadata_provider.payload = {
"id": 78,
"model": {"type": "LORA", "tags": ["concept"]},
"baseModel": "BaseModel",
"creator": {"username": "Author"},
"files": [
{
"id": 201,
"type": "Config",
"primary": True,
"name": "config.json",
"downloadUrl": "https://example.invalid/config.json",
},
{
"id": 202,
"type": "Model",
"primary": False,
"name": "weights.safetensors",
"downloadUrl": "https://example.invalid/weights.safetensors",
},
],
}
captured = {}
async def fake_execute_download(self, **kwargs):
captured["download_urls"] = kwargs["download_urls"]
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
result = await manager.download_from_civitai(
model_version_id=78,
save_dir=str(tmp_path),
use_default_paths=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["download_urls"] == [
"https://example.invalid/weights.safetensors"
]
@pytest.mark.asyncio
async def test_download_keeps_save_dir_when_use_save_dir_as_root(
monkeypatch, scanners, metadata_provider, tmp_path
):
"""use_default_paths with use_save_dir_as_root resolves the template under
the provided save_dir instead of switching to the default root."""
manager = DownloadManager()
captured = {}
async def fake_execute_download(
self,
*,
download_urls,
save_dir,
metadata,
version_info,
relative_path,
progress_callback,
model_type,
download_id,
transfer_backend=None,
):
captured.update(
{
"save_dir": Path(save_dir),
"relative_path": relative_path,
"model_type": model_type,
}
)
return {"success": True}
monkeypatch.setattr(
DownloadManager, "_execute_download", fake_execute_download, raising=False
)
custom_root = tmp_path / "custom_root"
result = await manager.download_from_civitai(
model_version_id=99,
save_dir=str(custom_root),
use_default_paths=True,
use_save_dir_as_root=True,
progress_callback=None,
source=None,
)
assert result["success"] is True
assert captured["relative_path"] == "MappedModel/fantasy"
assert captured["save_dir"] == custom_root / "MappedModel" / "fantasy"
assert captured["model_type"] == "lora"
@pytest.mark.asyncio
async def test_successful_download_schedules_auto_example_images(
monkeypatch, scanners, metadata_provider, tmp_path
@@ -618,6 +830,7 @@ async def test_resume_download_restores_persisted_aria2_task(monkeypatch, tmp_pa
use_default_paths=False,
source=None,
file_params=None,
use_save_dir_as_root=False,
):
created.update(
{
@@ -1037,6 +1250,7 @@ async def test_download_uses_captured_backend_when_settings_change(
transfer_backend="python",
source=None,
file_params=None,
use_save_dir_as_root=False,
):
captured["transfer_backend"] = transfer_backend
return {"success": True}
+5 -41
View File
@@ -10,7 +10,6 @@ import pytest
from py.services.model_lifecycle_service import ModelLifecycleService, _require_path_in_library_roots
from py.services.pending_delete_service import PENDING_DELETE_DIR_NAME, _reset_pending_delete_service
from py.services.settings_manager import get_settings_manager
from py.utils.metadata_manager import MetadataManager
from py.utils.models import LoraMetadata
@@ -953,11 +952,11 @@ def _make_delete_service(scanner: Any) -> ModelLifecycleService:
@pytest.mark.asyncio
async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
"""Undo enabled (the default): artifacts are renamed into a
``.lm-pending-delete/<batch_id>/`` staging dir under the model root, the
response carries the batch_id, the cache entry is removed and the cache
is persisted (``_persist_calls`` tracked by ``ScannerForDelete``)."""
async def test_delete_model_stages_file(tmp_path: Path):
"""Artifacts are renamed into a ``.lm-pending-delete/<batch_id>/`` staging
dir under the model root, the response carries the batch_id, the cache
entry is removed and the cache is persisted (``_persist_calls`` tracked by
``ScannerForDelete``)."""
root = tmp_path / "loras"
root.mkdir()
model_path = root / "model.safetensors"
@@ -999,41 +998,6 @@ async def test_delete_model_stages_file_when_undo_enabled(tmp_path: Path):
assert scanner._persist_calls == [True]
@pytest.mark.asyncio
async def test_delete_model_hard_deletes_when_undo_disabled(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""delete_undo_enabled=false: old os.remove behavior, batch_id is None and
no staging directory is ever created."""
root = tmp_path / "loras"
root.mkdir()
model_path = root / "model.safetensors"
model_path.write_bytes(b"content")
settings_manager = get_settings_manager()
monkeypatch.setattr(
settings_manager,
"get",
lambda key, default=None: False
if key == "delete_undo_enabled"
else default,
)
scanner = ScannerForDelete(
raw_data=[{"file_path": str(model_path)}],
roots=[str(root)],
)
service = _make_delete_service(scanner)
result = await service.delete_model(str(model_path))
assert result["success"] is True
assert result["batch_id"] is None
assert result["deleted_files"]
assert not model_path.exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
@pytest.mark.asyncio
async def test_delete_model_falls_back_when_staging_fails(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
-28
View File
@@ -22,7 +22,6 @@ from py.services.pending_delete_service import (
_reset_pending_delete_service,
)
from py.services.persistent_model_cache import PersistentModelCache, DEFAULT_LICENSE_FLAGS
from py.services.settings_manager import get_settings_manager
from py.utils.civitai_utils import build_license_flags
from py.utils.models import BaseModelMetadata
@@ -1166,33 +1165,6 @@ async def test_bulk_delete_merge_failure_falls_back_to_batch_ids(
assert sorted(staged_files) == ["one.txt", "two.txt"]
@pytest.mark.asyncio
async def test_bulk_delete_undo_disabled_hard_deletes(tmp_path: Path):
"""delete_undo_enabled=false -> old hard delete, no batch, no staging dirs."""
root = tmp_path / "loras"
root.mkdir()
first = root / "one.txt"
first.write_text("one", encoding="utf-8")
second = root / "two.txt"
second.write_text("two", encoding="utf-8")
scanner = _make_bulk_scanner(root, [first, second])
get_settings_manager().settings["delete_undo_enabled"] = False
result = await scanner.bulk_delete_models([str(first), str(second)])
assert result["success"] is True
assert result["status"] == "success"
assert result["total_deleted"] == 2
assert result.get("batch_id") is None
assert "batch_ids" not in result
# Old hard-delete behavior: files removed, zero staging dirs created.
assert not first.exists()
assert not second.exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
@pytest.mark.asyncio
async def test_bulk_delete_cancelled_after_one_staged_batch_present(
tmp_path: Path, monkeypatch
+177 -1
View File
@@ -59,7 +59,17 @@ class NotFoundProvider:
return {}
def make_version(version_id, *, in_library, base_model=None, should_ignore=False):
def make_version(
version_id,
*,
in_library,
base_model=None,
should_ignore=False,
early_access_ends_at=None,
is_early_access=False,
is_paid=False,
paid_access=None,
):
return ModelVersionRecord(
version_id=version_id,
name=None,
@@ -69,6 +79,10 @@ def make_version(version_id, *, in_library, base_model=None, should_ignore=False
preview_url=None,
is_in_library=in_library,
should_ignore=should_ignore,
early_access_ends_at=early_access_ends_at,
is_early_access=is_early_access,
is_paid=is_paid,
paid_access=paid_access,
)
@@ -622,3 +636,165 @@ async def test_refresh_folder_filter_considers_cross_folder_versions(tmp_path):
# has_update must be True (version 20 > max_in_library=15)
assert record.has_update() is True
def test_extract_single_version_paid_access_timed(tmp_path):
"""A timed paidAccess gate (permanent=False + future endsAt) is detected
as early access while availability stays 'Public'."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
entry = {
"id": 42,
"name": "v1 paid",
"availability": "Public",
"paidAccess": {
"permanent": False,
"endsAt": "2026-08-22T18:30:00.000Z",
},
"files": [],
"images": [],
}
version = service._extract_single_version(entry, index=0)
assert version is not None
assert version.is_early_access is True
assert version.early_access_ends_at == "2026-08-22T18:30:00.000Z"
assert version.is_paid is False
assert version.paid_access is not None
def test_extract_single_version_paid_access_permanent(tmp_path):
"""A permanent paidAccess gate (permanent=True, no endsAt) is detected and
flagged as paid but is NOT early access and carries no end date."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
entry = {
"id": 42,
"name": "v1 paid",
"availability": "Public",
"paidAccess": {"permanent": True, "endsAt": None},
"files": [],
"images": [],
}
version = service._extract_single_version(entry, index=0)
assert version is not None
assert version.is_early_access is False
assert version.is_paid is True
assert version.early_access_ends_at is None
assert version.paid_access is not None
def test_normalize_paid_access_accepts_json_string():
"""The by-hash enrichment path may hand paidAccess to _normalize_paid_access
as a JSON string; both the permanent and timed shapes must normalize."""
service = ModelUpdateService.__new__(ModelUpdateService)
permanent = ModelUpdateService._normalize_paid_access(
'{"permanent": true, "endsAt": null}'
)
assert permanent == {"permanent": True, "endsAt": None}
timed = ModelUpdateService._normalize_paid_access(
'{"permanent": false, "endsAt": "2026-08-22T18:30:00.000Z"}'
)
assert timed == {"permanent": False, "endsAt": "2026-08-22T18:30:00.000Z"}
empty = ModelUpdateService._normalize_paid_access(
'{"permanent": false, "endsAt": null}'
)
assert empty is None
malformed = ModelUpdateService._normalize_paid_access("{not json")
assert malformed is None
def test_has_update_for_base_hide_paid():
"""hide_paid also suppresses permanent paid versions in the same-base
update path (has_update_for_base)."""
record = make_record(
make_version(5, in_library=True, base_model="illustrious"),
make_version(
7,
in_library=False,
base_model="illustrious",
is_paid=True,
paid_access='{"permanent": true, "endsAt": null}',
),
)
assert record.has_update_for_base(5, "illustrious") is True
assert record.has_update_for_base(5, "illustrious", hide_paid=True) is False
def test_has_update_hide_paid():
"""hide_paid suppresses update flags raised by a permanent paid version."""
record = make_record(
make_version(5, in_library=True),
make_version(
7,
in_library=False,
is_paid=True,
paid_access='{"permanent": true, "endsAt": null}',
),
)
assert record.has_update() is True
assert record.has_update(hide_paid=True) is False
def test_has_update_hide_early_access_paid_timed():
"""hide_early_access suppresses a newer timed paidAccess version."""
record = make_record(
make_version(5, in_library=True),
make_version(
7,
in_library=False,
is_early_access=True,
early_access_ends_at="2099-01-01T00:00:00Z",
),
)
assert record.has_update() is True
assert record.has_update(hide_early_access=True) is False
def test_build_record_from_remote_preserves_paid_fields(tmp_path):
"""_build_record_from_remote must carry paid_access/is_paid from the
parsed remote versions into the rebuilt record, or the refresh path
silently drops paid data before persistence."""
db_path = tmp_path / "updates.sqlite"
service = ModelUpdateService(str(db_path))
remote_version = ModelVersionRecord(
version_id=7,
name="v7",
base_model=None,
released_at=None,
size_bytes=None,
preview_url=None,
is_in_library=False,
should_ignore=False,
early_access_ends_at=None,
is_early_access=True,
usage_control="Download",
paid_access='{"permanent": true, "endsAt": null}',
is_paid=True,
)
record = service._build_record_from_remote(
model_type="lora",
model_id=123,
local_versions=[],
remote_versions=[remote_version],
existing=None,
timestamp=1.0,
)
rebuilt = record.versions[0]
assert rebuilt.paid_access == '{"permanent": true, "endsAt": null}'
assert rebuilt.is_paid is True
+785 -41
View File
@@ -15,10 +15,11 @@ import asyncio
import errno
import json
import os
import shutil
import time
from collections.abc import Iterator
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from typing import Any, Dict, List, Optional, Sequence, Tuple
import pytest
@@ -30,7 +31,6 @@ from py.services.pending_delete_service import (
)
from py.services.model_hash_index import ModelHashIndex
from py.services.model_scanner import ModelScanner
from py.services.settings_manager import DEFAULT_SETTINGS, get_settings_manager
from py.utils import settings_paths
from py.utils.models import LoraMetadata
@@ -451,7 +451,9 @@ async def test_g_manifestless_dir_quarantined(tmp_path: Path, monkeypatch) -> No
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
await service.purge_expired()
# The batch is hand-created (unregistered): the reconciliation pass is
# required for the default registry-only purge to discover it.
await service.purge_expired(scan_roots=True)
orphaned = staging / "batch1.orphaned"
assert orphaned.is_dir()
@@ -474,7 +476,8 @@ async def test_h_corrupted_manifest_quarantined(tmp_path: Path, monkeypatch) ->
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
await service.purge_expired() # must not crash
# Hand-created (unregistered) batch: reconciliation discovers it.
await service.purge_expired(scan_roots=True) # must not crash
orphaned = staging / "batch2.orphaned"
assert orphaned.is_dir()
@@ -767,32 +770,6 @@ async def test_l2_merge_basename_collision_aborts_without_dropping_files(
assert (sub_b / "model.safetensors").read_bytes() == b"model-data"
# ---------------------------------------------------------------------------
# (m) delete_undo_enabled=false -> stage returns None, nothing created
# ---------------------------------------------------------------------------
async def test_m_undo_disabled_returns_none(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_bytes(b"data")
get_settings_manager().settings["delete_undo_enabled"] = False
service = await PendingDeleteService.get_instance()
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(root),
file_name="model",
main_extension=".safetensors",
original_file_path=str(model),
cached_entry=None,
)
assert batch_id is None
assert model.exists()
assert not (root / PENDING_DELETE_DIR_NAME).exists()
# ---------------------------------------------------------------------------
# (n) simulated OSError during staging -> rollback, no orphaned batch dir
# ---------------------------------------------------------------------------
@@ -835,13 +812,6 @@ async def test_n_staging_oserror_rolls_back(tmp_path: Path, monkeypatch) -> None
assert not any(staging.iterdir())
# ---------------------------------------------------------------------------
# (o) DEFAULT_SETTINGS contains delete_undo_enabled=True
# ---------------------------------------------------------------------------
def test_o_default_settings_contains_undo_enabled() -> None:
assert DEFAULT_SETTINGS.get("delete_undo_enabled") is True
# ---------------------------------------------------------------------------
# (p) SCANNER EXCLUSION
# ---------------------------------------------------------------------------
@@ -1055,7 +1025,8 @@ async def test_r_purge_expired_enumerates_all_scanner_types_and_recipe_dir(
)
service = await PendingDeleteService.get_instance()
purged = await service.purge_expired()
# Hand-created (unregistered) batches: reconciliation pass discovers them.
purged = await service.purge_expired(scan_roots=True)
assert purged >= 4
for root in (lora_root, ckpt_root, emb_root):
@@ -1117,12 +1088,13 @@ async def test_t_quarantine_is_terminal(tmp_path: Path, monkeypatch) -> None:
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
await service.purge_expired()
# Hand-created (unregistered) batch: reconciliation discovers it.
await service.purge_expired(scan_roots=True)
orphaned = staging / "qbatch.orphaned"
assert orphaned.is_dir()
# Second sweep must NOT re-rename or delete the quarantined dir.
await service.purge_expired()
await service.purge_expired(scan_roots=True)
assert orphaned.is_dir()
assert (orphaned / "model.safetensors").read_bytes() == b"data"
assert not batch_dir.exists()
@@ -1169,7 +1141,9 @@ async def test_u_lock_no_deadlock_with_concurrent_purge(tmp_path: Path, monkeypa
cached_entry=None,
)
purge_task = asyncio.create_task(service.purge_expired())
# Hand-created (unregistered) "expired" batch: the purge task must run the
# reconciliation pass to discover it alongside the staged "new" batch.
purge_task = asyncio.create_task(service.purge_expired(scan_roots=True))
stage_task = asyncio.create_task(do_stage())
results = await asyncio.gather(purge_task, stage_task, return_exceptions=True)
@@ -1533,3 +1507,773 @@ async def test_snap2_merge_keeps_both_snapshots(
snap_entries = [e for e in manifest["entries"] if e.get("snapshot")]
assert len(snap_entries) == 2
assert {e["snapshot"]["file_path"] for e in snap_entries} == {str(a1), str(b1)}
# ---------------------------------------------------------------------------
# Batch-registry lifecycle (todo 1: in-process _known_batch_dirs)
# ---------------------------------------------------------------------------
# (a) stage_model_delete registers in _known_batch_dirs
async def test_reg_a_stage_model_registers_batch(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
assert service._known_batch_dirs.get(batch_id) == str(
root / PENDING_DELETE_DIR_NAME / batch_id
)
# (b) undo success removes the entry
async def test_reg_b_undo_success_removes_registry_entry(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
assert batch_id in service._known_batch_dirs
await service.undo(batch_id)
assert batch_id not in service._known_batch_dirs
# (c) purge_batch removes the entry after a real purge
async def test_reg_c_purge_batch_removes_registry_entry(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
manifest_path = batch_dir / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
await service.purge_batch(batch_id)
assert batch_id not in service._known_batch_dirs
assert not batch_dir.exists()
# (d) quarantine (corrupted manifest) removes the entry
async def test_reg_d_quarantine_removes_registry_entry(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
assert batch_id in service._known_batch_dirs
# Corrupt the manifest: purge_batch quarantines the dir (returns True).
(batch_dir / "manifest.json").write_text("{ not valid json !!!")
await service.purge_batch(batch_id)
assert batch_id not in service._known_batch_dirs
assert not batch_dir.exists()
assert (batch_dir.with_name(f"{batch_id}.orphaned")).is_dir()
# (e) merge success: winner present + losers removed; EXDEV-abort: unchanged
async def test_reg_e_merge_registry_lifecycle(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
_spy_purge_timers(monkeypatch)
service = await PendingDeleteService.get_instance()
bid_a = await _stage_simple(service, root, "alpha")
bid_b = await _stage_simple(service, root, "beta")
bid_c = await _stage_simple(service, root, "gamma")
assert set(service._known_batch_dirs) == {bid_a, bid_b, bid_c}
# Merge success: winner stays, processed loser forgotten, untouched batch stays.
assert await service.merge_batches([bid_a, bid_b]) == bid_a
assert bid_a in service._known_batch_dirs
assert bid_b not in service._known_batch_dirs
assert bid_c in service._known_batch_dirs
# EXDEV-abort: registry untouched.
def exdev_rename(src: str, dst: str) -> None:
raise OSError(errno.EXDEV, "Invalid cross-device link", src, dst)
monkeypatch.setattr("py.services.pending_delete_service.os.rename", exdev_rename)
before = dict(service._known_batch_dirs)
assert await service.merge_batches([bid_a, bid_c]) is None
assert dict(service._known_batch_dirs) == before
# (f) _reset_pending_delete_service clears the registry
async def test_reg_f_reset_clears_registry(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
assert service._known_batch_dirs
_reset_pending_delete_service()
fresh = await PendingDeleteService.get_instance()
assert fresh is not service
assert fresh._known_batch_dirs == {}
# (g) scan_roots=True reconciles externally created batches (expired purged,
# non-expired registered); the registry-only default does NOT find them
async def test_reg_g_reconciliation_finds_external_batches(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
expired_dir = root / PENDING_DELETE_DIR_NAME / "ext-expired"
expired_dir.mkdir(parents=True)
(expired_dir / "old.safetensors").write_bytes(b"old")
_write_batch_manifest(
expired_dir,
batch_id="ext-expired",
kind="model",
model_type="loras",
expires_at=int(time.time()) - 10,
entries=[
{
"staged": str(expired_dir / "old.safetensors"),
"original": str(root / "old.safetensors"),
"restored": False,
}
],
)
fresh_dir = root / PENDING_DELETE_DIR_NAME / "ext-fresh"
fresh_dir.mkdir(parents=True)
(fresh_dir / "new.safetensors").write_bytes(b"new")
_write_batch_manifest(
fresh_dir,
batch_id="ext-fresh",
kind="model",
model_type="loras",
expires_at=int(time.time()) + 100,
entries=[
{
"staged": str(fresh_dir / "new.safetensors"),
"original": str(root / "new.safetensors"),
"restored": False,
}
],
)
service = await PendingDeleteService.get_instance()
# Registry-only default: the externally created batches are invisible.
await service.purge_expired()
assert expired_dir.is_dir()
assert fresh_dir.is_dir()
assert "ext-expired" not in service._known_batch_dirs
assert "ext-fresh" not in service._known_batch_dirs
# Reconciliation pass: expired one purged, non-expired one registered.
await service.purge_expired(scan_roots=True)
assert not expired_dir.exists()
assert not (root / "old.safetensors").exists()
assert fresh_dir.is_dir()
assert (fresh_dir / "new.safetensors").exists()
assert "ext-expired" not in service._known_batch_dirs
assert service._known_batch_dirs.get("ext-fresh") == str(fresh_dir)
# (h) _find_batch_dir with cleared registry locates + registers (restart sim)
async def test_reg_h_find_batch_dir_restart_simulation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
assert batch_id in service._known_batch_dirs
# Simulate a restart: the in-process registry is empty but the batch dir
# is still on disk.
service._known_batch_dirs.clear()
found = await service._find_batch_dir(batch_id)
assert found == str(root / PENDING_DELETE_DIR_NAME / batch_id)
assert service._known_batch_dirs.get(batch_id) == found
# Undo works after the restart simulation.
await service.undo(batch_id)
assert (root / "model.safetensors").read_bytes() == b"model-data"
assert batch_id not in service._known_batch_dirs
# (i) purge iteration uses a snapshot: no dict-changed-size when entries are
# removed mid-iteration
async def test_reg_i_purge_iteration_uses_snapshot(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
ids = [await _stage_simple(service, root, f"m{i}") for i in range(5)]
for batch_id in ids:
manifest_path = root / PENDING_DELETE_DIR_NAME / batch_id / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
# Every purge removes its registry entry mid-loop; the snapshot makes this
# safe (iterating the dict directly would raise RuntimeError).
await service.purge_expired()
assert service._known_batch_dirs == {}
for batch_id in ids:
assert not (root / PENDING_DELETE_DIR_NAME / batch_id).exists()
# (j) STARTUP SWEEP PIN: the startup sweep task passes scan_roots=True
async def test_reg_j_startup_sweep_passes_scan_roots_true(
monkeypatch: pytest.MonkeyPatch,
) -> None:
from py import lora_manager
sweep_calls: List[Dict[str, Any]] = []
class _SpySweepService:
async def purge_expired(self, scan_roots: bool = False) -> int:
sweep_calls.append({"scan_roots": scan_roots})
return 0
async def _fake_get_service() -> _SpySweepService:
return _SpySweepService()
monkeypatch.setattr(lora_manager, "get_pending_delete_service", _fake_get_service)
async def _stub(*args: Any, **_kwargs: Any) -> Any:
return args[0] if args else None
class _DummyScanner:
async def initialize_in_background(self) -> None:
return None
dummy = _DummyScanner()
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_civitai_client", lambda: _stub())
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_download_manager", lambda: _stub())
monkeypatch.setattr(
lora_manager.ServiceRegistry, "get_download_queue_service", lambda: _stub()
)
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_backup_service", lambda: _stub())
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_websocket_manager", lambda: _stub())
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_lora_scanner", lambda: _stub(dummy))
monkeypatch.setattr(
lora_manager.ServiceRegistry, "get_checkpoint_scanner", lambda: _stub(dummy)
)
monkeypatch.setattr(
lora_manager.ServiceRegistry, "get_embedding_scanner", lambda: _stub(dummy)
)
monkeypatch.setattr(lora_manager.ServiceRegistry, "get_recipe_scanner", lambda: _stub(dummy))
from py.services import metadata_service as metadata_service_module
monkeypatch.setattr(
metadata_service_module,
"initialize_metadata_providers",
_stub,
)
from py.services.llm_service import LLMService
monkeypatch.setattr(LLMService, "get_instance", _stub)
async def _fake_migration() -> None:
return None
monkeypatch.setattr(
lora_manager.ExampleImagesMigration,
"check_and_run_migrations",
staticmethod(_fake_migration),
)
captured: List[Any] = []
class _DummyTask:
def add_done_callback(self, _cb: Any) -> None: # pragma: no cover - stub
pass
def done(self) -> bool: # pragma: no cover - stub
return False
def _capture_task(coro: Any, *args: Any, **kwargs: Any) -> _DummyTask:
captured.append(coro)
return _DummyTask()
monkeypatch.setattr(asyncio, "create_task", _capture_task)
try:
await lora_manager.LoraManager._initialize_services()
finally:
sweep_coro: Any = None
for coro in captured:
qualname = getattr(coro.cr_code, "co_qualname", "")
if "_SpySweepService.purge_expired" in qualname:
sweep_coro = coro
else:
coro.close()
if sweep_coro is not None:
# The sweep task body only runs when awaited; execute just the
# spy's purge_expired so it records its invocation arguments.
await sweep_coro
# The startup sweep must invoke purge_expired with scan_roots=True (the
# reconciliation flag) - forgetting it would break restart cleanup.
assert sweep_calls == [{"scan_roots": True}]
# ---------------------------------------------------------------------------
# Todo 2: SIBLING-OF-MODEL STAGING (model file in a SUBDIR of the scanner root)
# ---------------------------------------------------------------------------
# (a) staging lands in <model_dir>/.lm-pending-delete/<batch_id>, NOT under the
# scanner root - manifest entries' staged paths live under the sibling dir.
async def test_sibling1_stage_model_in_subdir_uses_sibling_dir(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
sub = root / "nested"
sub.mkdir()
model = sub / "model.safetensors"
model.write_bytes(b"sibling-data")
metadata = sub / "model.metadata.json"
metadata.write_bytes(b"{}")
service = await PendingDeleteService.get_instance()
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(sub),
file_name="model",
main_extension=".safetensors",
original_file_path=str(model),
cached_entry=None,
)
assert batch_id is not None
sibling_dir = sub / PENDING_DELETE_DIR_NAME / batch_id
assert sibling_dir.is_dir()
# The OLD location (under the scanner root) must NOT be created.
assert not (root / PENDING_DELETE_DIR_NAME).exists()
manifest = json.loads((sibling_dir / "manifest.json").read_text(encoding="utf-8"))
assert len(manifest["entries"]) == 2
for entry in manifest["entries"]:
assert str(entry["staged"]).startswith(str(sibling_dir))
assert (sibling_dir / "model.safetensors").read_bytes() == b"sibling-data"
assert (sibling_dir / "model.metadata.json").exists()
assert not model.exists()
assert not metadata.exists()
# (b) undo of a sibling-staged batch restores the files byte-identically.
async def test_sibling2_undo_restores_byte_identically(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
sub = root / "nested"
sub.mkdir()
model = sub / "model.safetensors"
model.write_bytes(b"payload-1")
preview = sub / "model.preview.png"
preview.write_bytes(b"payload-2")
service = await PendingDeleteService.get_instance()
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(sub),
file_name="model",
main_extension=".safetensors",
original_file_path=str(model),
cached_entry=None,
)
assert batch_id is not None
sibling_dir = sub / PENDING_DELETE_DIR_NAME / batch_id
assert sibling_dir.is_dir()
assert not model.exists()
assert not preview.exists()
await service.undo(batch_id)
assert model.read_bytes() == b"payload-1"
assert preview.read_bytes() == b"payload-2"
assert not sibling_dir.exists()
assert batch_id not in service._known_batch_dirs
# (c) ROOT GATING: _find_model_root -> None skips staging entirely.
async def test_sibling3_root_gating_skips_staging(tmp_path: Path, monkeypatch) -> None:
root = tmp_path / "loras"
root.mkdir()
model = root / "model.safetensors"
model.write_bytes(b"keep-me")
service = await PendingDeleteService.get_instance()
monkeypatch.setattr(service, "_find_model_root", lambda _scanner, _path: None)
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(root),
file_name="model",
main_extension=".safetensors",
original_file_path=str(model),
cached_entry=None,
)
assert batch_id is None
assert model.read_bytes() == b"keep-me"
assert not (root / PENDING_DELETE_DIR_NAME).exists()
# QA scenario: simulated OSError on the 2nd artifact during sibling staging ->
# rollback renames the 1st back, returns None, and leaves no orphaned sibling
# batch dir behind.
async def test_sibling4_staging_oserror_rolls_back_sibling_dir(
tmp_path: Path, monkeypatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
sub = root / "nested"
sub.mkdir()
a = sub / "model.safetensors"
a.write_bytes(b"a-bytes")
b = sub / "model.metadata.json"
b.write_bytes(b"b-bytes")
service = await PendingDeleteService.get_instance()
real_rename = os.rename
calls = {"n": 0}
def flaky_rename(src: str, dst: str) -> None:
calls["n"] += 1
if calls["n"] == 2:
raise OSError("simulated sibling staging failure")
return real_rename(src, dst)
monkeypatch.setattr("py.services.pending_delete_service.os.rename", flaky_rename)
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(sub),
file_name="model",
main_extension=".safetensors",
original_file_path=str(a),
cached_entry=None,
)
assert batch_id is None
# Both artifacts rolled back; no orphaned sibling batch dir holds data.
assert a.read_bytes() == b"a-bytes"
assert b.read_bytes() == b"b-bytes"
sibling = sub / PENDING_DELETE_DIR_NAME
if sibling.exists():
assert not any(sibling.iterdir())
# (d) SCANNER EXCLUSION at a NESTED staging dir: a model staged into
# <root>/sub/.lm-pending-delete is excluded from the walk just like the
# root-level one (depth independence).
async def test_p_model_walk_excludes_nested_staging_dir(
tmp_path: Path, monkeypatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
(root / "normal.safetensors").write_bytes(b"normal")
sub = root / "sub"
sub.mkdir()
(sub / "real.safetensors").write_bytes(b"real")
nested_staging = sub / PENDING_DELETE_DIR_NAME / "x"
nested_staging.mkdir(parents=True)
(nested_staging / "model.safetensors").write_bytes(b"ghost")
(nested_staging / "model.metadata.json").write_bytes(b'{"hash_status": "pending"}')
# Root-level staging dir for comparison.
root_staging = root / PENDING_DELETE_DIR_NAME / "y"
root_staging.mkdir(parents=True)
(root_staging / "ghost2.safetensors").write_bytes(b"ghost2")
from py.services import model_scanner as model_scanner_module
async def _noop_register(*_args: Any, **_kwargs: Any) -> None:
return None
monkeypatch.setattr(model_scanner_module.ServiceRegistry, "register_service", _noop_register)
monkeypatch.setenv("LORA_MANAGER_DISABLE_PERSISTENT_CACHE", "1")
scanner = DummyScannerForWalk(root)
result = await scanner._gather_model_data()
paths = [entry["file_path"] for entry in result.raw_data]
assert not any(PENDING_DELETE_DIR_NAME in p for p in paths)
# Real files at both depths are still discovered.
assert any(p.endswith("normal.safetensors") for p in paths)
assert any(p.endswith("sub/real.safetensors") for p in paths)
assert scanner._count_model_files() == 2
# ---------------------------------------------------------------------------
# Todo 3: REGRESSION SUITE for the undo-delete symlink fix
# (real symlink round-trip, restart-undo, reconciliation, folder-deleted
# edge, merge EXDEV-abort + sequential undo)
# ---------------------------------------------------------------------------
# (a) SYMLINK ROUND-TRIP: staging through a symlinked dir must resolve into
# the REAL directory (sibling staging derives the batch dir from the
# model's own dir, so a nested symlink lands in the target of the link),
# never raise EXDEV, restore byte-identically at the business paths, and
# purge cleanly after expiry. This is the primary regression proof:
# PRE-FIX the batch was staged under the scanner ROOT, so
# ``real_batch.is_dir()`` (real_dir/.lm-pending-delete/<batch>) would have
# failed - the batch would have lived at <root>/.lm-pending-delete.
async def test_symlink1_stage_undo_round_trip_through_symlink(tmp_path: Path) -> None:
root = tmp_path / "loras"
root.mkdir()
real_dir = tmp_path / "real_dir"
real_dir.mkdir()
# The business path traverses a symlink NESTED under the scanner root.
link_dir = root / "link_dir"
os.symlink(real_dir, link_dir, target_is_directory=True)
model = link_dir / "model.safetensors"
model.write_bytes(b"model-payload")
metadata = link_dir / "model.metadata.json"
metadata.write_bytes(b'{"k": "v"}')
preview = link_dir / "model.preview.webp"
preview.write_bytes(b"preview-payload")
service = await PendingDeleteService.get_instance()
batch_id = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(link_dir),
file_name="model",
main_extension=".safetensors",
original_file_path=str(model),
cached_entry=None,
)
# Staging succeeded - no EXDEV, no silent hard-delete fallback.
assert batch_id is not None
# The registry records the BUSINESS path (symlink preserved, abspath only).
business_batch = link_dir / PENDING_DELETE_DIR_NAME / batch_id
assert service._known_batch_dirs[batch_id] == str(business_batch)
# ... and the dir itself resolves through the symlink into the REAL dir.
real_batch = real_dir / PENDING_DELETE_DIR_NAME / batch_id
assert real_batch.is_dir()
assert os.path.realpath(str(business_batch)) == str(real_batch)
assert (real_batch / "model.safetensors").read_bytes() == b"model-payload"
# Originals renamed away at the business paths.
assert not model.exists()
assert not metadata.exists()
assert not preview.exists()
# Undo restores byte-identically AT the business paths (through the link).
await service.undo(batch_id)
assert (link_dir / "model.safetensors").read_bytes() == b"model-payload"
assert (link_dir / "model.metadata.json").read_bytes() == b'{"k": "v"}'
assert (link_dir / "model.preview.webp").read_bytes() == b"preview-payload"
staging = real_dir / PENDING_DELETE_DIR_NAME
assert not staging.exists() or not any(staging.iterdir())
# Purge after expiry leaves the REAL directory clean.
batch_id2 = await service.stage_model_delete(
scanner=ScannerForStage([root]),
target_dir=str(link_dir),
file_name="model",
main_extension=".safetensors",
original_file_path=str(link_dir / "model.safetensors"),
cached_entry=None,
)
assert batch_id2 is not None
real_batch2 = real_dir / PENDING_DELETE_DIR_NAME / batch_id2
manifest_path = real_batch2 / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["expires_at"] = int(time.time()) - 10
manifest_path.write_text(json.dumps(manifest))
await service.purge_expired()
assert not (real_dir / "model.safetensors").exists()
assert not staging.exists() or not any(staging.iterdir())
# (b) RESTART-UNDO: with an empty in-process registry (simulated restart) undo
# still locates the batch via the scan fallback, restores it, and
# re-registers it (transiently) before the dir is removed.
async def test_symlink2_restart_undo_empty_registry_scan_fallback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
assert batch_id in service._known_batch_dirs
# Simulate a restart: the batch dir survives on disk, the registry does not.
service._known_batch_dirs.clear()
assert service._known_batch_dirs == {}
# Spy on the re-registration performed by the scan fallback inside undo.
registrations: List[Tuple[str, str]] = []
real_remember = service._remember_batch
async def _spy_remember(bid: str, bdir: str) -> None:
registrations.append((bid, bdir))
await real_remember(bid, bdir)
monkeypatch.setattr(service, "_remember_batch", _spy_remember)
result = await service.undo(batch_id)
assert result["batch_id"] == batch_id
assert (root / "model.safetensors").read_bytes() == b"model-data"
assert not batch_dir.exists()
# The scan fallback re-registered the batch during the undo lookup; undo
# then forgets it once the batch dir is removed.
assert (batch_id, str(batch_dir)) in registrations
assert batch_id not in service._known_batch_dirs
# (c) RECONCILIATION: crash leftovers hand-written in a NESTED staging parent
# (the sibling staging location for a model in a root subdir) are found by
# the startup sweep: expired ones purged, fresh ones registered. The
# registry-only default does NOT discover them.
async def test_symlink3_reconciliation_nested_staging_parents(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
nested = root / "sub"
nested.mkdir()
await _register_model_root(monkeypatch, lora_roots=[root])
expired_dir = nested / PENDING_DELETE_DIR_NAME / "crash-expired"
expired_dir.mkdir(parents=True)
(expired_dir / "old.safetensors").write_bytes(b"old")
_write_batch_manifest(
expired_dir,
batch_id="crash-expired",
kind="model",
model_type="loras",
expires_at=int(time.time()) - 10,
entries=[
{
"staged": str(expired_dir / "old.safetensors"),
"original": str(nested / "old.safetensors"),
"restored": False,
}
],
)
fresh_dir = nested / PENDING_DELETE_DIR_NAME / "crash-fresh"
fresh_dir.mkdir(parents=True)
(fresh_dir / "new.safetensors").write_bytes(b"new")
_write_batch_manifest(
fresh_dir,
batch_id="crash-fresh",
kind="model",
model_type="loras",
expires_at=int(time.time()) + 100,
entries=[
{
"staged": str(fresh_dir / "new.safetensors"),
"original": str(nested / "new.safetensors"),
"restored": False,
}
],
)
service = await PendingDeleteService.get_instance()
assert service._known_batch_dirs == {}
# Registry-only default: the externally created (crash-leftover) batches
# are invisible.
await service.purge_expired()
assert expired_dir.is_dir()
assert fresh_dir.is_dir()
# Reconciliation pass (startup sweep): expired purged, fresh registered.
await service.purge_expired(scan_roots=True)
assert not expired_dir.exists()
assert not (nested / "old.safetensors").exists()
assert fresh_dir.is_dir()
assert (fresh_dir / "new.safetensors").exists()
assert "crash-expired" not in service._known_batch_dirs
assert service._known_batch_dirs.get("crash-fresh") == str(fresh_dir)
# (d) FOLDER-DELETED EDGE: the model's whole folder is deleted during the undo
# window (the batch lived inside it - accepted edge). undo() must surface
# ValueError (batch gone) without crashing and forget the stale registry
# entry.
async def test_symlink4_folder_deleted_edge_forgets_stale_registry(
tmp_path: Path,
) -> None:
root = tmp_path / "loras"
root.mkdir()
service = await PendingDeleteService.get_instance()
batch_id = await _stage_simple(service, root, "model")
batch_dir = root / PENDING_DELETE_DIR_NAME / batch_id
assert batch_id in service._known_batch_dirs
# The model's folder (and with it the sibling batch dir) vanishes.
shutil.rmtree(root)
with pytest.raises(ValueError, match="Unknown batch"):
await service.undo(batch_id)
# No stale registry entry survives the failed undo.
assert batch_id not in service._known_batch_dirs
assert not batch_dir.exists()
# (e) MERGE EXDEV-ABORT: a cross-volume merge abort leaves the registry
# untouched AND the constituent batches individually undoable - sequential
# undo after the abort restores every file. (The merge-success winner/loser
# registry half is covered by test_reg_e; this adds the post-abort undo
# proof.)
async def test_symlink5_merge_exdev_abort_registry_unchanged_then_sequential_undo(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
root = tmp_path / "loras"
root.mkdir()
_spy_purge_timers(monkeypatch)
service = await PendingDeleteService.get_instance()
bid_a = await _stage_simple(service, root, "alpha")
bid_b = await _stage_simple(service, root, "beta")
assert set(service._known_batch_dirs) == {bid_a, bid_b}
real_rename = os.rename
fail_next = {"enabled": True}
def exdev_rename(src: str, dst: str) -> None:
if fail_next["enabled"]:
raise OSError(errno.EXDEV, "Invalid cross-device link", src, dst)
return real_rename(src, dst)
monkeypatch.setattr("py.services.pending_delete_service.os.rename", exdev_rename)
before = dict(service._known_batch_dirs)
assert await service.merge_batches([bid_a, bid_b]) is None
assert dict(service._known_batch_dirs) == before
# Sequential undo of the constituents after the abort restores everything.
fail_next["enabled"] = False
await service.undo(bid_a)
await service.undo(bid_b)
assert (root / "alpha.safetensors").read_bytes() == b"alpha-data"
assert (root / "beta.safetensors").read_bytes() == b"beta-data"
staging = root / PENDING_DELETE_DIR_NAME
assert not staging.exists() or not any(staging.iterdir())
-24
View File
@@ -31,7 +31,6 @@ from py.services.recipes.persistence_service import (
PersistenceResult,
RecipePersistenceService,
)
from py.services.settings_manager import get_settings_manager
from py.utils import settings_paths
@@ -223,29 +222,6 @@ async def test_delete_recipe_skips_missing_preview_image(tmp_path: Path) -> None
assert scanner.removed == ["r2"]
# ---------------------------------------------------------------------------
# (3) undo disabled -> no staging, payload batch_id None, existing behavior
# ---------------------------------------------------------------------------
async def test_delete_recipe_undo_disabled_no_staging(tmp_path: Path) -> None:
get_settings_manager().settings["delete_undo_enabled"] = False
scanner = RecipeScannerStub(tmp_path)
json_path, image_path, _recipe_data = _write_recipe(tmp_path, "r3")
scanner.register_recipe("r3", json_path)
result = await _make_service().delete_recipe(
recipe_scanner=scanner, recipe_id="r3"
)
assert result.payload["batch_id"] is None
# No staging leftovers when undo is disabled.
assert not _staging_parent().exists()
# Existing hard delete behavior unchanged.
assert not json_path.exists()
assert not image_path.exists()
assert scanner.removed == ["r3"]
# ---------------------------------------------------------------------------
# (4) bulk_delete with 2 ids -> single batch_id, one batch dir with both
# recipes, re-anchored expires_at in the merged manifest
+584 -4
View File
@@ -1047,6 +1047,106 @@ async def test_get_paginated_data_sorting(recipe_scanner):
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
@pytest.mark.asyncio
async def test_get_paginated_data_random_sort(recipe_scanner):
scanner, _ = recipe_scanner
# Add test recipes
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
await scanner.add_recipe(
{
"id": rid,
"title": title,
"created_date": 10.0,
"loras": [{}],
"file_path": f"{rid.lower()}.png",
}
)
await asyncio.sleep(0)
await _wait_for_resort(scanner)
# Same seed -> same order (deterministic, stable pagination)
res1 = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="random:seed123"
)
res2 = await scanner.get_paginated_data(
page=1, page_size=10, sort_by="random:seed123"
)
ids1 = [i["id"] for i in res1["items"]]
ids2 = [i["id"] for i in res2["items"]]
assert ids1 == ids2
assert sorted(ids1) == ["A", "B", "C"]
# Plain "random" (no seed) also returns the full set
res3 = await scanner.get_paginated_data(page=1, page_size=10, sort_by="random")
assert sorted(i["id"] for i in res3["items"]) == ["A", "B", "C"]
# Stable pagination: page1 + page2 with the same seed concatenate to the
# full seeded order, with no duplicates across pages
p1 = await scanner.get_paginated_data(
page=1, page_size=2, sort_by="random:seed123"
)
p2 = await scanner.get_paginated_data(
page=2, page_size=2, sort_by="random:seed123"
)
combined = [i["id"] for i in p1["items"]] + [i["id"] for i in p2["items"]]
assert combined == ids1
assert len(set(combined)) == 3
@pytest.mark.asyncio
async def test_get_paginated_data_opened_sort(recipe_scanner, monkeypatch):
scanner, _ = recipe_scanner
for rid, title in [("A", "Alpha"), ("B", "Beta"), ("C", "Gamma")]:
await scanner.add_recipe(
{
"id": rid,
"title": title,
"created_date": 10.0,
"loras": [{}],
"file_path": f"{rid.lower()}.png",
}
)
await asyncio.sleep(0)
await _wait_for_resort(scanner)
class _FakeStats:
def get_opened_map(self):
return {"B": 300.0, "C": 200.0}
monkeypatch.setattr(
"py.services.recipe_scanner.RecipeOpenStats", lambda: _FakeStats()
)
# Never-opened A is hidden from the view; B (300) > C (200)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
assert [i["id"] for i in res["items"]] == ["B", "C"]
assert res["total"] == 2
# ASC: C (200) < B (300)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:asc")
assert [i["id"] for i in res["items"]] == ["C", "B"]
# Plain "opened" (no direction) behaves like desc by default
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened")
assert [i["id"] for i in res["items"]] == ["B", "C"]
# When nothing was opened the view is empty (not a fallback reorder)
class _EmptyStats:
def get_opened_map(self):
return {}
monkeypatch.setattr(
"py.services.recipe_scanner.RecipeOpenStats", lambda: _EmptyStats()
)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="opened:desc")
assert res["items"] == []
assert res["total"] == 0
async def test_build_image_id_map_filters_correctly(recipe_scanner):
"""Only recipes with valid CivitAI source_path appear in image_id_map.
@@ -1783,9 +1883,10 @@ async def test_is_rematch_candidate_rejects_healthy_entry(tmp_path: Path):
assert not scanner._is_rematch_candidate({"hash": "abc", "file_name": "m.safetensors"})
async def test_is_rematch_candidate_rejects_no_identifier(tmp_path: Path):
async def test_is_rematch_candidate_file_name_only_is_identifier(tmp_path: Path):
scanner, _, _ = _make_rematch_scanner([], [], tmp_path)
assert not scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
# file_name alone is now an identifier (enables the L4 filename fallback)
assert scanner._is_rematch_candidate({"isDeleted": True, "file_name": "m.safetensors"})
assert not scanner._is_rematch_candidate({"isDeleted": True})
@@ -2120,6 +2221,481 @@ async def test_match_rematch_type_gate_lora_accepts_lora_typed_item(tmp_path: Pa
assert matched is not None
# _match_rematch_entry — L4 filename fallback (conservative)
async def test_match_rematch_entry_l4_filename_hit(tmp_path: Path):
item = _rematch_item(
sha256=("T1" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_filename_normalized_key(tmp_path: Path):
# case, path and extension differences are normalized on both sides
item = _rematch_item(
sha256=("T2" * 32).lower(),
sub_type="lora",
base_model="SDXL",
file_name="My_Mix.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "subdir/my_mix", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="sdxl",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_dotted_stem_no_collision(tmp_path: Path):
# "my.mix" (dotted stem) and "my" are distinct names — splitext-style
# stripping would collapse both to "my" and bind the wrong model as a
# unique candidate.
item = _rematch_item(
sha256=("T2A" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="my.mix",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "my", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_extension_bearing_entry_reconciled(tmp_path: Path):
# extension-bearing entry names reconcile with extensionless items
item = _rematch_item(
sha256=("T2B" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="my.mix.v1",
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "my.mix.v1.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_base_model_mismatch_rejects(tmp_path: Path):
item = _rematch_item(
sha256=("T3" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SDXL",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_recipe_base_model_unknown_rejects(tmp_path: Path):
item = _rematch_item(
sha256=("T4" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_item_base_model_unknown_rejects(tmp_path: Path):
item = _rematch_item(
sha256=("T5" * 32).lower(), sub_type="lora", file_name="detail.safetensors"
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_ambiguous_same_base_model_rejects(tmp_path: Path):
items = [
_rematch_item(
sha256=("T6" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
),
_rematch_item(
sha256=("T7" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
),
]
scanner, _, _ = _make_rematch_scanner(items, [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_ambiguity_resolved_by_base_model(tmp_path: Path):
sdxl_item = _rematch_item(
sha256=("T8" * 32).lower(),
sub_type="lora",
base_model="SDXL",
file_name="detail.safetensors",
)
sd15_item = _rematch_item(
sha256=("T9" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([sdxl_item, sd15_item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SDXL",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_type_gate_rejects(tmp_path: Path):
# a checkpoint-typed item with a matching name must not satisfy a lora entry
item = _rematch_item(
sha256=("TA" * 32).lower(),
sub_type="checkpoint",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_checkpoint_slot_rejects_type_less_candidate(
tmp_path: Path,
):
# lora raw items often carry no sub_type; an unknown-type candidate must
# not be bound into a checkpoint slot
item = _rematch_item(
sha256=("TA1" * 32).lower(),
base_model="SD 1.5",
file_name="realistic.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "realistic.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=True,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_checkpoint_slot_accepts_typed_candidate(
tmp_path: Path,
):
item = _rematch_item(
sha256=("TA2" * 32).lower(),
sub_type="checkpoint",
base_model="SD 1.5",
file_name="realistic.safetensors",
)
scanner, _, checkpoint = _make_rematch_scanner([], [item], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "realistic.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=True,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is checkpoint._cache.raw_data[0]
assert level == "L4"
async def test_match_rematch_entry_l4_lora_slot_accepts_type_less_candidate(tmp_path: Path):
# asymmetry: lora slots still accept type-less candidates (the norm for
# lora raw items); checkpoint items always carry sub_type, so the type
# gate alone protects the reverse direction
item = _rematch_item(
sha256=("TA3" * 32).lower(), base_model="SD 1.5", file_name="detail.safetensors"
)
scanner, lora, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "detail.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L4"
async def test_rematch_l4_entry_base_model_preferred_over_recipe(tmp_path: Path, monkeypatch):
# a Pony lora inside an SD 1.5 recipe matches via its own baseModel
item = _rematch_item(
sha256=("TB1" * 32).lower(),
sub_type="lora",
base_model="Pony",
file_name="pony.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
saved, _ = await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_fts(scanner, monkeypatch)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [
{"file_name": "pony.safetensors", "isDeleted": True, "baseModel": "Pony"}
],
}
rematched, _errors, details = await scanner._rematch_single_recipe(
recipe, {}, {}, filename_cache
)
assert rematched == 1
assert details["matched"][0]["match_level"] == "L4"
assert recipe["loras"][0]["hash"] == ("TB1" * 32).lower()
assert saved == [recipe]
async def test_rematch_l4_entry_base_model_missing_falls_back_to_recipe(
tmp_path: Path, monkeypatch
):
# without entry-level baseModel the recipe-level gate governs: a Pony
# candidate must not match an SD 1.5 recipe
item = _rematch_item(
sha256=("TB2" * 32).lower(),
sub_type="lora",
base_model="Pony",
file_name="pony.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
await _spy_rematch_persistence(scanner, monkeypatch)
await _spy_fts(scanner, monkeypatch)
recipe: Dict[str, Any] = {
"id": "r1",
"base_model": "SD 1.5",
"loras": [{"file_name": "pony.safetensors", "isDeleted": True}],
}
rematched, _errors, details = await scanner._rematch_single_recipe(
recipe, {}, {}, filename_cache
)
assert rematched == 0
assert details["unresolved"] == [{"type": "lora", "entry": "pony.safetensors"}]
async def test_match_rematch_entry_l4_no_filename_hit(tmp_path: Path):
item = _rematch_item(
sha256=("TB" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="other.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"file_name": "missing.safetensors", "isDeleted": True},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l4_entry_without_file_name_skipped(tmp_path: Path):
item = _rematch_item(
sha256=("TC" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, _, _ = _make_rematch_scanner([item], [], tmp_path)
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"isDeleted": True, "hash": ""},
{},
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert (matched, level) == (None, None)
async def test_match_rematch_entry_l1_wins_over_l4_filename(tmp_path: Path):
# a valid stored hash resolves via L1 even when the filename would match
sha256 = ("TD" * 32).lower()
l1_item = _rematch_item(
sha256=sha256, sub_type="lora", base_model="SD 1.5", file_name="l1-item.safetensors"
)
l4_item = _rematch_item(
sha256=("TE" * 32).lower(),
sub_type="lora",
base_model="SD 1.5",
file_name="detail.safetensors",
)
scanner, lora, _ = _make_rematch_scanner([l1_item, l4_item], [], tmp_path)
local_cache = await scanner.build_local_hash_cache()
filename_cache = await scanner._build_local_filename_cache()
matched, level = await scanner._match_rematch_entry_with_level(
{"hash": sha256, "file_name": "detail.safetensors", "isDeleted": True},
local_cache,
{},
is_checkpoint=False,
filename_cache=filename_cache,
recipe_base_model="SD 1.5",
)
assert matched is lora._cache.raw_data[0]
assert level == "L1"
# _build_local_filename_cache
async def test_build_local_filename_cache_normalized_keys_sha256_only(tmp_path: Path):
lora_items = [
_rematch_item(sha256=("TF" * 32).lower(), file_name="Case.Mix.safetensors"),
_rematch_item(sha256="", file_name="no-hash.safetensors"), # skipped
]
checkpoint_items = [
_rematch_item(
sha256=("TG" * 32).lower(), sub_type="checkpoint", file_name="Base.safetensors"
)
]
scanner, lora, checkpoint = _make_rematch_scanner(
lora_items, checkpoint_items, tmp_path
)
result = await scanner._build_local_filename_cache()
assert set(result) == {"case.mix", "base"}
assert len(result["case.mix"]) == 1
assert result["case.mix"][0] is lora._cache.raw_data[0]
# checkpoint items are indexed too (type-blind cache)
assert result["base"][0] is checkpoint._cache.raw_data[0]
# _build_rematch_autov3_cache
@@ -2989,6 +3565,7 @@ async def test_rematch_all_recipes_per_recipe_error_continues_loop(
recipe: Dict[str, Any],
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
filename_cache=None,
) -> tuple[int, int, dict[str, Any]]:
if recipe.get("id") == "boom":
raise RuntimeError("kaboom")
@@ -3046,12 +3623,13 @@ async def test_rematch_all_recipes_holds_mutation_lock(tmp_path: Path, monkeypat
recipe: Dict[str, Any],
local_cache: dict[str, Any],
autov3_cache: dict[str, Any],
) -> tuple[int, int]:
filename_cache=None,
) -> tuple[int, int, dict[str, Any]]:
nonlocal entered
if recipe.get("id") == "r0":
entered = True
await release.wait()
return await original(recipe, local_cache, autov3_cache)
return await original(recipe, local_cache, autov3_cache, filename_cache)
monkeypatch.setattr(scanner, "_rematch_single_recipe", blocking_single)
@@ -3171,6 +3749,8 @@ async def test_rematch_bulk_generic_exception_continues(tmp_path: Path, monkeypa
autov3_cache: dict[str, Any],
*,
is_checkpoint: bool,
filename_cache=None,
recipe_base_model=None,
) -> Any:
nonlocal calls
calls += 1
-5
View File
@@ -1178,8 +1178,3 @@ def test_skip_previously_downloaded_model_versions_coerces_string_input(manager)
assert manager.get_skip_previously_downloaded_model_versions() is True
assert manager.settings["skip_previously_downloaded_model_versions"] is True
def test_delete_undo_enabled_defaults_true(manager):
assert settings_manager_module.DEFAULT_SETTINGS.get("delete_undo_enabled") is True
assert manager.get("delete_undo_enabled") is True
+159
View File
@@ -0,0 +1,159 @@
import asyncio
import contextlib
import json
from pathlib import Path
import pytest
from py.utils import recipe_open_stats as stats_module
from py.utils.recipe_open_stats import RecipeOpenStats
async def _finalize(tasks) -> None:
for task in tasks:
task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await task
RecipeOpenStats._instance = None
def _prepare(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
RecipeOpenStats._instance = None
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr(
stats_module, "get_settings_dir", lambda create=True: str(settings_dir)
)
created_tasks = []
real_create_task = stats_module.asyncio.create_task
def _track_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
monkeypatch.setattr(stats_module.asyncio, "create_task", _track_task)
return RecipeOpenStats(), created_tasks, settings_dir
async def _wait_for_save(stats_file: Path) -> None:
for _ in range(100):
if stats_file.exists():
return
await asyncio.sleep(0.01)
raise AssertionError("Recipe open stats file was never written")
@pytest.mark.asyncio
async def test_record_open_persists_timestamp(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("abc-123")
await _wait_for_save(stats_file)
data = json.loads(stats_file.read_text(encoding="utf-8"))
assert isinstance(data["abc-123"], float)
await _finalize(tasks)
@pytest.mark.asyncio
async def test_record_open_updates_existing_entry(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("r1")
await _wait_for_save(stats_file)
first = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
await asyncio.sleep(0.01)
stats.record_open("r1")
await stats.save_stats(force=True)
second = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
assert second > first
await _finalize(tasks)
@pytest.mark.asyncio
async def test_get_opened_map_reloads_on_file_change(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("r1")
await _wait_for_save(stats_file)
stats_file.write_text(json.dumps({"r2": 500.0}), encoding="utf-8")
opened_map = stats.get_opened_map()
assert opened_map == {"r2": 500.0}
await _finalize(tasks)
@pytest.mark.asyncio
async def test_save_merges_entries_written_by_another_process(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats.record_open("r1")
await _wait_for_save(stats_file)
first_ts = json.loads(stats_file.read_text(encoding="utf-8"))["r1"]
# Another process writes its own entry plus a newer timestamp for r1
stats_file.write_text(
json.dumps({"r1": first_ts + 100000.0, "r2": 500.0}), encoding="utf-8"
)
stats.record_open("r3")
await stats.save_stats(force=True)
data = json.loads(stats_file.read_text(encoding="utf-8"))
# r2 from the other process survives; r1 keeps the newer disk timestamp;
# r3 from this process is added
assert data["r1"] == first_ts + 100000.0
assert data["r2"] == 500.0
assert isinstance(data["r3"], float)
await _finalize(tasks)
@pytest.mark.asyncio
async def test_get_opened_map_returns_copy(tmp_path, monkeypatch):
stats, tasks, _ = _prepare(tmp_path, monkeypatch)
stats.record_open("r1")
opened_map = stats.get_opened_map()
opened_map["injected"] = 1.0
assert "injected" not in stats.get_opened_map()
await _finalize(tasks)
@pytest.mark.asyncio
async def test_missing_stats_file_returns_empty_map(tmp_path, monkeypatch):
stats, tasks, _ = _prepare(tmp_path, monkeypatch)
assert stats.get_opened_map() == {}
await _finalize(tasks)
@pytest.mark.asyncio
async def test_save_stats_skips_when_not_dirty(tmp_path, monkeypatch):
stats, tasks, settings_dir = _prepare(tmp_path, monkeypatch)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
assert await stats.save_stats() is False
assert not stats_file.exists()
await _finalize(tasks)
@pytest.mark.asyncio
async def test_load_ignores_corrupt_file(tmp_path, monkeypatch):
settings_dir = tmp_path / "settings"
settings_dir.mkdir(parents=True, exist_ok=True)
stats_file = settings_dir / "stats" / RecipeOpenStats.STATS_FILENAME
stats_file.parent.mkdir(parents=True, exist_ok=True)
stats_file.write_text("{not valid json", encoding="utf-8")
monkeypatch.setattr(
stats_module, "get_settings_dir", lambda create=True: str(settings_dir)
)
RecipeOpenStats._instance = None
stats = RecipeOpenStats()
assert stats.get_opened_map() == {}
@@ -78,7 +78,17 @@ const updateHasTextState = () => {
hasText.value = textareaRef.value ? textareaRef.value.value.length > 0 : false
}
const onInput = () => {
const onInput = (event: Event) => {
// A clear via execCommand captures the full-text selection in the browser's
// undo entry; Ctrl+Z restores the content together with that selection.
// Collapse the caret so the restored text is not left selected.
if ((event as InputEvent).inputType === 'historyUndo') {
const ta = textareaRef.value
if (ta && ta.selectionStart === 0 && ta.selectionEnd === ta.value.length) {
ta.setSelectionRange(ta.value.length, ta.value.length)
}
}
// Update hasText state
updateHasTextState()
@@ -156,20 +166,44 @@ const setupWidgetOnSetValue = () => {
}
}
/**
* Clear the textarea contents.
*
* Uses a trusted editing command (execCommand: select all + replace with
* empty string) so the browser records the clear as an undoable edit
* Ctrl+Z with focus in the textarea restores the cleared text. Falls back
* to a plain programmatic clear when execCommand is unavailable (e.g. jsdom
* test environment), which is not undoable via native Ctrl+Z.
*/
const clearText = () => {
if (textareaRef.value) {
textareaRef.value.value = ''
hasText.value = false
textareaRef.value.focus()
// Trigger callback with empty value
if (typeof props.widget.callback === 'function') {
props.widget.callback('')
}
// Dispatch input event to ensure autocomplete handles the change
textareaRef.value.dispatchEvent(new Event('input'))
const ta = textareaRef.value
if (!ta || ta.value.length === 0) return
// Select all + replace via a trusted edit command so the browser pushes an
// undo entry that restores the full previous content.
ta.focus()
ta.setSelectionRange(0, ta.value.length)
let ok = false
try {
// Guarded for engines without execCommand (jsdom); some engines also
// throw instead of returning false for unsupported commands.
ok = typeof document.execCommand === 'function' && document.execCommand('insertText', false, '')
} catch {
ok = false
}
if (ok) {
// execCommand fired a trusted 'input' event onInput already synced
// hasText, called the widget callback, and notified the autocomplete.
hasText.value = false
return
}
// Fallback: execCommand unavailable (jsdom / unsupported browser) plain
// programmatic clear. The dispatched input event keeps onInput, the widget
// callback, and the autocomplete in sync.
ta.value = ''
ta.dispatchEvent(new Event('input'))
}
onMounted(() => {

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