mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 03:24:09 -03:00
Compare commits
28
Commits
f1d3ac0cdc
...
v1.2.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94dd08646d | ||
|
|
658f88ca48 | ||
|
|
f53352efb2 | ||
|
|
38809a9d1b | ||
|
|
395682509c | ||
|
|
ef3e7d7bf4 | ||
|
|
c85b6b64a1 | ||
|
|
34c87d4934 | ||
|
|
93472e5d67 | ||
|
|
ae185ee714 | ||
|
|
795036275a | ||
|
|
d43ab6e32f | ||
|
|
280181f92e | ||
|
|
f8d98934ad | ||
|
|
303cca0d85 | ||
|
|
c2f16784b3 | ||
|
|
5bc6d8286c | ||
|
|
3f8381ffee | ||
|
|
1ca99294c9 | ||
|
|
680f0a57f5 | ||
|
|
94e3f54571 | ||
|
|
5c2b2aedcc | ||
|
|
ebc31fb963 | ||
|
|
9659df6ad9 | ||
|
|
04d131e9dc | ||
|
|
78fe6282c7 | ||
|
|
0c00ee22fc | ||
|
|
5fd4946b1f |
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+28
-11
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),)
|
||||
|
||||
@@ -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,)
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user