mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-22 11:34:08 -03:00
Compare commits
36
Commits
41e1fd1e1f
...
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 | ||
|
|
f1d3ac0cdc | ||
|
|
e2c45905f0 | ||
|
|
b2c68e6a65 | ||
|
|
eb0f6dd3b6 | ||
|
|
0bf87f9092 | ||
|
|
1da2433bb2 | ||
|
|
2d6cf545b9 | ||
|
|
6a259a14fa |
@@ -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.lora_loader import LoraLoaderLM, LoraTextLoaderLM
|
||||||
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
|
from .py.nodes.checkpoint_loader import CheckpointLoaderLM
|
||||||
from .py.nodes.unet_loader import UNETLoaderLM
|
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.trigger_word_toggle import TriggerWordToggleLM
|
||||||
from .py.nodes.prompt import PromptLM
|
from .py.nodes.prompt import PromptLM
|
||||||
from .py.nodes.text import TextLM
|
from .py.nodes.text import TextLM
|
||||||
@@ -40,6 +42,12 @@ except (
|
|||||||
"py.nodes.checkpoint_loader"
|
"py.nodes.checkpoint_loader"
|
||||||
).CheckpointLoaderLM
|
).CheckpointLoaderLM
|
||||||
UNETLoaderLM = importlib.import_module("py.nodes.unet_loader").UNETLoaderLM
|
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(
|
TriggerWordToggleLM = importlib.import_module(
|
||||||
"py.nodes.trigger_word_toggle"
|
"py.nodes.trigger_word_toggle"
|
||||||
).TriggerWordToggleLM
|
).TriggerWordToggleLM
|
||||||
@@ -79,6 +87,8 @@ NODE_CLASS_MAPPINGS = {
|
|||||||
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
LoraTextLoaderLM.NAME: LoraTextLoaderLM,
|
||||||
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
|
CheckpointLoaderLM.NAME: CheckpointLoaderLM,
|
||||||
UNETLoaderLM.NAME: UNETLoaderLM,
|
UNETLoaderLM.NAME: UNETLoaderLM,
|
||||||
|
RandomCheckpointLoaderLM.NAME: RandomCheckpointLoaderLM,
|
||||||
|
RandomUNETLoaderLM.NAME: RandomUNETLoaderLM,
|
||||||
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
TriggerWordToggleLM.NAME: TriggerWordToggleLM,
|
||||||
LoraStackerLM.NAME: LoraStackerLM,
|
LoraStackerLM.NAME: LoraStackerLM,
|
||||||
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
|
LoraStackCombinerLM.NAME: LoraStackCombinerLM,
|
||||||
|
|||||||
+327
-295
File diff suppressed because it is too large
Load Diff
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "Früher Zugriff Updates ausblenden",
|
"label": "Früher Zugriff Updates ausblenden",
|
||||||
"help": "Nur Early-Access-Updates"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
"useNewStyle": "Aktualisierte Lizenzsymbole verwenden",
|
||||||
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
|
"useNewStyleHelp": "Lizenzberechtigungen mit farbigen Indikatoren (neuer Stil) oder nur Einschränkungssymbolen (klassischer Stil) anzeigen. Orientiert sich am aktuellen CivitAI-Design."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "Älteste",
|
"dateAsc": "Älteste",
|
||||||
"lorasCount": "LoRA-Anzahl",
|
"lorasCount": "LoRA-Anzahl",
|
||||||
"lorasCountDesc": "Meiste",
|
"lorasCountDesc": "Meiste",
|
||||||
"lorasCountAsc": "Wenigste"
|
"lorasCountAsc": "Wenigste",
|
||||||
|
"opened": "Zuletzt geöffnet",
|
||||||
|
"openedDesc": "Zuletzt geöffnet"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren",
|
"title": "Rezeptliste aktualisieren",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Nur Favoriten anzeigen",
|
"title": "Nur Favoriten anzeigen",
|
||||||
"action": "Favoriten"
|
"action": "Favoriten"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Rezepte-Layout",
|
||||||
|
"grid": "Raster-Layout",
|
||||||
|
"masonry": "Masonry-Layout (Pinterest-Stil, behält das Seitenverhältnis des Bildes bei)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "Gibt {size} frei",
|
||||||
"title": "Modell löschen",
|
"title": "Modell löschen",
|
||||||
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?"
|
"message": "Sind Sie sicher, dass Sie dieses Modell und alle zugehörigen Dateien löschen möchten?",
|
||||||
|
"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": {
|
"excludeModel": {
|
||||||
"title": "Modell ausschließen",
|
"title": "Modell ausschließen",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
"newerTooltip": "Diese Version ist neuer als Ihre neueste lokale Version",
|
||||||
"earlyAccess": "Früher Zugriff",
|
"earlyAccess": "Früher Zugriff",
|
||||||
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
"earlyAccessTooltip": "Für diese Version ist derzeit Civitai Early Access erforderlich",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "Ignoriert",
|
"ignored": "Ignoriert",
|
||||||
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
"ignoredTooltip": "Für diese Version sind Update-Benachrichtigungen deaktiviert",
|
||||||
"onSiteOnly": "Nur On-Site",
|
"onSiteOnly": "Nur On-Site",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "Herunterladen",
|
"download": "Herunterladen",
|
||||||
"downloadTooltip": "Diese Version herunterladen",
|
"downloadTooltip": "Diese Version herunterladen",
|
||||||
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
"downloadEarlyAccessTooltip": "Diese Early-Access-Version von Civitai herunterladen",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
"downloadNotAllowedTooltip": "Diese Version ist nur für die On-Site-Generierung auf Civitai verfügbar",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
"deleteTooltip": "Diese lokale Version löschen",
|
"deleteTooltip": "Diese lokale Version löschen",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "Rezept im Workflow ersetzt",
|
"recipeReplaced": "Rezept im Workflow ersetzt",
|
||||||
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
"recipeFailedToSend": "Fehler beim Senden des Rezepts an den Workflow",
|
||||||
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
"noMatchingNodes": "Keine kompatiblen Knoten im aktuellen Workflow verfügbar",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
"noTargetNodeSelected": "Kein Zielknoten ausgewählt",
|
||||||
"modelUpdated": "Modell im Workflow aktualisiert",
|
"modelUpdated": "Modell im Workflow aktualisiert",
|
||||||
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
"modelFailed": "Fehler beim Aktualisieren des Modellknotens",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
"updateFailed": "Fehler beim Aktualisieren der Trigger Words",
|
||||||
"copyFailed": "Kopieren fehlgeschlagen"
|
"copyFailed": "Kopieren fehlgeschlagen"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"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": {
|
"virtual": {
|
||||||
"loadFailed": "Fehler beim Laden der Elemente",
|
"loadFailed": "Fehler beim Laden der Elemente",
|
||||||
"loadMoreFailed": "Fehler beim Laden weiterer Elemente",
|
"loadMoreFailed": "Fehler beim Laden weiterer Elemente",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
"fileRenameFailed": "Fehler beim Umbenennen der Datei: {error}",
|
||||||
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
"previewUpdated": "Vorschau erfolgreich aktualisiert",
|
||||||
"previewUploadFailed": "Fehler beim Hochladen des Vorschaubilds",
|
"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",
|
"refreshComplete": "{action} abgeschlossen",
|
||||||
"refreshFailed": "Fehler beim {action} der {type}s",
|
"refreshFailed": "Fehler beim {action} der {type}s",
|
||||||
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
|
"metadataRefreshed": "Metadaten erfolgreich aktualisiert",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "Hide Early Access Updates",
|
"label": "Hide Early Access Updates",
|
||||||
"help": "When enabled, models with only early access updates will not show 'Update available' badge"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Use updated license icons",
|
"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."
|
"useNewStyleHelp": "Display license permissions with colored indicators (new style) or restriction-only icons (classic style). Mirroring the current CivitAI design."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "Oldest",
|
"dateAsc": "Oldest",
|
||||||
"lorasCount": "LoRA Count",
|
"lorasCount": "LoRA Count",
|
||||||
"lorasCountDesc": "Most",
|
"lorasCountDesc": "Most",
|
||||||
"lorasCountAsc": "Least"
|
"lorasCountAsc": "Least",
|
||||||
|
"opened": "Recently Opened",
|
||||||
|
"openedDesc": "Recently opened"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list",
|
"title": "Refresh recipe list",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Show Favorites Only",
|
"title": "Show Favorites Only",
|
||||||
"action": "Favorites"
|
"action": "Favorites"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Recipes Layout",
|
||||||
|
"grid": "Grid layout",
|
||||||
|
"masonry": "Masonry layout (Pinterest-style, preserves image aspect ratio)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "Frees {size}",
|
||||||
"title": "Delete Model",
|
"title": "Delete Model",
|
||||||
"message": "Are you sure you want to delete this model and all associated files?"
|
"message": "Are you sure you want to delete this model and all associated files?",
|
||||||
|
"recoverableWarning": "This will permanently delete the file after 20 seconds unless you undo."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "This action can be undone for 20 seconds."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Exclude Model",
|
"title": "Exclude Model",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "This version is newer than your latest local version",
|
"newerTooltip": "This version is newer than your latest local version",
|
||||||
"earlyAccess": "Early Access",
|
"earlyAccess": "Early Access",
|
||||||
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
"earlyAccessTooltip": "This version currently requires Civitai early access",
|
||||||
|
"paid": "Paid",
|
||||||
|
"paidTooltip": "This version requires payment to download",
|
||||||
"ignored": "Ignored",
|
"ignored": "Ignored",
|
||||||
"ignoredTooltip": "Update notifications are disabled for this version",
|
"ignoredTooltip": "Update notifications are disabled for this version",
|
||||||
"onSiteOnly": "On-Site Only",
|
"onSiteOnly": "On-Site Only",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTooltip": "Download this version",
|
"downloadTooltip": "Download this version",
|
||||||
"downloadEarlyAccessTooltip": "Download this early access version from Civitai",
|
"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",
|
"downloadNotAllowedTooltip": "This version is only available for on-site generation on Civitai",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"deleteTooltip": "Delete this local version",
|
"deleteTooltip": "Delete this local version",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "Recipe replaced in workflow",
|
"recipeReplaced": "Recipe replaced in workflow",
|
||||||
"recipeFailedToSend": "Failed to send recipe to workflow",
|
"recipeFailedToSend": "Failed to send recipe to workflow",
|
||||||
"noMatchingNodes": "No compatible nodes available in the current 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",
|
"noTargetNodeSelected": "No target node selected",
|
||||||
"modelUpdated": "Model updated in workflow",
|
"modelUpdated": "Model updated in workflow",
|
||||||
"modelFailed": "Failed to update model node",
|
"modelFailed": "Failed to update model node",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "Failed to update trigger words",
|
"updateFailed": "Failed to update trigger words",
|
||||||
"copyFailed": "Copy failed"
|
"copyFailed": "Copy failed"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "Undo",
|
||||||
|
"deleted": "Deleted {name}",
|
||||||
|
"deletedBulk": "Deleted {count} item(s)",
|
||||||
|
"expired": "Undo window expired. The item was permanently deleted.",
|
||||||
|
"failed": "Undo failed: {error}",
|
||||||
|
"restored": "Item restored"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Failed to load items",
|
"loadFailed": "Failed to load items",
|
||||||
"loadMoreFailed": "Failed to load more items",
|
"loadMoreFailed": "Failed to load more items",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "Failed to rename file: {error}",
|
"fileRenameFailed": "Failed to rename file: {error}",
|
||||||
"previewUpdated": "Preview updated successfully",
|
"previewUpdated": "Preview updated successfully",
|
||||||
"previewUploadFailed": "Failed to upload preview image",
|
"previewUploadFailed": "Failed to upload preview image",
|
||||||
|
"previewDropInvalid": "Unsupported file type: {name}. Drop an image or MP4 video instead.",
|
||||||
"refreshComplete": "{action} complete",
|
"refreshComplete": "{action} complete",
|
||||||
"refreshFailed": "Failed to {action} {type}s",
|
"refreshFailed": "Failed to {action} {type}s",
|
||||||
"metadataRefreshed": "Metadata refreshed successfully",
|
"metadataRefreshed": "Metadata refreshed successfully",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "Ocultar actualizaciones de acceso temprano",
|
"label": "Ocultar actualizaciones de acceso temprano",
|
||||||
"help": "Solo 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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Usar iconos de licencia actualizados",
|
"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."
|
"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."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "Más antiguo",
|
"dateAsc": "Más antiguo",
|
||||||
"lorasCount": "Cant. de LoRAs",
|
"lorasCount": "Cant. de LoRAs",
|
||||||
"lorasCountDesc": "Más",
|
"lorasCountDesc": "Más",
|
||||||
"lorasCountAsc": "Menos"
|
"lorasCountAsc": "Menos",
|
||||||
|
"opened": "Abiertos recientemente",
|
||||||
|
"openedDesc": "Abiertos recientemente"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas",
|
"title": "Actualizar lista de recetas",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Mostrar solo favoritos",
|
"title": "Mostrar solo favoritos",
|
||||||
"action": "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": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "Libera {size}",
|
||||||
"title": "Eliminar modelo",
|
"title": "Eliminar modelo",
|
||||||
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?"
|
"message": "¿Estás seguro de que quieres eliminar este modelo y todos los archivos asociados?",
|
||||||
|
"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": {
|
"excludeModel": {
|
||||||
"title": "Excluir modelo",
|
"title": "Excluir modelo",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
"newerTooltip": "Esta versión es más reciente que tu última versión local",
|
||||||
"earlyAccess": "Acceso temprano",
|
"earlyAccess": "Acceso temprano",
|
||||||
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
"earlyAccessTooltip": "Esta versión requiere actualmente acceso temprano de Civitai",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "Ignorada",
|
"ignored": "Ignorada",
|
||||||
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
"ignoredTooltip": "Las notificaciones de actualización están desactivadas para esta versión",
|
||||||
"onSiteOnly": "Solo en Sitio",
|
"onSiteOnly": "Solo en Sitio",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "Descargar",
|
"download": "Descargar",
|
||||||
"downloadTooltip": "Descargar esta versión",
|
"downloadTooltip": "Descargar esta versión",
|
||||||
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
"downloadEarlyAccessTooltip": "Descargar esta versión de acceso temprano desde Civitai",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
"downloadNotAllowedTooltip": "Esta versión solo está disponible para generación en el sitio de Civitai",
|
||||||
"delete": "Eliminar",
|
"delete": "Eliminar",
|
||||||
"deleteTooltip": "Eliminar esta versión local",
|
"deleteTooltip": "Eliminar esta versión local",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
"recipeReplaced": "Receta reemplazada en el flujo de trabajo",
|
||||||
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
"recipeFailedToSend": "Error al enviar receta al flujo de trabajo",
|
||||||
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
"noMatchingNodes": "No hay nodos compatibles disponibles en el flujo de trabajo actual",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
"noTargetNodeSelected": "No se ha seleccionado ningún nodo de destino",
|
||||||
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
"modelUpdated": "Modelo actualizado en el flujo de trabajo",
|
||||||
"modelFailed": "Error al actualizar nodo de modelo",
|
"modelFailed": "Error al actualizar nodo de modelo",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "Error al actualizar palabras clave",
|
"updateFailed": "Error al actualizar palabras clave",
|
||||||
"copyFailed": "Error al copiar"
|
"copyFailed": "Error al copiar"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"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": {
|
"virtual": {
|
||||||
"loadFailed": "Error al cargar elementos",
|
"loadFailed": "Error al cargar elementos",
|
||||||
"loadMoreFailed": "Error al cargar más elementos",
|
"loadMoreFailed": "Error al cargar más elementos",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
"fileRenameFailed": "Error al renombrar archivo: {error}",
|
||||||
"previewUpdated": "Vista previa actualizada exitosamente",
|
"previewUpdated": "Vista previa actualizada exitosamente",
|
||||||
"previewUploadFailed": "Error al subir imagen de vista previa",
|
"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",
|
"refreshComplete": "{action} completada",
|
||||||
"refreshFailed": "Error al {action} {type}s",
|
"refreshFailed": "Error al {action} {type}s",
|
||||||
"metadataRefreshed": "Metadatos actualizados exitosamente",
|
"metadataRefreshed": "Metadatos actualizados exitosamente",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "Masquer les mises à jour en accès anticipé",
|
"label": "Masquer les mises à jour en accès anticipé",
|
||||||
"help": "Seulement 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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Utiliser les icônes de licence mises à jour",
|
"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."
|
"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."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "Plus ancien",
|
"dateAsc": "Plus ancien",
|
||||||
"lorasCount": "Nombre de LoRAs",
|
"lorasCount": "Nombre de LoRAs",
|
||||||
"lorasCountDesc": "Plus",
|
"lorasCountDesc": "Plus",
|
||||||
"lorasCountAsc": "Moins"
|
"lorasCountAsc": "Moins",
|
||||||
|
"opened": "Récemment ouverts",
|
||||||
|
"openedDesc": "Récemment ouverts"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes",
|
"title": "Actualiser la liste des recipes",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Afficher uniquement les favoris",
|
"title": "Afficher uniquement les favoris",
|
||||||
"action": "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": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "Libère {size}",
|
||||||
"title": "Supprimer le modèle",
|
"title": "Supprimer le modèle",
|
||||||
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?"
|
"message": "Êtes-vous sûr de vouloir supprimer ce modèle et tous les fichiers associés ?",
|
||||||
|
"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": {
|
"excludeModel": {
|
||||||
"title": "Exclure le modèle",
|
"title": "Exclure le modèle",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
"newerTooltip": "Cette version est plus récente que votre dernière version locale",
|
||||||
"earlyAccess": "Accès anticipé",
|
"earlyAccess": "Accès anticipé",
|
||||||
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
"earlyAccessTooltip": "Cette version nécessite actuellement l'accès anticipé Civitai",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "Ignorée",
|
"ignored": "Ignorée",
|
||||||
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
"ignoredTooltip": "Les notifications de mise à jour sont désactivées pour cette version",
|
||||||
"onSiteOnly": "Uniquement sur Site",
|
"onSiteOnly": "Uniquement sur Site",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "Télécharger",
|
"download": "Télécharger",
|
||||||
"downloadTooltip": "Télécharger cette version",
|
"downloadTooltip": "Télécharger cette version",
|
||||||
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
"downloadEarlyAccessTooltip": "Télécharger cette version en accès anticipé depuis Civitai",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
"downloadNotAllowedTooltip": "Cette version n'est disponible que pour la génération sur le site Civitai",
|
||||||
"delete": "Supprimer",
|
"delete": "Supprimer",
|
||||||
"deleteTooltip": "Supprimer cette version locale",
|
"deleteTooltip": "Supprimer cette version locale",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "Recipe remplacée dans le workflow",
|
"recipeReplaced": "Recipe remplacée dans le workflow",
|
||||||
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
"recipeFailedToSend": "Échec de l'envoi de la recipe au workflow",
|
||||||
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
"noMatchingNodes": "Aucun nœud compatible disponible dans le workflow actuel",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
"noTargetNodeSelected": "Aucun nœud cible sélectionné",
|
||||||
"modelUpdated": "Modèle mis à jour dans le workflow",
|
"modelUpdated": "Modèle mis à jour dans le workflow",
|
||||||
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
"modelFailed": "Échec de la mise à jour du nœud modèle",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "Échec de la mise à jour des mots-clés",
|
"updateFailed": "Échec de la mise à jour des mots-clés",
|
||||||
"copyFailed": "Échec de la copie"
|
"copyFailed": "Échec de la copie"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"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": {
|
"virtual": {
|
||||||
"loadFailed": "Échec du chargement des éléments",
|
"loadFailed": "Échec du chargement des éléments",
|
||||||
"loadMoreFailed": "Échec du chargement de plus d'éléments",
|
"loadMoreFailed": "Échec du chargement de plus d'éléments",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
"fileRenameFailed": "Échec du renommage du fichier : {error}",
|
||||||
"previewUpdated": "Aperçu mis à jour avec succès",
|
"previewUpdated": "Aperçu mis à jour avec succès",
|
||||||
"previewUploadFailed": "Échec du téléchargement de l'image d'aperçu",
|
"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é",
|
"refreshComplete": "{action} terminé",
|
||||||
"refreshFailed": "Échec de {action} des {type}s",
|
"refreshFailed": "Échec de {action} des {type}s",
|
||||||
"metadataRefreshed": "Métadonnées actualisées avec succès",
|
"metadataRefreshed": "Métadonnées actualisées avec succès",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "הסתר עדכוני גישה מוקדמת",
|
"label": "הסתר עדכוני גישה מוקדמת",
|
||||||
"help": "רק עדכוני גישה מוקדמת"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
"useNewStyle": "השתמש בסמלי רישיון מעודכנים",
|
||||||
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
"useNewStyleHelp": "הצג הרשאות רישיון עם מחוונים צבעוניים (סגנון חדש) או סמלי הגבלה בלבד (סגנון קלאסי). משקף את העיצוב העדכני של CivitAI."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "הכי ישן",
|
"dateAsc": "הכי ישן",
|
||||||
"lorasCount": "מספר LoRAs",
|
"lorasCount": "מספר LoRAs",
|
||||||
"lorasCountDesc": "הכי הרבה",
|
"lorasCountDesc": "הכי הרבה",
|
||||||
"lorasCountAsc": "הכי פחות"
|
"lorasCountAsc": "הכי פחות",
|
||||||
|
"opened": "נפתחו לאחרונה",
|
||||||
|
"openedDesc": "נפתחו לאחרונה"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים",
|
"title": "רענן רשימת מתכונים",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "הצג מועדפים בלבד",
|
"title": "הצג מועדפים בלבד",
|
||||||
"action": "מועדפים"
|
"action": "מועדפים"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "פריסת מתכונים",
|
||||||
|
"grid": "פריסת רשת",
|
||||||
|
"masonry": "פריסת Masonry (בסגנון Pinterest, שומרת על יחס הגובה-רוחב של התמונה)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "מפנה {size}",
|
||||||
"title": "מחק מודל",
|
"title": "מחק מודל",
|
||||||
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?"
|
"message": "האם אתה בטוח שברצונך למחוק מודל זה וכל הקבצים הנלווים?",
|
||||||
|
"recoverableWarning": "הקובץ יימחק לצמיתות לאחר 20 שניות, אלא אם תבטלו את הפעולה."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "ניתן לבטל פעולה זו תוך 20 שניות."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "החרג מודל",
|
"title": "החרג מודל",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
"newerTooltip": "גרסה זו חדשה יותר מהגרסה המקומית האחרונה שלך",
|
||||||
"earlyAccess": "גישה מוקדמת",
|
"earlyAccess": "גישה מוקדמת",
|
||||||
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
"earlyAccessTooltip": "גרסה זו דורשת כרגע גישת Early Access של Civitai",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "התעלם",
|
"ignored": "התעלם",
|
||||||
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
"ignoredTooltip": "התראות העדכון מושבתות עבור גרסה זו",
|
||||||
"onSiteOnly": "רק באתר",
|
"onSiteOnly": "רק באתר",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "הורדה",
|
"download": "הורדה",
|
||||||
"downloadTooltip": "הורד את הגרסה הזו",
|
"downloadTooltip": "הורד את הגרסה הזו",
|
||||||
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
"downloadEarlyAccessTooltip": "הורד את גרסת ה-Early Access הזו מ-Civitai",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
"downloadNotAllowedTooltip": "גרסה זו זמינה רק ליצירה באתר Civitai",
|
||||||
"delete": "מחיקה",
|
"delete": "מחיקה",
|
||||||
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
"deleteTooltip": "מחק את הגרסה המקומית הזו",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
"recipeReplaced": "מתכון הוחלף ב-workflow",
|
||||||
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
"recipeFailedToSend": "שליחת מתכון ל-workflow נכשלה",
|
||||||
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
"noMatchingNodes": "אין צמתים תואמים זמינים ב-workflow הנוכחי",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
"noTargetNodeSelected": "לא נבחר צומת יעד",
|
||||||
"modelUpdated": "מודל עודכן ב-workflow",
|
"modelUpdated": "מודל עודכן ב-workflow",
|
||||||
"modelFailed": "עדכון צומת המודל נכשל",
|
"modelFailed": "עדכון צומת המודל נכשל",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "עדכון מילות הטריגר נכשל",
|
"updateFailed": "עדכון מילות הטריגר נכשל",
|
||||||
"copyFailed": "ההעתקה נכשלה"
|
"copyFailed": "ההעתקה נכשלה"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "בטל",
|
||||||
|
"deleted": "נמחק: {name}",
|
||||||
|
"deletedBulk": "{count} פריטים נמחקו",
|
||||||
|
"expired": "חלון הביטול פג. הפריט נמחק לצמיתות.",
|
||||||
|
"failed": "הביטול נכשל: {error}",
|
||||||
|
"restored": "הפריט שוחזר"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "טעינת הפריטים נכשלה",
|
"loadFailed": "טעינת הפריטים נכשלה",
|
||||||
"loadMoreFailed": "טעינת פריטים נוספים נכשלה",
|
"loadMoreFailed": "טעינת פריטים נוספים נכשלה",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
"fileRenameFailed": "שינוי שם הקובץ נכשל: {error}",
|
||||||
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
"previewUpdated": "התצוגה המקדימה עודכנה בהצלחה",
|
||||||
"previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה",
|
"previewUploadFailed": "העלאת תמונת התצוגה המקדימה נכשלה",
|
||||||
|
"previewDropInvalid": "סוג קובץ לא נתמך: {name}. גרור במקום זאת תמונה או סרטון MP4.",
|
||||||
"refreshComplete": "{action} הושלם",
|
"refreshComplete": "{action} הושלם",
|
||||||
"refreshFailed": "{action} של {type}s נכשל",
|
"refreshFailed": "{action} של {type}s נכשל",
|
||||||
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
|
"metadataRefreshed": "המטא-דאטה רועננה בהצלחה",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "早期アクセス更新を非表示",
|
"label": "早期アクセス更新を非表示",
|
||||||
"help": "早期アクセスのみの更新"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "更新されたライセンスアイコンを使用",
|
"useNewStyle": "更新されたライセンスアイコンを使用",
|
||||||
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
"useNewStyleHelp": "カラーインジケーター付きでライセンス許可を表示(新スタイル)するか、制限のみのアイコンを表示(クラシックスタイル)します。現在のCivitAIデザインを反映しています。"
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "古い順",
|
"dateAsc": "古い順",
|
||||||
"lorasCount": "LoRA数",
|
"lorasCount": "LoRA数",
|
||||||
"lorasCountDesc": "多い順",
|
"lorasCountDesc": "多い順",
|
||||||
"lorasCountAsc": "少ない順"
|
"lorasCountAsc": "少ない順",
|
||||||
|
"opened": "最近開いた",
|
||||||
|
"openedDesc": "最近開いた"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新",
|
"title": "レシピリストを更新",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "お気に入りのみ表示",
|
"title": "お気に入りのみ表示",
|
||||||
"action": "お気に入り"
|
"action": "お気に入り"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "レシピのレイアウト",
|
||||||
|
"grid": "グリッドレイアウト",
|
||||||
|
"masonry": "メイソンリーレイアウト(Pinterest スタイル、画像のアスペクト比を保持)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "{size} を解放します",
|
||||||
"title": "モデルを削除",
|
"title": "モデルを削除",
|
||||||
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?"
|
"message": "このモデルと関連するすべてのファイルを削除してもよろしいですか?",
|
||||||
|
"recoverableWarning": "元に戻さない場合、このファイルは20秒後に完全に削除されます。"
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "この操作は20秒以内であれば元に戻せます。"
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "モデルを除外",
|
"title": "モデルを除外",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
"newerTooltip": "このバージョンはローカルの最新バージョンより新しいです",
|
||||||
"earlyAccess": "早期アクセス",
|
"earlyAccess": "早期アクセス",
|
||||||
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
"earlyAccessTooltip": "このバージョンは現在 Civitai の早期アクセスが必要です",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "無視中",
|
"ignored": "無視中",
|
||||||
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
"ignoredTooltip": "このバージョンの更新通知は無効です",
|
||||||
"onSiteOnly": "サイト内のみ",
|
"onSiteOnly": "サイト内のみ",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "ダウンロード",
|
"download": "ダウンロード",
|
||||||
"downloadTooltip": "このバージョンをダウンロード",
|
"downloadTooltip": "このバージョンをダウンロード",
|
||||||
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
"downloadEarlyAccessTooltip": "Civitai からこの早期アクセス版をダウンロード",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
"downloadNotAllowedTooltip": "このバージョンはCivitaiサイト内でのみ利用可能で、ダウンロードはできません",
|
||||||
"delete": "削除",
|
"delete": "削除",
|
||||||
"deleteTooltip": "このローカルバージョンを削除",
|
"deleteTooltip": "このローカルバージョンを削除",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "レシピがワークフローで置換されました",
|
"recipeReplaced": "レシピがワークフローで置換されました",
|
||||||
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
"recipeFailedToSend": "レシピをワークフローに送信できませんでした",
|
||||||
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
"noMatchingNodes": "現在のワークフローには互換性のあるノードがありません",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
"noTargetNodeSelected": "ターゲットノードが選択されていません",
|
||||||
"modelUpdated": "モデルがワークフローで更新されました",
|
"modelUpdated": "モデルがワークフローで更新されました",
|
||||||
"modelFailed": "モデルノードの更新に失敗しました",
|
"modelFailed": "モデルノードの更新に失敗しました",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "トリガーワードの更新に失敗しました",
|
"updateFailed": "トリガーワードの更新に失敗しました",
|
||||||
"copyFailed": "コピーに失敗しました"
|
"copyFailed": "コピーに失敗しました"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "元に戻す",
|
||||||
|
"deleted": "{name} を削除しました",
|
||||||
|
"deletedBulk": "{count} 個のアイテムを削除しました",
|
||||||
|
"expired": "元に戻せる時間が経過しました。アイテムは完全に削除されました。",
|
||||||
|
"failed": "元に戻せませんでした: {error}",
|
||||||
|
"restored": "アイテムを復元しました"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "アイテムの読み込みに失敗しました",
|
"loadFailed": "アイテムの読み込みに失敗しました",
|
||||||
"loadMoreFailed": "追加アイテムの読み込みに失敗しました",
|
"loadMoreFailed": "追加アイテムの読み込みに失敗しました",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
"fileRenameFailed": "ファイル名の変更に失敗しました:{error}",
|
||||||
"previewUpdated": "プレビューが正常に更新されました",
|
"previewUpdated": "プレビューが正常に更新されました",
|
||||||
"previewUploadFailed": "プレビュー画像のアップロードに失敗しました",
|
"previewUploadFailed": "プレビュー画像のアップロードに失敗しました",
|
||||||
|
"previewDropInvalid": "サポートされていないファイル形式:{name}。画像またはMP4ビデオをドロップしてください。",
|
||||||
"refreshComplete": "{action} 完了",
|
"refreshComplete": "{action} 完了",
|
||||||
"refreshFailed": "{type}の{action}に失敗しました",
|
"refreshFailed": "{type}の{action}に失敗しました",
|
||||||
"metadataRefreshed": "メタデータが正常に更新されました",
|
"metadataRefreshed": "メタデータが正常に更新されました",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "얼리 액세스 업데이트 숨기기",
|
"label": "얼리 액세스 업데이트 숨기기",
|
||||||
"help": "얼리 액세스 업데이트만"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
"useNewStyle": "업데이트된 라이선스 아이콘 사용",
|
||||||
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
"useNewStyleHelp": "색상 표시기가 있는 라이선스 권한(새 스타일) 또는 제한 전용 아이콘(클래식 스타일)을 표시합니다. 현재 CivitAI 디자인을 반영합니다."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "오래된순",
|
"dateAsc": "오래된순",
|
||||||
"lorasCount": "LoRA 수",
|
"lorasCount": "LoRA 수",
|
||||||
"lorasCountDesc": "많은순",
|
"lorasCountDesc": "많은순",
|
||||||
"lorasCountAsc": "적은순"
|
"lorasCountAsc": "적은순",
|
||||||
|
"opened": "최근에 연",
|
||||||
|
"openedDesc": "최근에 연"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침",
|
"title": "레시피 목록 새로고침",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "즐겨찾기만 표시",
|
"title": "즐겨찾기만 표시",
|
||||||
"action": "즐겨찾기"
|
"action": "즐겨찾기"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "레시피 레이아웃",
|
||||||
|
"grid": "그리드 레이아웃",
|
||||||
|
"masonry": "메이슨리 레이아웃 (Pinterest 스타일, 이미지 종횡비 유지)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "{size} 확보",
|
||||||
"title": "모델 삭제",
|
"title": "모델 삭제",
|
||||||
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?"
|
"message": "이 모델과 모든 관련 파일을 삭제하시겠습니까?",
|
||||||
|
"recoverableWarning": "실행 취소하지 않으면 20초 후에 파일이 영구적으로 삭제됩니다."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "이 작업은 20초 이내에 실행 취소할 수 있습니다."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "모델 제외",
|
"title": "모델 제외",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
"newerTooltip": "이 버전은 로컬의 최신 버전보다 더 새롭습니다",
|
||||||
"earlyAccess": "얼리 액세스",
|
"earlyAccess": "얼리 액세스",
|
||||||
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
"earlyAccessTooltip": "이 버전은 현재 Civitai 얼리 액세스가 필요합니다",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "무시됨",
|
"ignored": "무시됨",
|
||||||
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
"ignoredTooltip": "이 버전은 업데이트 알림이 비활성화되어 있습니다",
|
||||||
"onSiteOnly": "사이트 내 전용",
|
"onSiteOnly": "사이트 내 전용",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "다운로드",
|
"download": "다운로드",
|
||||||
"downloadTooltip": "이 버전 다운로드",
|
"downloadTooltip": "이 버전 다운로드",
|
||||||
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
"downloadEarlyAccessTooltip": "Civitai에서 이 얼리 액세스 버전 다운로드",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
"downloadNotAllowedTooltip": "이 버전은 Civitai 사이트 내에서만 사용 가능하며 다운로드할 수 없습니다",
|
||||||
"delete": "삭제",
|
"delete": "삭제",
|
||||||
"deleteTooltip": "이 로컬 버전 삭제",
|
"deleteTooltip": "이 로컬 버전 삭제",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
"recipeReplaced": "레시피가 워크플로에서 교체되었습니다",
|
||||||
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
"recipeFailedToSend": "레시피를 워크플로로 전송하지 못했습니다",
|
||||||
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
"noMatchingNodes": "현재 워크플로에서 호환되는 노드가 없습니다",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
"noTargetNodeSelected": "대상 노드가 선택되지 않았습니다",
|
||||||
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
"modelUpdated": "모델이 워크플로에서 업데이트되었습니다",
|
||||||
"modelFailed": "모델 노드 업데이트 실패",
|
"modelFailed": "모델 노드 업데이트 실패",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
"updateFailed": "트리거 단어 업데이트에 실패했습니다",
|
||||||
"copyFailed": "복사 실패"
|
"copyFailed": "복사 실패"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "실행 취소",
|
||||||
|
"deleted": "{name} 삭제됨",
|
||||||
|
"deletedBulk": "{count}개 항목 삭제됨",
|
||||||
|
"expired": "실행 취소 기간이 만료되었습니다. 항목이 영구적으로 삭제되었습니다.",
|
||||||
|
"failed": "실행 취소 실패: {error}",
|
||||||
|
"restored": "항목이 복원되었습니다"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "항목 로딩 실패",
|
"loadFailed": "항목 로딩 실패",
|
||||||
"loadMoreFailed": "더 많은 항목 로딩 실패",
|
"loadMoreFailed": "더 많은 항목 로딩 실패",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
"fileRenameFailed": "파일 이름 변경 실패: {error}",
|
||||||
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
"previewUpdated": "미리보기가 성공적으로 업데이트되었습니다",
|
||||||
"previewUploadFailed": "미리보기 이미지 업로드 실패",
|
"previewUploadFailed": "미리보기 이미지 업로드 실패",
|
||||||
|
"previewDropInvalid": "지원되지 않는 파일 형식: {name}. 이미지 또는 MP4 동영상을 드롭하세요.",
|
||||||
"refreshComplete": "{action} 완료",
|
"refreshComplete": "{action} 완료",
|
||||||
"refreshFailed": "{type} {action} 실패",
|
"refreshFailed": "{type} {action} 실패",
|
||||||
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
|
"metadataRefreshed": "메타데이터가 성공적으로 새로고침되었습니다",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "Скрыть обновления раннего доступа",
|
"label": "Скрыть обновления раннего доступа",
|
||||||
"help": "Только обновления раннего доступа"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "Использовать обновлённые значки лицензии",
|
"useNewStyle": "Использовать обновлённые значки лицензии",
|
||||||
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
"useNewStyleHelp": "Отображать разрешения лицензии с цветными индикаторами (новый стиль) или только значки ограничений (классический стиль). Соответствует текущему дизайну CivitAI."
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "Сначала старые",
|
"dateAsc": "Сначала старые",
|
||||||
"lorasCount": "Кол-во LoRA",
|
"lorasCount": "Кол-во LoRA",
|
||||||
"lorasCountDesc": "Больше всего",
|
"lorasCountDesc": "Больше всего",
|
||||||
"lorasCountAsc": "Меньше всего"
|
"lorasCountAsc": "Меньше всего",
|
||||||
|
"opened": "Недавно открытые",
|
||||||
|
"openedDesc": "Недавно открытые"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов",
|
"title": "Обновить список рецептов",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "Только избранные",
|
"title": "Только избранные",
|
||||||
"action": "Избранное"
|
"action": "Избранное"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "Макет рецептов",
|
||||||
|
"grid": "Макет сеткой",
|
||||||
|
"masonry": "Masonry-макет (в стиле Pinterest, сохраняет пропорции изображения)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "Освобождает {size}",
|
||||||
"title": "Удалить модель",
|
"title": "Удалить модель",
|
||||||
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?"
|
"message": "Вы уверены, что хотите удалить эту модель и все связанные файлы?",
|
||||||
|
"recoverableWarning": "Файл будет удалён навсегда через 20 секунд, если вы не отмените действие."
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "Это действие можно отменить в течение 20 секунд."
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "Исключить модель",
|
"title": "Исключить модель",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
"newerTooltip": "Эта версия новее вашей последней локальной версии",
|
||||||
"earlyAccess": "Ранний доступ",
|
"earlyAccess": "Ранний доступ",
|
||||||
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
"earlyAccessTooltip": "Для этой версии сейчас требуется ранний доступ Civitai",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "Игнорируется",
|
"ignored": "Игнорируется",
|
||||||
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
"ignoredTooltip": "Уведомления об обновлениях для этой версии отключены",
|
||||||
"onSiteOnly": "Только на Сайте",
|
"onSiteOnly": "Только на Сайте",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "Скачать",
|
"download": "Скачать",
|
||||||
"downloadTooltip": "Скачать эту версию",
|
"downloadTooltip": "Скачать эту версию",
|
||||||
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
"downloadEarlyAccessTooltip": "Скачать эту версию раннего доступа с Civitai",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
"downloadNotAllowedTooltip": "Эта версия доступна только для генерации на сайте Civitai",
|
||||||
"delete": "Удалить",
|
"delete": "Удалить",
|
||||||
"deleteTooltip": "Удалить эту локальную версию",
|
"deleteTooltip": "Удалить эту локальную версию",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "Рецепт заменён в workflow",
|
"recipeReplaced": "Рецепт заменён в workflow",
|
||||||
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
"recipeFailedToSend": "Не удалось отправить рецепт в workflow",
|
||||||
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
"noMatchingNodes": "В текущем workflow нет совместимых узлов",
|
||||||
|
"noPromptTargets": "[TODO: Translate] No compatible prompt targets in the workflow.\nRight-click a node in ComfyUI → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "Целевой узел не выбран",
|
"noTargetNodeSelected": "Целевой узел не выбран",
|
||||||
"modelUpdated": "Модель обновлена в workflow",
|
"modelUpdated": "Модель обновлена в workflow",
|
||||||
"modelFailed": "Не удалось обновить узел модели",
|
"modelFailed": "Не удалось обновить узел модели",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "Не удалось обновить триггерные слова",
|
"updateFailed": "Не удалось обновить триггерные слова",
|
||||||
"copyFailed": "Копирование не удалось"
|
"copyFailed": "Копирование не удалось"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "Отменить",
|
||||||
|
"deleted": "Удалено: {name}",
|
||||||
|
"deletedBulk": "Удалено: {count} шт.",
|
||||||
|
"expired": "Время отмены истекло. Элемент был удалён навсегда.",
|
||||||
|
"failed": "Не удалось отменить: {error}",
|
||||||
|
"restored": "Элемент восстановлен"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "Не удалось загрузить элементы",
|
"loadFailed": "Не удалось загрузить элементы",
|
||||||
"loadMoreFailed": "Не удалось загрузить больше элементов",
|
"loadMoreFailed": "Не удалось загрузить больше элементов",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
"fileRenameFailed": "Не удалось переименовать файл: {error}",
|
||||||
"previewUpdated": "Превью успешно обновлено",
|
"previewUpdated": "Превью успешно обновлено",
|
||||||
"previewUploadFailed": "Не удалось загрузить превью изображение",
|
"previewUploadFailed": "Не удалось загрузить превью изображение",
|
||||||
|
"previewDropInvalid": "Неподдерживаемый тип файла: {name}. Перетащите вместо этого изображение или видео MP4.",
|
||||||
"refreshComplete": "{action} завершено",
|
"refreshComplete": "{action} завершено",
|
||||||
"refreshFailed": "Не удалось {action} {type}s",
|
"refreshFailed": "Не удалось {action} {type}s",
|
||||||
"metadataRefreshed": "Метаданные успешно обновлены",
|
"metadataRefreshed": "Метаданные успешно обновлены",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "隐藏抢先体验更新",
|
"label": "隐藏抢先体验更新",
|
||||||
"help": "抢先体验更新"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "使用新版许可协议图标",
|
"useNewStyle": "使用新版许可协议图标",
|
||||||
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
"useNewStyleHelp": "以彩色指示器显示许可权限(新样式),或仅显示限制图标(经典样式)。与当前 CivitAI 设计保持一致。"
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "最早",
|
"dateAsc": "最早",
|
||||||
"lorasCount": "LoRA 数量",
|
"lorasCount": "LoRA 数量",
|
||||||
"lorasCountDesc": "最多",
|
"lorasCountDesc": "最多",
|
||||||
"lorasCountAsc": "最少"
|
"lorasCountAsc": "最少",
|
||||||
|
"opened": "最近打开",
|
||||||
|
"openedDesc": "最近打开"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表",
|
"title": "刷新配方列表",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "仅显示收藏",
|
"title": "仅显示收藏",
|
||||||
"action": "收藏"
|
"action": "收藏"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "配方布局",
|
||||||
|
"grid": "网格布局",
|
||||||
|
"masonry": "瀑布流布局(Pinterest 风格,保留图片原始宽高比)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "释放 {size}",
|
||||||
"title": "删除模型",
|
"title": "删除模型",
|
||||||
"message": "你确定要删除此模型及所有相关文件吗?"
|
"message": "你确定要删除此模型及所有相关文件吗?",
|
||||||
|
"recoverableWarning": "如果不撤销,文件将在 20 秒后被永久删除。"
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "此操作可在 20 秒内撤销。"
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "排除模型",
|
"title": "排除模型",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||||
"earlyAccess": "抢先体验",
|
"earlyAccess": "抢先体验",
|
||||||
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
"earlyAccessTooltip": "此版本当前需要 Civitai 抢先体验权限",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已关闭更新通知",
|
"ignoredTooltip": "此版本已关闭更新通知",
|
||||||
"onSiteOnly": "仅站内生成",
|
"onSiteOnly": "仅站内生成",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTooltip": "下载此版本",
|
"downloadTooltip": "下载此版本",
|
||||||
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
"downloadEarlyAccessTooltip": "从 Civitai 下载此抢先体验版本",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
"downloadNotAllowedTooltip": "此版本仅在 Civitai 站内可用,无法下载",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"deleteTooltip": "删除此本地版本",
|
"deleteTooltip": "删除此本地版本",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "配方已替换到工作流",
|
"recipeReplaced": "配方已替换到工作流",
|
||||||
"recipeFailedToSend": "发送配方到工作流失败",
|
"recipeFailedToSend": "发送配方到工作流失败",
|
||||||
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
"noMatchingNodes": "当前工作流中没有兼容的节点",
|
||||||
|
"noPromptTargets": "工作流中没有兼容的 prompt 目标节点。\n在 ComfyUI 中右键节点 → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "未选择目标节点",
|
"noTargetNodeSelected": "未选择目标节点",
|
||||||
"modelUpdated": "模型已更新到工作流",
|
"modelUpdated": "模型已更新到工作流",
|
||||||
"modelFailed": "更新模型节点失败",
|
"modelFailed": "更新模型节点失败",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "触发词更新失败",
|
"updateFailed": "触发词更新失败",
|
||||||
"copyFailed": "复制失败"
|
"copyFailed": "复制失败"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "撤销",
|
||||||
|
"deleted": "已删除 {name}",
|
||||||
|
"deletedBulk": "已删除 {count} 个项目",
|
||||||
|
"expired": "撤销窗口已过期,项目已被永久删除。",
|
||||||
|
"failed": "撤销失败:{error}",
|
||||||
|
"restored": "项目已恢复"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "加载项目失败",
|
"loadFailed": "加载项目失败",
|
||||||
"loadMoreFailed": "加载更多项目失败",
|
"loadMoreFailed": "加载更多项目失败",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "重命名文件失败:{error}",
|
"fileRenameFailed": "重命名文件失败:{error}",
|
||||||
"previewUpdated": "预览图片更新成功",
|
"previewUpdated": "预览图片更新成功",
|
||||||
"previewUploadFailed": "上传预览图片失败",
|
"previewUploadFailed": "上传预览图片失败",
|
||||||
|
"previewDropInvalid": "不支持的文件类型:{name}。请拖入图片或 MP4 视频。",
|
||||||
"refreshComplete": "{action} 完成",
|
"refreshComplete": "{action} 完成",
|
||||||
"refreshFailed": "{action} {type} 失败",
|
"refreshFailed": "{action} {type} 失败",
|
||||||
"metadataRefreshed": "元数据刷新成功",
|
"metadataRefreshed": "元数据刷新成功",
|
||||||
|
|||||||
+31
-2
@@ -622,6 +622,10 @@
|
|||||||
"label": "隱藏搶先體驗更新",
|
"label": "隱藏搶先體驗更新",
|
||||||
"help": "搶先體驗更新"
|
"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": {
|
"licenseIcons": {
|
||||||
"useNewStyle": "使用新版許可協議圖標",
|
"useNewStyle": "使用新版許可協議圖標",
|
||||||
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
"useNewStyleHelp": "以彩色指示器顯示許可權限(新樣式),或僅顯示限制圖標(經典樣式)。與當前 CivitAI 設計保持一致。"
|
||||||
@@ -920,7 +924,9 @@
|
|||||||
"dateAsc": "最舊",
|
"dateAsc": "最舊",
|
||||||
"lorasCount": "LoRA 數量",
|
"lorasCount": "LoRA 數量",
|
||||||
"lorasCountDesc": "最多",
|
"lorasCountDesc": "最多",
|
||||||
"lorasCountAsc": "最少"
|
"lorasCountAsc": "最少",
|
||||||
|
"opened": "最近開啟",
|
||||||
|
"openedDesc": "最近開啟"
|
||||||
},
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表",
|
"title": "重新整理配方列表",
|
||||||
@@ -931,6 +937,11 @@
|
|||||||
"favorites": {
|
"favorites": {
|
||||||
"title": "僅顯示收藏",
|
"title": "僅顯示收藏",
|
||||||
"action": "收藏"
|
"action": "收藏"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"title": "配方版面",
|
||||||
|
"grid": "網格版面",
|
||||||
|
"masonry": "瀑布流版面(Pinterest 風格,保留圖片原始寬高比)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
@@ -1277,8 +1288,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"deleteModel": {
|
"deleteModel": {
|
||||||
|
"freesSpace": "釋放 {size}",
|
||||||
"title": "刪除模型",
|
"title": "刪除模型",
|
||||||
"message": "您確定要刪除此模型及所有相關檔案嗎?"
|
"message": "您確定要刪除此模型及所有相關檔案嗎?",
|
||||||
|
"recoverableWarning": "如果未復原,檔案將在 20 秒後被永久刪除。"
|
||||||
|
},
|
||||||
|
"deleteRecipe": {
|
||||||
|
"recoverableWarning": "此操作可在 20 秒內復原。"
|
||||||
},
|
},
|
||||||
"excludeModel": {
|
"excludeModel": {
|
||||||
"title": "排除模型",
|
"title": "排除模型",
|
||||||
@@ -1543,6 +1559,8 @@
|
|||||||
"newerTooltip": "此版本比你本地的最新版本更新",
|
"newerTooltip": "此版本比你本地的最新版本更新",
|
||||||
"earlyAccess": "搶先體驗",
|
"earlyAccess": "搶先體驗",
|
||||||
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
"earlyAccessTooltip": "此版本目前需要 Civitai 搶先體驗權限",
|
||||||
|
"paid": "[TODO: Translate] Paid",
|
||||||
|
"paidTooltip": "[TODO: Translate] This version requires payment to download",
|
||||||
"ignored": "已忽略",
|
"ignored": "已忽略",
|
||||||
"ignoredTooltip": "此版本已關閉更新通知",
|
"ignoredTooltip": "此版本已關閉更新通知",
|
||||||
"onSiteOnly": "僅站內生成",
|
"onSiteOnly": "僅站內生成",
|
||||||
@@ -1552,6 +1570,7 @@
|
|||||||
"download": "下載",
|
"download": "下載",
|
||||||
"downloadTooltip": "下載此版本",
|
"downloadTooltip": "下載此版本",
|
||||||
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
"downloadEarlyAccessTooltip": "從 Civitai 下載此搶先體驗版本",
|
||||||
|
"downloadPaidTooltip": "[TODO: Translate] Download this paid version from Civitai",
|
||||||
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
"downloadNotAllowedTooltip": "此版本僅在 Civitai 站內可用,無法下載",
|
||||||
"delete": "刪除",
|
"delete": "刪除",
|
||||||
"deleteTooltip": "刪除此本地版本",
|
"deleteTooltip": "刪除此本地版本",
|
||||||
@@ -1721,6 +1740,7 @@
|
|||||||
"recipeReplaced": "配方已取代於工作流",
|
"recipeReplaced": "配方已取代於工作流",
|
||||||
"recipeFailedToSend": "傳送配方到工作流失敗",
|
"recipeFailedToSend": "傳送配方到工作流失敗",
|
||||||
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
"noMatchingNodes": "目前工作流程中沒有相容的節點",
|
||||||
|
"noPromptTargets": "工作流中沒有相容的 prompt 目標節點。\n在 ComfyUI 中右鍵節點 → Mark as → Send Prompt Target",
|
||||||
"noTargetNodeSelected": "未選擇目標節點",
|
"noTargetNodeSelected": "未選擇目標節點",
|
||||||
"modelUpdated": "模型已更新到工作流",
|
"modelUpdated": "模型已更新到工作流",
|
||||||
"modelFailed": "更新模型節點失敗",
|
"modelFailed": "更新模型節點失敗",
|
||||||
@@ -2113,6 +2133,14 @@
|
|||||||
"updateFailed": "更新觸發詞失敗",
|
"updateFailed": "更新觸發詞失敗",
|
||||||
"copyFailed": "複製失敗"
|
"copyFailed": "複製失敗"
|
||||||
},
|
},
|
||||||
|
"undo": {
|
||||||
|
"action": "復原",
|
||||||
|
"deleted": "已刪除 {name}",
|
||||||
|
"deletedBulk": "已刪除 {count} 個項目",
|
||||||
|
"expired": "復原視窗已過期,項目已被永久刪除。",
|
||||||
|
"failed": "復原失敗:{error}",
|
||||||
|
"restored": "項目已還原"
|
||||||
|
},
|
||||||
"virtual": {
|
"virtual": {
|
||||||
"loadFailed": "載入項目失敗",
|
"loadFailed": "載入項目失敗",
|
||||||
"loadMoreFailed": "載入更多項目失敗",
|
"loadMoreFailed": "載入更多項目失敗",
|
||||||
@@ -2176,6 +2204,7 @@
|
|||||||
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
"fileRenameFailed": "重新命名檔案失敗:{error}",
|
||||||
"previewUpdated": "預覽圖片已成功更新",
|
"previewUpdated": "預覽圖片已成功更新",
|
||||||
"previewUploadFailed": "上傳預覽圖片失敗",
|
"previewUploadFailed": "上傳預覽圖片失敗",
|
||||||
|
"previewDropInvalid": "不支援的檔案類型:{name}。請拖入圖片或 MP4 影片。",
|
||||||
"refreshComplete": "{action} 完成",
|
"refreshComplete": "{action} 完成",
|
||||||
"refreshFailed": "{action} {type} 失敗",
|
"refreshFailed": "{action} {type} 失敗",
|
||||||
"metadataRefreshed": "metadata 已成功刷新",
|
"metadataRefreshed": "metadata 已成功刷新",
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ from .routes.recipe_routes import RecipeRoutes
|
|||||||
from .routes.stats_routes import StatsRoutes
|
from .routes.stats_routes import StatsRoutes
|
||||||
from .routes.update_routes import UpdateRoutes
|
from .routes.update_routes import UpdateRoutes
|
||||||
from .routes.misc_routes import MiscRoutes
|
from .routes.misc_routes import MiscRoutes
|
||||||
|
from .routes.pending_delete_routes import PendingDeleteRoutes
|
||||||
from .routes.preview_routes import PreviewRoutes
|
from .routes.preview_routes import PreviewRoutes
|
||||||
from .routes.example_images_routes import ExampleImagesRoutes
|
from .routes.example_images_routes import ExampleImagesRoutes
|
||||||
from .services.service_registry import ServiceRegistry
|
from .services.service_registry import ServiceRegistry
|
||||||
from .services.settings_manager import get_settings_manager
|
from .services.settings_manager import get_settings_manager
|
||||||
|
from .services.pending_delete_service import get_pending_delete_service
|
||||||
from .utils.example_images_migration import ExampleImagesMigration
|
from .utils.example_images_migration import ExampleImagesMigration
|
||||||
from .services.websocket_manager import ws_manager
|
from .services.websocket_manager import ws_manager
|
||||||
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
from .services.example_images_cleanup_service import ExampleImagesCleanupService
|
||||||
@@ -170,6 +172,7 @@ class LoraManager:
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app)
|
MiscRoutes.setup_routes(app)
|
||||||
|
PendingDeleteRoutes.setup_routes(app)
|
||||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||||
PreviewRoutes.setup_routes(app)
|
PreviewRoutes.setup_routes(app)
|
||||||
|
|
||||||
@@ -245,6 +248,20 @@ class LoraManager:
|
|||||||
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
|
cls._run_post_initialization_tasks(init_tasks), name="post_init_tasks"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 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. 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(scan_roots=True),
|
||||||
|
name="pending_delete_startup_sweep",
|
||||||
|
)
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
"LoRA Manager: All services initialized and background tasks scheduled"
|
"LoRA Manager: All services initialized and background tasks scheduled"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -214,6 +214,24 @@ class MetadataProcessor:
|
|||||||
max_denoise = denoise
|
max_denoise = denoise
|
||||||
primary_sampler = sampler_info
|
primary_sampler = sampler_info
|
||||||
primary_sampler_id = node_id
|
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
|
return primary_sampler_id, primary_sampler
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class GenericNodeExtractor(NodeMetadataExtractor):
|
|||||||
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
|
* ``MODEL`` output: common input fields (ckpt_name, unet_name, etc.)
|
||||||
are checked for a model file name and stored as checkpoint metadata.
|
are checked for a model file name and stored as checkpoint metadata.
|
||||||
* ``CONDITIONING`` output: common text input fields are checked for
|
* ``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.
|
# 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)
|
_store_checkpoint_metadata(metadata, node_id, name)
|
||||||
return
|
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):
|
if "CONDITIONING" in return_types or any("CONDITIONING" in str(t) for t in return_types):
|
||||||
text = None
|
text = None
|
||||||
for field in GenericNodeExtractor._TEXT_FIELDS:
|
for field in GenericNodeExtractor._TEXT_FIELDS:
|
||||||
@@ -81,12 +81,14 @@ class GenericNodeExtractor(NodeMetadataExtractor):
|
|||||||
if val and isinstance(val, str) and val.strip():
|
if val and isinstance(val, str) and val.strip():
|
||||||
text = val.strip()
|
text = val.strip()
|
||||||
break
|
break
|
||||||
if text:
|
|
||||||
prompt_data = metadata.setdefault(PROMPTS, {})
|
input_conditionings = _collect_conditioning_inputs(inputs)
|
||||||
prompt_data[node_id] = {
|
if text or input_conditionings:
|
||||||
"text": text,
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
"node_id": node_id,
|
if text:
|
||||||
}
|
prompt_metadata["text"] = text
|
||||||
|
if input_conditionings:
|
||||||
|
prompt_metadata["orig_conditionings"] = input_conditionings
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def update(node_id, outputs, metadata, return_types=None):
|
def update(node_id, outputs, metadata, return_types=None):
|
||||||
@@ -98,11 +100,26 @@ class GenericNodeExtractor(NodeMetadataExtractor):
|
|||||||
return
|
return
|
||||||
if node_id not in metadata.get(PROMPTS, {}):
|
if node_id not in metadata.get(PROMPTS, {}):
|
||||||
return
|
return
|
||||||
if outputs and isinstance(outputs, list) and len(outputs) > 0:
|
output_tuple = _first_output_tuple(outputs)
|
||||||
if isinstance(outputs[0], tuple) and len(outputs[0]) > 0:
|
if not output_tuple or len(output_tuple) < 1:
|
||||||
cond = outputs[0][0]
|
return
|
||||||
if cond is not None:
|
|
||||||
metadata[PROMPTS][node_id]["conditioning"] = cond
|
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):
|
class CheckpointLoaderExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -417,6 +434,34 @@ def _first_output_tuple(outputs):
|
|||||||
return None
|
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(
|
def _record_conditioning_source(
|
||||||
metadata, node_id, output_conditioning, input_conditionings
|
metadata, node_id, output_conditioning, input_conditionings
|
||||||
):
|
):
|
||||||
@@ -429,6 +474,14 @@ def _record_conditioning_source(
|
|||||||
if not sources:
|
if not sources:
|
||||||
return
|
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 = _ensure_prompt_metadata(metadata, node_id)
|
||||||
prompt_metadata.setdefault("conditioning_sources", []).append(
|
prompt_metadata.setdefault("conditioning_sources", []).append(
|
||||||
{
|
{
|
||||||
@@ -508,13 +561,7 @@ class ConditioningCombineExtractor(NodeMetadataExtractor):
|
|||||||
if not inputs:
|
if not inputs:
|
||||||
return
|
return
|
||||||
|
|
||||||
input_conditionings = []
|
input_conditionings = _collect_conditioning_inputs(inputs)
|
||||||
for input_name in inputs:
|
|
||||||
if (
|
|
||||||
input_name.startswith("conditioning")
|
|
||||||
and inputs[input_name] is not None
|
|
||||||
):
|
|
||||||
input_conditionings.append(inputs[input_name])
|
|
||||||
|
|
||||||
if input_conditionings:
|
if input_conditionings:
|
||||||
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
prompt_metadata = _ensure_prompt_metadata(metadata, node_id)
|
||||||
@@ -814,6 +861,65 @@ class TSCKSamplerAdvancedExtractor(KSamplerAdvancedExtractor, TSCSamplerBaseExtr
|
|||||||
|
|
||||||
# Update method is inherited from TSCSamplerBaseExtractor
|
# 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):
|
class LoraLoaderExtractor(NodeMetadataExtractor):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def extract(node_id, inputs, outputs, metadata):
|
def extract(node_id, inputs, outputs, metadata):
|
||||||
@@ -854,6 +960,37 @@ class ImageSizeExtractor(NodeMetadataExtractor):
|
|||||||
"node_id": node_id
|
"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):
|
class RgthreePowerLoraLoaderExtractor(NodeMetadataExtractor):
|
||||||
"""Extract LoRA metadata from rgthree Power Lora Loader.
|
"""Extract LoRA metadata from rgthree Power Lora Loader.
|
||||||
|
|
||||||
@@ -1255,6 +1392,8 @@ NODE_EXTRACTORS = {
|
|||||||
"ClownsharKSampler_Beta": SamplerExtractor,
|
"ClownsharKSampler_Beta": SamplerExtractor,
|
||||||
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
"TSC_KSampler": TSCKSamplerExtractor, # Efficient Nodes
|
||||||
"TSC_KSamplerAdvanced": TSCKSamplerAdvancedExtractor, # 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
|
"KSamplerBasicPipe": KSamplerBasicPipeExtractor, # comfyui-impact-pack
|
||||||
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
|
"KSamplerAdvancedBasicPipe": KSamplerAdvancedBasicPipeExtractor, # comfyui-impact-pack
|
||||||
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
|
"KSampler_inspire_pipe": KSamplerBasicPipeExtractor, # comfyui-inspire-pack
|
||||||
@@ -1306,6 +1445,7 @@ NODE_EXTRACTORS = {
|
|||||||
"GetNode": GetNodeExtractor,
|
"GetNode": GetNodeExtractor,
|
||||||
# Latent
|
# Latent
|
||||||
"EmptyLatentImage": ImageSizeExtractor,
|
"EmptyLatentImage": ImageSizeExtractor,
|
||||||
|
"KreaDualResolutionSelector": KreaDualResolutionSelectorExtractor, # Auryg/Krea-2-Two-Stage-Sampler
|
||||||
# Flux
|
# Flux
|
||||||
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
|
"FluxGuidance": FluxGuidanceExtractor, # Add FluxGuidance
|
||||||
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
"CFGGuider": CFGGuiderExtractor, # Add CFGGuider
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ cannot drift between the two paths.
|
|||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict
|
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
|
from .constants import CLIP_SKIP_SENTINEL, METADATA_OVERWRITE_FIELDS
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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
|
of 0 is preserved. The ``model`` field accepts either a manual string or
|
||||||
a wired MODEL (ModelPatcher) connection; in the latter case the source
|
a wired MODEL (ModelPatcher) connection; in the latter case the source
|
||||||
model name is extracted from the patcher's ``cached_patcher_init`` and
|
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] = {}
|
result: Dict[str, Any] = {}
|
||||||
for key in METADATA_OVERWRITE_FIELDS:
|
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 "
|
"Could not extract model name from wired MODEL input "
|
||||||
"(no cached_patcher_init); model metadata overwrite skipped"
|
"(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 key == "clip_skip":
|
||||||
if value != CLIP_SKIP_SENTINEL:
|
if value != CLIP_SKIP_SENTINEL:
|
||||||
result[key] = value
|
result[key] = value
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
from typing import Any, List, Tuple
|
from typing import Any, List, Tuple
|
||||||
import comfy.sd # pyright: ignore[reportMissingImports]
|
import comfy.sd # pyright: ignore[reportMissingImports]
|
||||||
import folder_paths # pyright: ignore[reportMissingImports]
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
@@ -58,7 +59,10 @@ class CheckpointLoaderLM:
|
|||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("sub_type") == "checkpoint":
|
if item.get("sub_type") == "checkpoint":
|
||||||
file_path = item.get("file_path", "")
|
file_path = item.get("file_path", "")
|
||||||
if 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
|
# Format using relative path with OS-native separator
|
||||||
formatted_name = _format_model_name_for_comfyui(
|
formatted_name = _format_model_name_for_comfyui(
|
||||||
file_path, model_roots
|
file_path, model_roots
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .utils import (
|
|||||||
any_type,
|
any_type,
|
||||||
apply_lora_syntax_format,
|
apply_lora_syntax_format,
|
||||||
get_loras_list,
|
get_loras_list,
|
||||||
|
validate_lora_entries,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -42,6 +43,11 @@ class CreateHookLoraLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
RETURN_TYPES = ("HOOKS", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
RETURN_NAMES = ("HOOKS", "trigger_words", "active_loras")
|
||||||
FUNCTION = "create_hook"
|
FUNCTION = "create_hook"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from .utils import (
|
|||||||
get_loras_list,
|
get_loras_list,
|
||||||
nunchaku_load_lora,
|
nunchaku_load_lora,
|
||||||
parse_lora_syntax,
|
parse_lora_syntax,
|
||||||
|
validate_lora_entries,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -142,6 +143,11 @@ class LoraLoaderLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
RETURN_TYPES = ("MODEL", "CLIP", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
RETURN_NAMES = ("MODEL", "CLIP", "trigger_words", "loaded_loras")
|
||||||
FUNCTION = "load_loras"
|
FUNCTION = "load_loras"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ and tracks the last used combination for reuse.
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
|
from .utils import validate_lora_entries
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -31,6 +32,11 @@ class LoraRandomizerLM:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("LORA_STACK",)
|
RETURN_TYPES = ("LORA_STACK",)
|
||||||
RETURN_NAMES = ("LORA_STACK",)
|
RETURN_NAMES = ("LORA_STACK",)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info
|
from ..utils.utils import get_lora_info
|
||||||
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, apply_lora_syntax_format, extract_lora_name, get_loras_list, validate_lora_entries
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
@@ -22,6 +22,11 @@ class LoraStackerLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
RETURN_TYPES = ("LORA_STACK", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
RETURN_NAMES = ("LORA_STACK", "trigger_words", "active_loras")
|
||||||
FUNCTION = "stack_loras"
|
FUNCTION = "stack_loras"
|
||||||
|
|||||||
@@ -71,10 +71,18 @@ class MetadataOverwriteLM:
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
"sampler": (
|
"sampler": (
|
||||||
"STRING",
|
"STRING,SAMPLER",
|
||||||
{
|
{
|
||||||
"default": "",
|
"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": (
|
"scheduler": (
|
||||||
@@ -164,6 +172,8 @@ class MetadataOverwriteLM:
|
|||||||
The ``model`` field accepts either a manual string or a wired MODEL
|
The ``model`` field accepts either a manual string or a wired MODEL
|
||||||
(ModelPatcher) connection; in the latter case the underlying model
|
(ModelPatcher) connection; in the latter case the underlying model
|
||||||
name is extracted from the patcher's ``cached_patcher_init`` and
|
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),)
|
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)}"
|
||||||
|
)
|
||||||
@@ -74,7 +74,10 @@ class UNETLoaderLM:
|
|||||||
for item in cache.raw_data:
|
for item in cache.raw_data:
|
||||||
if item.get("sub_type") == "diffusion_model":
|
if item.get("sub_type") == "diffusion_model":
|
||||||
file_path = item.get("file_path", "")
|
file_path = item.get("file_path", "")
|
||||||
if 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
|
# Format using relative path with OS-native separator
|
||||||
formatted_name = _format_model_name_for_comfyui(
|
formatted_name = _format_model_name_for_comfyui(
|
||||||
file_path, model_roots
|
file_path, model_roots
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import re
|
|||||||
import logging
|
import logging
|
||||||
import copy
|
import copy
|
||||||
import sys
|
import sys
|
||||||
|
import asyncio
|
||||||
import folder_paths # pyright: ignore[reportMissingImports]
|
import folder_paths # pyright: ignore[reportMissingImports]
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -111,6 +112,157 @@ def get_loras_list(kwargs):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
_LORA_EXTENSIONS = (".safetensors", ".ckpt", ".pt", ".bin")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_lora_extension(name: str) -> str:
|
||||||
|
"""Strip a known LoRA model extension from a name (case-insensitive)."""
|
||||||
|
lowered = name.lower()
|
||||||
|
for ext in _LORA_EXTENSIONS:
|
||||||
|
if lowered.endswith(ext):
|
||||||
|
return name[: -len(ext)]
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _find_missing_loras(names: list[str]) -> list[str]:
|
||||||
|
"""Return the names that cannot be resolved to an existing local LoRA file.
|
||||||
|
|
||||||
|
Mirrors the matching semantics of ``get_lora_info_absolute``
|
||||||
|
(py/utils/utils.py): after stripping the extension, a name matches a cached
|
||||||
|
LoRA when it equals the cached file name or the ``folder/file`` path. As a
|
||||||
|
fallback, a name containing a folder that only matches by basename resolves
|
||||||
|
to the first basename match (same behavior as the runtime resolver). Raw
|
||||||
|
absolute paths that exist on disk are always considered available.
|
||||||
|
|
||||||
|
The scanner cache is fetched once for all names; the cache may be stale, so
|
||||||
|
resolved paths are additionally verified with ``os.path.isfile``.
|
||||||
|
"""
|
||||||
|
if not names:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def _check() -> list[str]:
|
||||||
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
|
||||||
|
scanner = await ServiceRegistry.get_lora_scanner()
|
||||||
|
# The scanner cache may not be hydrated yet (startup, library path
|
||||||
|
# change). An empty cache is not authoritative — treat it as "cannot
|
||||||
|
# verify" and skip validation instead of flagging every active LoRA
|
||||||
|
# as missing.
|
||||||
|
if getattr(scanner, "_cache", None) is None or getattr(
|
||||||
|
scanner, "_is_initializing", False
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
|
||||||
|
lookup = {}
|
||||||
|
basename_candidates = {}
|
||||||
|
for item in cache.raw_data:
|
||||||
|
file_path = item.get("file_path")
|
||||||
|
if not file_path:
|
||||||
|
continue
|
||||||
|
file_name = item.get("file_name", "")
|
||||||
|
folder = item.get("folder", "")
|
||||||
|
file_name_no_ext = _strip_lora_extension(file_name)
|
||||||
|
path_name_no_ext = (
|
||||||
|
f"{folder}/{file_name_no_ext}".replace("\\", "/")
|
||||||
|
if folder
|
||||||
|
else file_name_no_ext
|
||||||
|
)
|
||||||
|
lookup.setdefault(file_name_no_ext, file_path)
|
||||||
|
lookup.setdefault(path_name_no_ext, file_path)
|
||||||
|
basename_candidates.setdefault(file_name_no_ext, []).append(
|
||||||
|
(folder, file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for name in names:
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
normalized = name.replace("\\", "/")
|
||||||
|
# Raw absolute paths (outside the library) are usable as-is.
|
||||||
|
if os.path.isfile(normalized):
|
||||||
|
continue
|
||||||
|
no_ext = _strip_lora_extension(normalized)
|
||||||
|
file_path = lookup.get(no_ext)
|
||||||
|
if file_path is None and "/" in no_ext:
|
||||||
|
# A name with a folder that matches only by basename resolves
|
||||||
|
# at runtime like get_lora_info_absolute's fallback does:
|
||||||
|
# prefer a candidate whose folder prefixes the name, else the
|
||||||
|
# first basename match.
|
||||||
|
folder, basename = no_ext.rsplit("/", 1)
|
||||||
|
candidates = basename_candidates.get(basename, [])
|
||||||
|
file_path = next(
|
||||||
|
(
|
||||||
|
fp
|
||||||
|
for fld, fp in candidates
|
||||||
|
if fld and no_ext.startswith(fld + "/")
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if file_path is None and candidates:
|
||||||
|
file_path = candidates[0][1]
|
||||||
|
if file_path is None or not os.path.isfile(file_path):
|
||||||
|
missing.append(name)
|
||||||
|
return missing
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if we're already in an event loop
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
# If we're in a running loop, run the async check in a separate thread
|
||||||
|
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(_check())
|
||||||
|
finally:
|
||||||
|
new_loop.close()
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
future = executor.submit(run_in_thread)
|
||||||
|
return future.result()
|
||||||
|
except RuntimeError:
|
||||||
|
# No event loop is running, we can use asyncio.run()
|
||||||
|
return asyncio.run(_check())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_lora_entries(kwargs):
|
||||||
|
"""Validate active LoRA widget entries against the local library.
|
||||||
|
|
||||||
|
Used by node ``VALIDATE_INPUTS`` implementations so ComfyUI rejects the
|
||||||
|
prompt at queue time (``custom_validation_failed``) when an active entry
|
||||||
|
references a LoRA that is not available locally — mirroring how built-in
|
||||||
|
loader nodes flag missing models before execution starts.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
None when every active entry resolves to an existing local file,
|
||||||
|
otherwise a descriptive error string listing the missing LoRAs.
|
||||||
|
Verification failures (e.g. scanner not ready) are treated as valid
|
||||||
|
so queueing is never blocked by validation machinery itself.
|
||||||
|
"""
|
||||||
|
# Missing/empty loras input is always valid; skip get_loras_list so it
|
||||||
|
# does not log a warning for the None case on every queue.
|
||||||
|
if not kwargs.get("loras"):
|
||||||
|
return None
|
||||||
|
loras = get_loras_list(kwargs)
|
||||||
|
active_names = []
|
||||||
|
for lora in loras:
|
||||||
|
if not isinstance(lora, dict):
|
||||||
|
continue
|
||||||
|
if not lora.get("active", False):
|
||||||
|
continue
|
||||||
|
active_names.append(apply_lora_syntax_format(str(lora.get("name") or "")))
|
||||||
|
try:
|
||||||
|
missing = _find_missing_loras(active_names)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to validate LoRA entries against the local library")
|
||||||
|
return None
|
||||||
|
if not missing:
|
||||||
|
return None
|
||||||
|
return "Missing LoRA(s) in local library: " + ", ".join(missing)
|
||||||
|
|
||||||
|
|
||||||
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
|
def load_state_dict_in_safetensors(path, device="cpu", filter_prefix=""):
|
||||||
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
|
"""Simplified version of load_state_dict_in_safetensors that just loads from a local path"""
|
||||||
import safetensors.torch
|
import safetensors.torch
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import os
|
import os
|
||||||
from ..utils.utils import get_lora_info_absolute
|
from ..utils.utils import get_lora_info_absolute
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from .utils import FlexibleOptionalInputType, any_type, get_loras_list
|
from .utils import FlexibleOptionalInputType, any_type, get_loras_list, validate_lora_entries
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -35,6 +35,11 @@ class WanVideoLoraSelectLM:
|
|||||||
"optional": FlexibleOptionalInputType(any_type),
|
"optional": FlexibleOptionalInputType(any_type),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def VALIDATE_INPUTS(cls, loras=None):
|
||||||
|
"""Queue-time validation: reject missing local LoRAs before execution."""
|
||||||
|
return validate_lora_entries({"loras": loras}) or True
|
||||||
|
|
||||||
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
RETURN_TYPES = ("WANVIDLORA", "STRING", "STRING")
|
||||||
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
RETURN_NAMES = ("lora", "trigger_words", "active_loras")
|
||||||
FUNCTION = "process_loras"
|
FUNCTION = "process_loras"
|
||||||
|
|||||||
+14
-4
@@ -11,7 +11,7 @@ import re
|
|||||||
from typing import Dict, List, Any, Optional, Tuple
|
from typing import Dict, List, Any, Optional, Tuple
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from ..config import config
|
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
|
from ..utils.civitai_utils import rewrite_preview_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -155,9 +155,9 @@ class RecipeMetadataParser(ABC):
|
|||||||
|
|
||||||
# Process file information if available
|
# Process file information if available
|
||||||
if 'files' in civitai_info:
|
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', [])
|
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:
|
if model_file:
|
||||||
# Get size
|
# Get size
|
||||||
@@ -261,11 +261,21 @@ class RecipeMetadataParser(ABC):
|
|||||||
checkpoint['id'] = civitai_data.get('id', 0)
|
checkpoint['id'] = civitai_data.get('id', 0)
|
||||||
|
|
||||||
if 'files' in civitai_data:
|
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(
|
model_file = next(
|
||||||
(
|
(
|
||||||
file
|
file
|
||||||
for file in civitai_data.get('files', [])
|
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,
|
None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ from ..services.websocket_progress_callback import (
|
|||||||
WebSocketProgressCallback,
|
WebSocketProgressCallback,
|
||||||
)
|
)
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
|
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .model_route_registrar import COMMON_ROUTE_DEFINITIONS, ModelRouteRegistrar
|
from .model_route_registrar import COMMON_ROUTE_DEFINITIONS, ModelRouteRegistrar
|
||||||
from .handlers.model_handlers import (
|
from .handlers.model_handlers import (
|
||||||
@@ -251,7 +252,7 @@ class BaseModelRoutes(ABC):
|
|||||||
|
|
||||||
def _find_model_file(self, files):
|
def _find_model_file(self, files):
|
||||||
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
"""Find the appropriate model file from the files list - can be overridden by subclasses."""
|
||||||
return next((file for file in files if file.get("type") in ("Model", "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]]:
|
def get_handler(self, name: str) -> Callable[[web.Request], Awaitable[web.StreamResponse]]:
|
||||||
"""Expose handlers for subclasses or tests."""
|
"""Expose handlers for subclasses or tests."""
|
||||||
|
|||||||
@@ -51,6 +51,29 @@ LICENSE_FIELDS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_broadcast_models_changed_tasks: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _broadcast_models_changed() -> None:
|
||||||
|
"""Notify connected clients that the local model library changed.
|
||||||
|
|
||||||
|
The ComfyUI graph page listens for this event to invalidate its cached
|
||||||
|
model availability data (loras widget missing-model cues / error flags)
|
||||||
|
without waiting for the cache TTL to expire.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from ...services.websocket_manager import ws_manager
|
||||||
|
|
||||||
|
task = asyncio.create_task(ws_manager.broadcast({"type": "models_changed"}))
|
||||||
|
# Keep a reference so the task is not garbage-collected mid-await.
|
||||||
|
_broadcast_models_changed_tasks.add(task)
|
||||||
|
task.add_done_callback(_broadcast_models_changed_tasks.discard)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).debug(
|
||||||
|
"Failed to broadcast models_changed", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModelPageView:
|
class ModelPageView:
|
||||||
"""Render the HTML view for model listings."""
|
"""Render the HTML view for model listings."""
|
||||||
|
|
||||||
@@ -460,6 +483,7 @@ class ModelManagementHandler:
|
|||||||
return web.Response(text="Model path is required", status=400)
|
return web.Response(text="Model path is required", status=400)
|
||||||
|
|
||||||
result = await self._lifecycle_service.delete_model(file_path)
|
result = await self._lifecycle_service.delete_model(file_path)
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
@@ -931,6 +955,8 @@ class ModelManagementHandler:
|
|||||||
file_path=file_path, new_file_name=new_file_name
|
file_path=file_path, new_file_name=new_file_name
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_broadcast_models_changed()
|
||||||
|
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
**result,
|
**result,
|
||||||
@@ -959,6 +985,7 @@ class ModelManagementHandler:
|
|||||||
)
|
)
|
||||||
|
|
||||||
result = await self._lifecycle_service.bulk_delete_models(file_paths)
|
result = await self._lifecycle_service.bulk_delete_models(file_paths)
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
@@ -1061,6 +1088,7 @@ class ModelQueryHandler:
|
|||||||
await self._service.scan_models(
|
await self._service.scan_models(
|
||||||
force_refresh=True, rebuild_cache=full_rebuild
|
force_refresh=True, rebuild_cache=full_rebuild
|
||||||
)
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
if self._service.scanner.is_cancelled():
|
if self._service.scanner.is_cancelled():
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -2235,6 +2263,8 @@ class ModelMoveHandler:
|
|||||||
result = await self._move_service.move_model(
|
result = await self._move_service.move_model(
|
||||||
file_path, target_path, use_default_paths=use_default_paths
|
file_path, target_path, use_default_paths=use_default_paths
|
||||||
)
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
_broadcast_models_changed()
|
||||||
status = 200 if result.get("success") else 500
|
status = 200 if result.get("success") else 500
|
||||||
return web.json_response(result, status=status)
|
return web.json_response(result, status=status)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -2254,6 +2284,8 @@ class ModelMoveHandler:
|
|||||||
result = await self._move_service.move_models_bulk(
|
result = await self._move_service.move_models_bulk(
|
||||||
file_paths, target_path, use_default_paths=use_default_paths
|
file_paths, target_path, use_default_paths=use_default_paths
|
||||||
)
|
)
|
||||||
|
if result.get("success"):
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result)
|
return web.json_response(result)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)
|
self._logger.error("Error moving models in bulk: %s", exc, exc_info=True)
|
||||||
@@ -2299,6 +2331,7 @@ class ModelAutoOrganizeHandler:
|
|||||||
progress_callback=self._progress_callback,
|
progress_callback=self._progress_callback,
|
||||||
exclusion_patterns=exclusion_patterns,
|
exclusion_patterns=exclusion_patterns,
|
||||||
)
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
return web.json_response(result.to_dict())
|
return web.json_response(result.to_dict())
|
||||||
except AutoOrganizeInProgressError:
|
except AutoOrganizeInProgressError:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
@@ -2502,6 +2535,7 @@ class ModelUpdateHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
hide_early_access = False
|
hide_early_access = False
|
||||||
|
hide_paid = False
|
||||||
if self._settings is not None:
|
if self._settings is not None:
|
||||||
try:
|
try:
|
||||||
hide_early_access = bool(
|
hide_early_access = bool(
|
||||||
@@ -2509,12 +2543,17 @@ class ModelUpdateHandler:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
hide_paid = bool(self._settings.get("hide_paid_updates", False))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
serialized_records = []
|
serialized_records = []
|
||||||
for record in records.values():
|
for record in records.values():
|
||||||
has_update_fn = getattr(record, "has_update", None)
|
has_update_fn = getattr(record, "has_update", None)
|
||||||
if callable(has_update_fn) and has_update_fn(
|
if 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))
|
serialized_records.append(self._serialize_record(record))
|
||||||
|
|
||||||
@@ -2668,10 +2707,16 @@ class ModelUpdateHandler:
|
|||||||
if not record or not record.versions:
|
if not record or not record.versions:
|
||||||
return record
|
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 = []
|
versions_needing_update = []
|
||||||
for version in record.versions:
|
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)
|
versions_needing_update.append(version)
|
||||||
|
|
||||||
if not versions_needing_update:
|
if not versions_needing_update:
|
||||||
@@ -2901,6 +2946,7 @@ class ModelUpdateHandler:
|
|||||||
context = version_context or {}
|
context = version_context or {}
|
||||||
# Check user setting for hiding early access versions
|
# Check user setting for hiding early access versions
|
||||||
hide_early_access = False
|
hide_early_access = False
|
||||||
|
hide_paid = False
|
||||||
if self._settings is not None:
|
if self._settings is not None:
|
||||||
try:
|
try:
|
||||||
hide_early_access = bool(
|
hide_early_access = bool(
|
||||||
@@ -2908,6 +2954,10 @@ class ModelUpdateHandler:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
|
hide_paid = bool(self._settings.get("hide_paid_updates", False))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
return {
|
return {
|
||||||
"modelType": record.model_type,
|
"modelType": record.model_type,
|
||||||
"modelId": record.model_id,
|
"modelId": record.model_id,
|
||||||
@@ -2916,7 +2966,10 @@ class ModelUpdateHandler:
|
|||||||
"inLibraryVersionIds": record.in_library_version_ids,
|
"inLibraryVersionIds": record.in_library_version_ids,
|
||||||
"lastCheckedAt": record.last_checked_at,
|
"lastCheckedAt": record.last_checked_at,
|
||||||
"shouldIgnore": record.should_ignore_model,
|
"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": [
|
"versions": [
|
||||||
self._serialize_version(version, context.get(version.version_id))
|
self._serialize_version(version, context.get(version.version_id))
|
||||||
for version in record.versions
|
for version in record.versions
|
||||||
@@ -2935,8 +2988,11 @@ class ModelUpdateHandler:
|
|||||||
|
|
||||||
# Determine if version is currently in early access
|
# Determine if version is currently in early access
|
||||||
# Two-phase detection: use exact end time if available, otherwise fallback to basic flag
|
# 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
|
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:
|
try:
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -2951,6 +3007,13 @@ class ModelUpdateHandler:
|
|||||||
# Fallback to basic EA flag from bulk API
|
# Fallback to basic EA flag from bulk API
|
||||||
is_early_access = True
|
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 {
|
return {
|
||||||
"versionId": version.version_id,
|
"versionId": version.version_id,
|
||||||
"name": version.name,
|
"name": version.name,
|
||||||
@@ -2964,6 +3027,8 @@ class ModelUpdateHandler:
|
|||||||
"earlyAccessEndsAt": version.early_access_ends_at,
|
"earlyAccessEndsAt": version.early_access_ends_at,
|
||||||
"isEarlyAccess": is_early_access,
|
"isEarlyAccess": is_early_access,
|
||||||
"usageControl": version.usage_control,
|
"usageControl": version.usage_control,
|
||||||
|
"isPaid": bool(getattr(version, "is_paid", False)),
|
||||||
|
"paidAccess": paid_access_payload,
|
||||||
"filePath": context.get("file_path"),
|
"filePath": context.get("file_path"),
|
||||||
"fileName": context.get("file_name"),
|
"fileName": context.get("file_name"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,323 @@
|
|||||||
|
"""Handler for the pending-delete undo endpoint.
|
||||||
|
|
||||||
|
Restores a staged delete batch (models or recipes) via
|
||||||
|
``PendingDeleteService.undo`` and then repairs the affected library caches:
|
||||||
|
the model cache entry is restored from the manifest's ``model_snapshot``
|
||||||
|
(including the version index and hash index), tag counts are re-incremented,
|
||||||
|
and the recipe cache is re-populated via ``RecipeScanner.add_recipe``.
|
||||||
|
|
||||||
|
The per-type scanner is resolved from the manifest's ``model_type`` page value
|
||||||
|
through the SAME ServiceRegistry getters the model route registrars use
|
||||||
|
(lora/checkpoint/embedding) - never a hardcoded lora scanner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set, cast
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from ...services.pending_delete_service import get_pending_delete_service
|
||||||
|
from .model_handlers import _broadcast_models_changed
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Manifest ``model_type`` page values -> ServiceRegistry scanner getter names.
|
||||||
|
# The model route registrars resolve per-type scanners via these getters
|
||||||
|
# (lora_routes / checkpoint_routes / embedding_routes); undo must do the same
|
||||||
|
# so the CORRECT cache is restored for the deleted model's type.
|
||||||
|
_MODEL_TYPE_GETTER_NAMES: Dict[str, str] = {
|
||||||
|
"loras": "get_lora_scanner",
|
||||||
|
"checkpoints": "get_checkpoint_scanner",
|
||||||
|
"embeddings": "get_embedding_scanner",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Staged batch ids are ``uuid.uuid4().hex`` (32 lowercase hex chars). The id is
|
||||||
|
# joined into filesystem paths by ``_find_batch_dir``, so reject anything that
|
||||||
|
# does not match this exact shape (blocks path-traversal via batch_id).
|
||||||
|
_BATCH_ID_RE = re.compile(r"^[0-9a-f]{32}$")
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeleteHandler:
|
||||||
|
"""Handle undo requests for staged model/recipe deletions."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
service_factory: Callable[[], Awaitable[Any]] = get_pending_delete_service,
|
||||||
|
scanner_getter: Optional[Callable[[str], Awaitable[Any]]] = None,
|
||||||
|
recipe_scanner_getter: Optional[Callable[[], Awaitable[Any]]] = None,
|
||||||
|
) -> None:
|
||||||
|
self._service_factory: Callable[[], Awaitable[Any]] = service_factory
|
||||||
|
self._scanner_getter: Callable[[str], Awaitable[Any]] = (
|
||||||
|
scanner_getter or self._resolve_scanner
|
||||||
|
)
|
||||||
|
self._recipe_scanner_getter: Callable[[], Awaitable[Any]] = (
|
||||||
|
recipe_scanner_getter or self._resolve_recipe_scanner
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_scanner(model_type: str) -> Any:
|
||||||
|
"""Resolve the per-type scanner for a manifest ``model_type``.
|
||||||
|
|
||||||
|
The getter is looked up on the ServiceRegistry module namespace at call
|
||||||
|
time so tests (and the registry stubs) can patch it.
|
||||||
|
"""
|
||||||
|
from ...services import service_registry
|
||||||
|
|
||||||
|
getter_name = _MODEL_TYPE_GETTER_NAMES.get(model_type)
|
||||||
|
if getter_name is None:
|
||||||
|
raise ValueError(f"Unknown model type: {model_type}")
|
||||||
|
getter = getattr(service_registry.ServiceRegistry, getter_name, None)
|
||||||
|
if not callable(getter):
|
||||||
|
raise ValueError(f"No scanner getter for model type: {model_type}")
|
||||||
|
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||||
|
if scanner is None:
|
||||||
|
raise ValueError(f"No scanner registered for model type: {model_type}")
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _resolve_recipe_scanner() -> Any:
|
||||||
|
"""Resolve the recipe scanner via the ServiceRegistry module namespace."""
|
||||||
|
from ...services import service_registry
|
||||||
|
|
||||||
|
getter = getattr(service_registry.ServiceRegistry, "get_recipe_scanner", None)
|
||||||
|
if not callable(getter):
|
||||||
|
raise ValueError("Recipe scanner getter unavailable")
|
||||||
|
scanner = await cast(Callable[[], Awaitable[Any]], getter)()
|
||||||
|
if scanner is None:
|
||||||
|
raise ValueError("No recipe scanner registered")
|
||||||
|
return scanner
|
||||||
|
|
||||||
|
async def undo_delete(self, request: web.Request) -> web.Response:
|
||||||
|
"""Restore a staged batch and its library cache entry.
|
||||||
|
|
||||||
|
Body: ``{"batch_id": str}``. On success returns
|
||||||
|
``{"success": True, "restored": [<original paths>], "kind": kind}``.
|
||||||
|
Expired/unknown batches and occupied target paths -> 404.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid JSON body"}, status=400
|
||||||
|
)
|
||||||
|
batch_id = data.get("batch_id")
|
||||||
|
if not batch_id or not isinstance(batch_id, str):
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "batch_id is required"}, status=400
|
||||||
|
)
|
||||||
|
if not _BATCH_ID_RE.fullmatch(batch_id):
|
||||||
|
# batch_id is joined into a path by _find_batch_dir - restrict to
|
||||||
|
# the exact staged-id shape so traversal payloads get 400.
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "Invalid batch_id"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
service = await self._service_factory()
|
||||||
|
try:
|
||||||
|
# Read the manifest BEFORE undo: undo() removes the batch dir.
|
||||||
|
manifest = await self._read_staged_manifest(service, batch_id)
|
||||||
|
result = await service.undo(batch_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Unexpected error undoing batch %s: %s", batch_id, exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
kind = result.get("kind")
|
||||||
|
try:
|
||||||
|
if kind == "model":
|
||||||
|
if manifest is not None:
|
||||||
|
await self._restore_model_cache(manifest)
|
||||||
|
else:
|
||||||
|
# undo() raises when the manifest is missing, so this only
|
||||||
|
# happens defensively - files are restored regardless.
|
||||||
|
logger.warning(
|
||||||
|
"Manifest missing after undo of %s; skipping cache restore",
|
||||||
|
batch_id,
|
||||||
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
|
elif kind == "recipe":
|
||||||
|
# Recipe undo is client-refresh only: re-add to the scanner
|
||||||
|
# cache, no models_changed broadcast.
|
||||||
|
if manifest is not None:
|
||||||
|
await self._restore_recipe_cache(result, manifest)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Manifest missing after undo of %s; skipping cache restore",
|
||||||
|
batch_id,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
# Files are already restored; only the cache restoration failed.
|
||||||
|
logger.error(
|
||||||
|
"Cache restoration failed after undo of %s: %s",
|
||||||
|
batch_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"restored": result.get("restored", []),
|
||||||
|
"kind": kind,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _read_staged_manifest(
|
||||||
|
service: Any, batch_id: str
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Locate and read the batch manifest while it still exists on disk."""
|
||||||
|
batch_dir = await service._find_batch_dir(batch_id)
|
||||||
|
if not batch_dir:
|
||||||
|
return None
|
||||||
|
manifest_path = os.path.join(batch_dir, "manifest.json")
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
logger.debug("Failed to read manifest for batch %s: %s", batch_id, exc)
|
||||||
|
return None
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
async def _restore_model_cache(self, manifest: Dict[str, Any]) -> None:
|
||||||
|
"""Re-add every deleted model's cache entry from the manifest.
|
||||||
|
|
||||||
|
Each main-file entry carries the deleted model's ``snapshot`` (added at
|
||||||
|
stage time), so a merged bulk manifest holds ALL snapshots - undo must
|
||||||
|
restore every one, not just the top-level winner's. Old-format
|
||||||
|
manifests without entry snapshots fall back to the top-level
|
||||||
|
``model_snapshot`` (backward compat / single-delete path).
|
||||||
|
"""
|
||||||
|
model_type = manifest.get("model_type")
|
||||||
|
if not model_type or not isinstance(model_type, str):
|
||||||
|
raise ValueError(f"Manifest carries no model_type: {manifest.get('batch_id')}")
|
||||||
|
scanner = await self._scanner_getter(model_type)
|
||||||
|
|
||||||
|
# Collect one snapshot per distinct file_path from the entry snapshots.
|
||||||
|
snapshots: List[Dict[str, Any]] = []
|
||||||
|
seen: Set[str] = set()
|
||||||
|
for entry in manifest.get("entries") or []:
|
||||||
|
snapshot = entry.get("snapshot")
|
||||||
|
if not isinstance(snapshot, dict):
|
||||||
|
continue
|
||||||
|
file_path = snapshot.get("file_path")
|
||||||
|
if not file_path or not isinstance(file_path, str):
|
||||||
|
continue
|
||||||
|
if file_path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(file_path)
|
||||||
|
snapshots.append(snapshot)
|
||||||
|
|
||||||
|
if not snapshots:
|
||||||
|
# Backward compat: pre-F3 manifests carry only the top-level
|
||||||
|
# model_snapshot (single-delete path, unchanged behavior).
|
||||||
|
top = manifest.get("model_snapshot")
|
||||||
|
if isinstance(top, dict) and top.get("file_path"):
|
||||||
|
snapshots = [top]
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Manifest %s has no restorable model snapshot; skipping cache restore",
|
||||||
|
manifest.get("batch_id"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cache = await scanner.get_cached_data()
|
||||||
|
if cache is None:
|
||||||
|
logger.warning(
|
||||||
|
"Scanner cache unavailable for %s; skipping cache restore", model_type
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for snapshot in snapshots:
|
||||||
|
file_path = str(snapshot["file_path"])
|
||||||
|
# A rescan between delete and undo may have re-added a stale entry
|
||||||
|
# for this path - drop it so exactly one (the snapshot) remains.
|
||||||
|
cache.raw_data = [
|
||||||
|
item for item in cache.raw_data if item.get("file_path") != file_path
|
||||||
|
]
|
||||||
|
|
||||||
|
# Restore tag counts (mirror of the bulk-delete decrement in
|
||||||
|
# _batch_update_cache_for_deleted_models: undo re-increments).
|
||||||
|
tags = snapshot.get("tags")
|
||||||
|
if isinstance(tags, list):
|
||||||
|
for tag in tags:
|
||||||
|
if not isinstance(tag, str) or not tag:
|
||||||
|
continue
|
||||||
|
scanner._tags_count[tag] = scanner._tags_count.get(tag, 0) + 1
|
||||||
|
|
||||||
|
cache.raw_data.append(dict(snapshot))
|
||||||
|
|
||||||
|
# Re-register the path in the hash index (add_entry guards a
|
||||||
|
# missing sha256 internally; still guard defensively here).
|
||||||
|
sha256 = snapshot.get("sha256") or ""
|
||||||
|
autov3 = snapshot.get("autov3")
|
||||||
|
hash_index = getattr(scanner, "_hash_index", None)
|
||||||
|
if hash_index is not None and sha256 and file_path:
|
||||||
|
hash_index.add_entry(sha256, file_path, autov3)
|
||||||
|
|
||||||
|
# Follow the bulk-delete cache-update pattern ONCE after all entries,
|
||||||
|
# including the explicit version-index rebuild so the version index
|
||||||
|
# does not go stale.
|
||||||
|
cache.rebuild_version_index()
|
||||||
|
await cache.resort()
|
||||||
|
|
||||||
|
scanner.bump_cache_version()
|
||||||
|
|
||||||
|
persist = getattr(scanner, "_persist_current_cache", None)
|
||||||
|
if callable(persist):
|
||||||
|
result = persist()
|
||||||
|
if inspect.isawaitable(result):
|
||||||
|
await result
|
||||||
|
|
||||||
|
async def _restore_recipe_cache(
|
||||||
|
self, result: Dict[str, Any], manifest: Dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
"""Re-add a restored recipe via ``RecipeScanner.add_recipe``.
|
||||||
|
|
||||||
|
The recipe JSON embeds the full recipe_data (incl. id/file_path);
|
||||||
|
``add_recipe`` only READS the ``_json_path_map`` so the forced frontend
|
||||||
|
refresh self-heals any transient path-map gap.
|
||||||
|
"""
|
||||||
|
restored = result.get("restored") or []
|
||||||
|
json_path = next(
|
||||||
|
(p for p in restored if isinstance(p, str) and p.endswith(".json")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not json_path or not os.path.exists(json_path):
|
||||||
|
# Defensive fallback to the manifest's recipe_snapshot file_path.
|
||||||
|
snapshot = manifest.get("recipe_snapshot") or {}
|
||||||
|
fallback = snapshot.get("file_path")
|
||||||
|
if fallback and os.path.exists(fallback):
|
||||||
|
json_path = fallback
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"Restored recipe JSON not found in %s; skipping cache restore",
|
||||||
|
restored,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(json_path, "r", encoding="utf-8") as handle:
|
||||||
|
recipe_data = json.load(handle)
|
||||||
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
|
logger.warning("Failed to load restored recipe JSON %s: %s", json_path, exc)
|
||||||
|
return
|
||||||
|
if not isinstance(recipe_data, dict):
|
||||||
|
return
|
||||||
|
recipe_scanner = await self._recipe_scanner_getter()
|
||||||
|
await recipe_scanner.add_recipe(recipe_data)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PendingDeleteHandler"]
|
||||||
@@ -34,6 +34,7 @@ from ...utils.civitai_utils import (
|
|||||||
)
|
)
|
||||||
from ...utils.constants import NSFW_LEVELS
|
from ...utils.constants import NSFW_LEVELS
|
||||||
from ...utils.exif_utils import ExifUtils
|
from ...utils.exif_utils import ExifUtils
|
||||||
|
from ...utils.recipe_open_stats import RecipeOpenStats
|
||||||
from ...recipes.merger import GenParamsMerger
|
from ...recipes.merger import GenParamsMerger
|
||||||
from ...recipes.enrichment import RecipeEnricher
|
from ...recipes.enrichment import RecipeEnricher
|
||||||
from ...services.websocket_manager import ws_manager as default_ws_manager
|
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,
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
"update_recipe": self.management.update_recipe,
|
"update_recipe": self.management.update_recipe,
|
||||||
|
"record_recipe_open": self.management.record_recipe_open,
|
||||||
"reconnect_lora": self.management.reconnect_lora,
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
"find_duplicates": self.query.find_duplicates,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
"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)
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
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:
|
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Route controller for the pending-delete undo endpoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from .handlers.pending_delete_handler import PendingDeleteHandler
|
||||||
|
|
||||||
|
|
||||||
|
class PendingDeleteRoutes:
|
||||||
|
"""Shared route controller mirroring MiscRoutes/UpdateRoutes.
|
||||||
|
|
||||||
|
Registered ONCE per mode (py/lora_manager.py, standalone.py); NEVER through
|
||||||
|
the per-model-type ModelRouteRegistrar, which is instantiated per model
|
||||||
|
type and would register this non-prefixed route three times.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def setup_routes(app: web.Application) -> None:
|
||||||
|
"""Register the shared undo-delete endpoint."""
|
||||||
|
handler = PendingDeleteHandler()
|
||||||
|
_ = app.router.add_post("/api/lm/undo-delete", handler.undo_delete)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["PendingDeleteRoutes"]
|
||||||
@@ -43,6 +43,9 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
),
|
),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
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/recipe/move", "move_recipe"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
|
|||||||
@@ -633,6 +633,13 @@ class BaseModelService(ABC):
|
|||||||
except Exception:
|
except Exception:
|
||||||
hide_early_access = False
|
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
|
records = None
|
||||||
resolved: Optional[Dict[int, bool]] = None
|
resolved: Optional[Dict[int, bool]] = None
|
||||||
if same_base_mode:
|
if same_base_mode:
|
||||||
@@ -641,7 +648,10 @@ class BaseModelService(ABC):
|
|||||||
try:
|
try:
|
||||||
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
|
records = await cast(Awaitable[Any], record_method(self.model_type, ordered_ids))
|
||||||
resolved = {
|
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()
|
for model_id, record in records.items()
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -663,6 +673,7 @@ class BaseModelService(ABC):
|
|||||||
self.model_type,
|
self.model_type,
|
||||||
ordered_ids,
|
ordered_ids,
|
||||||
hide_early_access=hide_early_access,
|
hide_early_access=hide_early_access,
|
||||||
|
hide_paid=hide_paid,
|
||||||
))
|
))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -677,7 +688,10 @@ class BaseModelService(ABC):
|
|||||||
if resolved is None:
|
if resolved is None:
|
||||||
tasks = [
|
tasks = [
|
||||||
self.update_service.has_update(
|
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
|
for model_id in ordered_ids
|
||||||
]
|
]
|
||||||
@@ -717,6 +731,7 @@ class BaseModelService(ABC):
|
|||||||
threshold_version,
|
threshold_version,
|
||||||
base_model,
|
base_model,
|
||||||
hide_early_access=hide_early_access,
|
hide_early_access=hide_early_access,
|
||||||
|
hide_paid=hide_paid,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
flag = default_flag
|
flag = default_flag
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from ..utils.models import CheckpointMetadata
|
|||||||
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
from ..utils.file_utils import find_preview_file, normalize_path, calculate_autov3
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from .model_scanner import ModelScanner
|
from .model_scanner import ModelScanner, _is_excluded_dir
|
||||||
from .model_hash_index import ModelHashIndex
|
from .model_hash_index import ModelHashIndex
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -328,7 +328,8 @@ class CheckpointScanner(ModelScanner):
|
|||||||
if not os.path.exists(root_path):
|
if not os.path.exists(root_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||||
|
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
if not filename.endswith(".metadata.json"):
|
if not filename.endswith(".metadata.json"):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from .model_metadata_provider import (
|
|||||||
from .downloader import get_downloader
|
from .downloader import get_downloader
|
||||||
from .errors import RateLimitError, ResourceNotFoundError
|
from .errors import RateLimitError, ResourceNotFoundError
|
||||||
from ..utils.civitai_utils import resolve_license_payload
|
from ..utils.civitai_utils import resolve_license_payload
|
||||||
|
from ..utils.constants import MODEL_WEIGHT_FILE_TYPES
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -538,10 +539,16 @@ class CivitaiClient:
|
|||||||
return model_versions[0]
|
return model_versions[0]
|
||||||
|
|
||||||
def _extract_primary_model_hash(self, version_entry: Dict[str, Any]) -> Optional[str]:
|
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", []):
|
for file_info in version_entry.get("files", []):
|
||||||
if file_info.get("type") == "Model" and file_info.get("primary"):
|
if file_info.get("type") == "Model" and file_info.get("primary"):
|
||||||
hashes = file_info.get("hashes", {})
|
model_hash = (file_info.get("hashes", {}) or {}).get("SHA256")
|
||||||
model_hash = hashes.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:
|
if model_hash:
|
||||||
return model_hash
|
return model_hash
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class DownloadCoordinator:
|
|||||||
save_dir=payload.get("model_root"),
|
save_dir=payload.get("model_root"),
|
||||||
relative_path=payload.get("relative_path", ""),
|
relative_path=payload.get("relative_path", ""),
|
||||||
use_default_paths=payload.get("use_default_paths", False),
|
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,
|
progress_callback=progress_callback,
|
||||||
download_id=download_id,
|
download_id=download_id,
|
||||||
source=payload.get("source"),
|
source=payload.get("source"),
|
||||||
|
|||||||
+125
-48
@@ -3,6 +3,7 @@
|
|||||||
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
# reportImportCycles, so the ServiceRegistry singleton pattern necessarily forms
|
||||||
# import cycles. Breaking them would require an architectural refactor.
|
# import cycles. Breaking them would require an architectural refactor.
|
||||||
import copy
|
import copy
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -18,6 +19,7 @@ from ..utils.models import LoraMetadata, CheckpointMetadata, EmbeddingMetadata
|
|||||||
from ..utils.constants import (
|
from ..utils.constants import (
|
||||||
CARD_PREVIEW_WIDTH,
|
CARD_PREVIEW_WIDTH,
|
||||||
DIFFUSION_MODEL_BASE_MODELS,
|
DIFFUSION_MODEL_BASE_MODELS,
|
||||||
|
MODEL_WEIGHT_FILE_TYPES,
|
||||||
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
SUPPORTED_DOWNLOAD_SKIP_BASE_MODELS,
|
||||||
VALID_LORA_TYPES,
|
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:
|
class DownloadManager:
|
||||||
_instance = None
|
_instance = None
|
||||||
_lock = asyncio.Lock()
|
_lock = asyncio.Lock()
|
||||||
@@ -217,6 +224,7 @@ class DownloadManager:
|
|||||||
download_id: str | None = None,
|
download_id: str | None = None,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
file_params: Dict[str, Any] | None = None,
|
file_params: Dict[str, Any] | None = None,
|
||||||
|
use_save_dir_as_root: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Download model from Civitai with task tracking and concurrency control
|
"""Download model from Civitai with task tracking and concurrency control
|
||||||
|
|
||||||
@@ -257,6 +265,7 @@ class DownloadManager:
|
|||||||
"save_dir": save_dir,
|
"save_dir": save_dir,
|
||||||
"relative_path": relative_path,
|
"relative_path": relative_path,
|
||||||
"use_default_paths": bool(use_default_paths),
|
"use_default_paths": bool(use_default_paths),
|
||||||
|
"use_save_dir_as_root": bool(use_save_dir_as_root),
|
||||||
"source": source,
|
"source": source,
|
||||||
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
"file_params": copy.deepcopy(file_params) if file_params is not None else None,
|
||||||
"progress": 0,
|
"progress": 0,
|
||||||
@@ -287,6 +296,7 @@ class DownloadManager:
|
|||||||
use_default_paths,
|
use_default_paths,
|
||||||
source,
|
source,
|
||||||
file_params,
|
file_params,
|
||||||
|
use_save_dir_as_root,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -321,6 +331,7 @@ class DownloadManager:
|
|||||||
use_default_paths: bool = False,
|
use_default_paths: bool = False,
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
file_params: Dict[str, Any] | None = None,
|
file_params: Dict[str, Any] | None = None,
|
||||||
|
use_save_dir_as_root: bool = False,
|
||||||
):
|
):
|
||||||
"""Execute download with semaphore to limit concurrency"""
|
"""Execute download with semaphore to limit concurrency"""
|
||||||
# Update status to waiting
|
# Update status to waiting
|
||||||
@@ -401,6 +412,7 @@ class DownloadManager:
|
|||||||
),
|
),
|
||||||
source,
|
source,
|
||||||
file_params,
|
file_params,
|
||||||
|
use_save_dir_as_root=use_save_dir_as_root,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Update status based on result
|
# Update status based on result
|
||||||
@@ -621,6 +633,7 @@ class DownloadManager:
|
|||||||
"save_dir": info.get("save_dir"),
|
"save_dir": info.get("save_dir"),
|
||||||
"relative_path": info.get("relative_path", ""),
|
"relative_path": info.get("relative_path", ""),
|
||||||
"use_default_paths": bool(info.get("use_default_paths", False)),
|
"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"),
|
"source": info.get("source"),
|
||||||
"file_params": copy.deepcopy(info.get("file_params")),
|
"file_params": copy.deepcopy(info.get("file_params")),
|
||||||
"transfer_backend": info.get("transfer_backend", "aria2"),
|
"transfer_backend": info.get("transfer_backend", "aria2"),
|
||||||
@@ -643,6 +656,7 @@ class DownloadManager:
|
|||||||
"save_dir": record.get("save_dir"),
|
"save_dir": record.get("save_dir"),
|
||||||
"relative_path": record.get("relative_path", ""),
|
"relative_path": record.get("relative_path", ""),
|
||||||
"use_default_paths": bool(record.get("use_default_paths", False)),
|
"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"),
|
"source": record.get("source"),
|
||||||
"file_params": copy.deepcopy(record.get("file_params")),
|
"file_params": copy.deepcopy(record.get("file_params")),
|
||||||
"progress": record.get("progress", 0),
|
"progress": record.get("progress", 0),
|
||||||
@@ -1001,6 +1015,7 @@ class DownloadManager:
|
|||||||
bool(restored.get("use_default_paths", False)),
|
bool(restored.get("use_default_paths", False)),
|
||||||
restored.get("source"),
|
restored.get("source"),
|
||||||
restored.get("file_params"),
|
restored.get("file_params"),
|
||||||
|
bool(restored.get("use_save_dir_as_root", False)),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
@@ -1134,6 +1149,7 @@ class DownloadManager:
|
|||||||
transfer_backend: str = "python",
|
transfer_backend: str = "python",
|
||||||
source: str | None = None,
|
source: str | None = None,
|
||||||
file_params: Dict[str, Any] | None = None,
|
file_params: Dict[str, Any] | None = None,
|
||||||
|
use_save_dir_as_root: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Wrapper for original download_from_civitai implementation"""
|
"""Wrapper for original download_from_civitai implementation"""
|
||||||
try:
|
try:
|
||||||
@@ -1362,36 +1378,41 @@ class DownloadManager:
|
|||||||
# Handle use_default_paths
|
# Handle use_default_paths
|
||||||
if use_default_paths:
|
if use_default_paths:
|
||||||
settings_manager = get_settings_manager()
|
settings_manager = get_settings_manager()
|
||||||
# Set save_dir based on model type
|
# With use_save_dir_as_root, an explicitly provided save_dir is kept
|
||||||
if model_type == "checkpoint":
|
# as the base root and the path template is resolved underneath it.
|
||||||
if is_diffusion_model:
|
# Otherwise fall back to the configured default root, which keeps the
|
||||||
default_path = settings_manager.get("default_unet_root")
|
# classic "download to default root" behavior for regular downloads.
|
||||||
error_msg = "Default unet root path not set in settings"
|
if not save_dir or not use_save_dir_as_root:
|
||||||
else:
|
# Set save_dir based on model type
|
||||||
default_path = settings_manager.get("default_checkpoint_root")
|
if model_type == "checkpoint":
|
||||||
error_msg = "Default checkpoint root path not set in settings"
|
if is_diffusion_model:
|
||||||
if not default_path:
|
default_path = settings_manager.get("default_unet_root")
|
||||||
return {
|
error_msg = "Default unet root path not set in settings"
|
||||||
"success": False,
|
else:
|
||||||
"error": error_msg,
|
default_path = settings_manager.get("default_checkpoint_root")
|
||||||
}
|
error_msg = "Default checkpoint root path not set in settings"
|
||||||
save_dir = default_path
|
if not default_path:
|
||||||
elif model_type == "lora":
|
return {
|
||||||
default_path = settings_manager.get("default_lora_root")
|
"success": False,
|
||||||
if not default_path:
|
"error": error_msg,
|
||||||
return {
|
}
|
||||||
"success": False,
|
save_dir = default_path
|
||||||
"error": "Default lora root path not set in settings",
|
elif model_type == "lora":
|
||||||
}
|
default_path = settings_manager.get("default_lora_root")
|
||||||
save_dir = default_path
|
if not default_path:
|
||||||
elif model_type == "embedding":
|
return {
|
||||||
default_path = settings_manager.get("default_embedding_root")
|
"success": False,
|
||||||
if not default_path:
|
"error": "Default lora root path not set in settings",
|
||||||
return {
|
}
|
||||||
"success": False,
|
save_dir = default_path
|
||||||
"error": "Default embedding root path not set in settings",
|
elif model_type == "embedding":
|
||||||
}
|
default_path = settings_manager.get("default_embedding_root")
|
||||||
save_dir = default_path
|
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
|
# Calculate relative path using template
|
||||||
relative_path = self._calculate_relative_path(version_info, model_type)
|
relative_path = self._calculate_relative_path(version_info, model_type)
|
||||||
@@ -1414,24 +1435,48 @@ class DownloadManager:
|
|||||||
# Create directory if it doesn't exist
|
# Create directory if it doesn't exist
|
||||||
os.makedirs(save_dir, exist_ok=True)
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
# Check if this is an early access model
|
# Check if this is a paid or early access model
|
||||||
if version_info.get("earlyAccessEndsAt"):
|
paid_access = version_info.get("paidAccess")
|
||||||
early_access_date = version_info.get("earlyAccessEndsAt", "")
|
if isinstance(paid_access, str):
|
||||||
# Convert to a readable date if possible
|
# Some providers (e.g. CivArchive fallback) carry the DTO as JSON text
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
parsed = json.loads(paid_access)
|
||||||
|
paid_access = parsed if isinstance(parsed, dict) else None
|
||||||
date_obj = datetime.fromisoformat(
|
except (TypeError, ValueError):
|
||||||
early_access_date.replace("Z", "+00:00")
|
paid_access = None
|
||||||
)
|
if not isinstance(paid_access, dict):
|
||||||
formatted_date = date_obj.strftime("%Y-%m-%d")
|
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 = (
|
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:
|
else:
|
||||||
early_access_msg = "This model requires payment. "
|
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(
|
logger.warning(
|
||||||
f"Early access model detected: {version_info.get('name', 'Unknown')}"
|
f"Early access model detected: {version_info.get('name', 'Unknown')}"
|
||||||
)
|
)
|
||||||
@@ -1486,7 +1531,7 @@ class DownloadManager:
|
|||||||
f
|
f
|
||||||
for f in files
|
for f in files
|
||||||
if f.get("primary")
|
if f.get("primary")
|
||||||
and f.get("type") in ("Model", "Negative", "Diffusion Model", "UNet")
|
and f.get("type") in MODEL_WEIGHT_FILE_TYPES
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
@@ -1526,21 +1571,52 @@ class DownloadManager:
|
|||||||
# Fallback to primary file if no match found
|
# Fallback to primary file if no match found
|
||||||
if not file_info:
|
if not file_info:
|
||||||
logger.debug("[download] Looking for primary file as fallback")
|
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(
|
file_info = next(
|
||||||
(
|
(
|
||||||
f
|
f
|
||||||
for f in files
|
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,
|
None,
|
||||||
)
|
)
|
||||||
if file_info:
|
if file_info:
|
||||||
logger.debug(
|
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"),
|
file_info.get("id"), file_info.get("name"),
|
||||||
)
|
)
|
||||||
else:
|
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:
|
if not file_info:
|
||||||
return {"success": False, "error": "No suitable file found in metadata"}
|
return {"success": False, "error": "No suitable file found in metadata"}
|
||||||
@@ -2761,6 +2837,7 @@ class DownloadManager:
|
|||||||
bool(persisted.get("use_default_paths", False)),
|
bool(persisted.get("use_default_paths", False)),
|
||||||
persisted.get("source"),
|
persisted.get("source"),
|
||||||
persisted.get("file_params"),
|
persisted.get("file_params"),
|
||||||
|
bool(persisted.get("use_save_dir_as_root", False)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import os
|
|||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||||
|
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..services.pending_delete_service import get_pending_delete_service
|
||||||
from ..utils.constants import PREVIEW_EXTENSIONS
|
from ..utils.constants import PREVIEW_EXTENSIONS
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
|
|
||||||
@@ -129,9 +130,24 @@ class ModelLifecycleService:
|
|||||||
target_dir = os.path.dirname(file_path)
|
target_dir = os.path.dirname(file_path)
|
||||||
base_name = os.path.basename(file_path)
|
base_name = os.path.basename(file_path)
|
||||||
file_name, main_extension = os.path.splitext(base_name)
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
deleted_files = await delete_model_artifacts(
|
|
||||||
target_dir, file_name, main_extension=main_extension
|
# Stage the delete into the pending-delete service when undo is
|
||||||
|
# enabled; a successful stage renames the artifacts away, otherwise
|
||||||
|
# fall back to the direct hard delete.
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
batch_id = await pending_delete_service.stage_model_delete(
|
||||||
|
scanner=self._scanner,
|
||||||
|
target_dir=target_dir,
|
||||||
|
file_name=file_name,
|
||||||
|
main_extension=main_extension,
|
||||||
|
original_file_path=file_path,
|
||||||
|
cached_entry=cached_entry,
|
||||||
)
|
)
|
||||||
|
deleted_files: List[str] = []
|
||||||
|
if batch_id is None:
|
||||||
|
deleted_files = await delete_model_artifacts(
|
||||||
|
target_dir, file_name, main_extension=main_extension
|
||||||
|
)
|
||||||
|
|
||||||
if cache:
|
if cache:
|
||||||
cache.raw_data = [
|
cache.raw_data = [
|
||||||
@@ -151,7 +167,11 @@ class ModelLifecycleService:
|
|||||||
if callable(persist_current_cache):
|
if callable(persist_current_cache):
|
||||||
await cast(Awaitable[Any], persist_current_cache())
|
await cast(Awaitable[Any], persist_current_cache())
|
||||||
|
|
||||||
return {"success": True, "deleted_files": deleted_files}
|
return {
|
||||||
|
"success": True,
|
||||||
|
"deleted_files": deleted_files,
|
||||||
|
"batch_id": batch_id,
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _extract_model_id_from_payload(payload: Any) -> Optional[int]:
|
def _extract_model_id_from_payload(payload: Any) -> Optional[int]:
|
||||||
|
|||||||
@@ -19,12 +19,28 @@ from .service_registry import ServiceRegistry
|
|||||||
from .websocket_manager import ws_manager
|
from .websocket_manager import ws_manager
|
||||||
from .persistent_model_cache import get_persistent_cache
|
from .persistent_model_cache import get_persistent_cache
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
|
from .pending_delete_service import PENDING_DELETE_DIR_NAME, get_pending_delete_service
|
||||||
from .cache_entry_validator import CacheEntryValidator
|
from .cache_entry_validator import CacheEntryValidator
|
||||||
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
from .cache_health_monitor import CacheHealthMonitor, CacheHealthStatus
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_excluded_dir(name: str) -> bool:
|
||||||
|
"""Return True when a directory entry must be skipped during model walks.
|
||||||
|
|
||||||
|
The pending-delete staging directory is excluded so staged files never
|
||||||
|
appear in the library as ghost model entries.
|
||||||
|
"""
|
||||||
|
return name == PENDING_DELETE_DIR_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pending_delete_path(path: str) -> bool:
|
||||||
|
"""Return True when any path component is the pending-delete staging dir."""
|
||||||
|
normalized = str(path).replace(os.sep, "/")
|
||||||
|
return any(part == PENDING_DELETE_DIR_NAME for part in normalized.split("/"))
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CacheBuildResult:
|
class CacheBuildResult:
|
||||||
"""Represents the outcome of scanning model files for cache building."""
|
"""Represents the outcome of scanning model files for cache building."""
|
||||||
@@ -711,6 +727,8 @@ class ModelScanner:
|
|||||||
if ext in self.file_extensions:
|
if ext in self.file_extensions:
|
||||||
total_files += 1
|
total_files += 1
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
|
if _is_excluded_dir(entry.name):
|
||||||
|
continue
|
||||||
count_recursive(entry.path)
|
count_recursive(entry.path)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
logger.error(f"Error counting files in entry {entry.path}: {e}")
|
||||||
@@ -864,7 +882,8 @@ class ModelScanner:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Recursively scan directory
|
# Recursively scan directory
|
||||||
for root, _, files in os.walk(root_path, followlinks=True):
|
for root, dirnames, files in os.walk(root_path, followlinks=True):
|
||||||
|
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||||
real_root = os.path.realpath(root)
|
real_root = os.path.realpath(root)
|
||||||
if real_root in visited_real_paths:
|
if real_root in visited_real_paths:
|
||||||
continue
|
continue
|
||||||
@@ -1137,6 +1156,11 @@ class ModelScanner:
|
|||||||
hash_index = hash_index or self._hash_index
|
hash_index = hash_index or self._hash_index
|
||||||
excluded_models = excluded_models if excluded_models is not None else self._excluded_models
|
excluded_models = excluded_models if excluded_models is not None else self._excluded_models
|
||||||
|
|
||||||
|
# Belt-and-braces: staged files must never become library entries even
|
||||||
|
# if a caller invokes this method directly with a staging path.
|
||||||
|
if _is_pending_delete_path(file_path):
|
||||||
|
return None
|
||||||
|
|
||||||
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
metadata, should_skip = await MetadataManager.load_metadata(file_path, self.model_class)
|
||||||
|
|
||||||
if should_skip:
|
if should_skip:
|
||||||
@@ -1456,6 +1480,8 @@ class ModelScanner:
|
|||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
return
|
return
|
||||||
elif entry.is_dir(follow_symlinks=True):
|
elif entry.is_dir(follow_symlinks=True):
|
||||||
|
if _is_excluded_dir(entry.name):
|
||||||
|
continue
|
||||||
await scan_recursive(entry.path, root_path, visited_paths)
|
await scan_recursive(entry.path, root_path, visited_paths)
|
||||||
except Exception as entry_error:
|
except Exception as entry_error:
|
||||||
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
logger.error(f"Error processing entry {entry.path}: {entry_error}")
|
||||||
@@ -2206,6 +2232,11 @@ class ModelScanner:
|
|||||||
# Track deleted models to update cache once
|
# Track deleted models to update cache once
|
||||||
deleted_models = []
|
deleted_models = []
|
||||||
|
|
||||||
|
# Stage each file into the pending-delete staging area and merge
|
||||||
|
# all per-file batches into ONE batch for the whole bulk action.
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
batch_ids: List[str] = []
|
||||||
|
|
||||||
for file_path in file_paths:
|
for file_path in file_paths:
|
||||||
if self.is_cancelled():
|
if self.is_cancelled():
|
||||||
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
|
logger.info(f"{self.model_type.capitalize()} Scanner: Bulk delete cancelled by user")
|
||||||
@@ -2218,11 +2249,35 @@ class ModelScanner:
|
|||||||
base_name = os.path.basename(file_path)
|
base_name = os.path.basename(file_path)
|
||||||
file_name, main_extension = os.path.splitext(base_name)
|
file_name, main_extension = os.path.splitext(base_name)
|
||||||
|
|
||||||
deleted_files = await delete_model_artifacts(
|
# Snapshot the cache entry BEFORE the cache mutation that
|
||||||
target_dir,
|
# runs after the loop - the manifest needs it for undo.
|
||||||
file_name,
|
cached_entry = None
|
||||||
|
if cache is not None:
|
||||||
|
cached_entry = next(
|
||||||
|
(item for item in cache.raw_data if item.get('file_path') == file_path),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_id = await pending_delete_service.stage_model_delete(
|
||||||
|
scanner=self,
|
||||||
|
target_dir=target_dir,
|
||||||
|
file_name=file_name,
|
||||||
main_extension=main_extension,
|
main_extension=main_extension,
|
||||||
|
original_file_path=file_path,
|
||||||
|
cached_entry=cached_entry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if batch_id is not None:
|
||||||
|
# Artifacts were renamed into staging: the main file is
|
||||||
|
# gone from its original location.
|
||||||
|
batch_ids.append(batch_id)
|
||||||
|
deleted_files = [file_path]
|
||||||
|
else:
|
||||||
|
deleted_files = await delete_model_artifacts(
|
||||||
|
target_dir,
|
||||||
|
file_name,
|
||||||
|
main_extension=main_extension,
|
||||||
|
)
|
||||||
|
|
||||||
if deleted_files:
|
if deleted_files:
|
||||||
deleted_models.append(file_path)
|
deleted_models.append(file_path)
|
||||||
@@ -2246,6 +2301,18 @@ class ModelScanner:
|
|||||||
'error': str(e)
|
'error': str(e)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Merge every staged per-file batch into ONE undoable batch. On a
|
||||||
|
# merge failure (cross-volume EXDEV etc.) the response falls back
|
||||||
|
# to the constituent batch_ids array so the frontend can undo them
|
||||||
|
# sequentially.
|
||||||
|
batch_field: Dict[str, Any] = {}
|
||||||
|
if batch_ids:
|
||||||
|
merged_id = await pending_delete_service.merge_batches(batch_ids)
|
||||||
|
if merged_id is not None:
|
||||||
|
batch_field['batch_id'] = merged_id
|
||||||
|
else:
|
||||||
|
batch_field['batch_ids'] = list(batch_ids)
|
||||||
|
|
||||||
# Batch update cache if any models were deleted
|
# Batch update cache if any models were deleted
|
||||||
if deleted_models:
|
if deleted_models:
|
||||||
# Update the cache in a batch operation
|
# Update the cache in a batch operation
|
||||||
@@ -2257,7 +2324,8 @@ class ModelScanner:
|
|||||||
'total_deleted': total_deleted,
|
'total_deleted': total_deleted,
|
||||||
'total_attempted': len(file_paths),
|
'total_attempted': len(file_paths),
|
||||||
'cache_updated': cache_updated,
|
'cache_updated': cache_updated,
|
||||||
'results': results
|
'results': results,
|
||||||
|
**batch_field
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -74,6 +75,8 @@ class ModelVersionRecord:
|
|||||||
sort_index: int = 0
|
sort_index: int = 0
|
||||||
is_early_access: bool = False
|
is_early_access: bool = False
|
||||||
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
usage_control: Optional[str] = None # "Download", "Generation", "InternalGeneration"
|
||||||
|
paid_access: Optional[str] = None # JSON string of the CivitAI paidAccess DTO
|
||||||
|
is_paid: bool = False # True when paidAccess.permanent is True (permanent paid gate)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -107,13 +110,17 @@ class ModelUpdateRecord:
|
|||||||
return [version.version_id for version in self.versions if version.is_in_library]
|
return [version.version_id for version in self.versions if version.is_in_library]
|
||||||
|
|
||||||
def has_update(
|
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:
|
) -> bool:
|
||||||
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
"""Return True when a non-ignored remote version newer than the newest local copy is available.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
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_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:
|
if self.should_ignore_model:
|
||||||
@@ -129,6 +136,7 @@ class ModelUpdateRecord:
|
|||||||
not version.is_in_library
|
not version.is_in_library
|
||||||
and not version.should_ignore
|
and not version.should_ignore
|
||||||
and not (hide_early_access and ModelUpdateRecord._is_early_access_active(version))
|
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))
|
and not (hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version))
|
||||||
for version in self.versions
|
for version in self.versions
|
||||||
)
|
)
|
||||||
@@ -138,6 +146,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_paid and version.is_paid:
|
||||||
|
continue
|
||||||
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
continue
|
continue
|
||||||
if version.version_id > max_in_library:
|
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
|
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)
|
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
|
# Phase 2: Precise check with exact end time
|
||||||
if version.early_access_ends_at:
|
if version.early_access_ends_at:
|
||||||
try:
|
try:
|
||||||
@@ -178,6 +193,7 @@ class ModelUpdateRecord:
|
|||||||
local_base_model: Optional[str],
|
local_base_model: Optional[str],
|
||||||
hide_early_access: bool = False,
|
hide_early_access: bool = False,
|
||||||
hide_non_downloadable: bool = True,
|
hide_non_downloadable: bool = True,
|
||||||
|
hide_paid: bool = False,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True when a newer remote version with the same base model exists.
|
"""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.
|
local_base_model: The base model to filter by.
|
||||||
hide_early_access: If True, exclude early access versions from update check.
|
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_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:
|
if self.should_ignore_model:
|
||||||
@@ -216,6 +233,8 @@ class ModelUpdateRecord:
|
|||||||
continue
|
continue
|
||||||
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
if hide_early_access and ModelUpdateRecord._is_early_access_active(version):
|
||||||
continue
|
continue
|
||||||
|
if hide_paid and version.is_paid:
|
||||||
|
continue
|
||||||
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
if hide_non_downloadable and not ModelUpdateRecord._is_downloadable(version):
|
||||||
continue
|
continue
|
||||||
version_base = _normalize_base_model(version.base_model)
|
version_base = _normalize_base_model(version.base_model)
|
||||||
@@ -252,6 +271,8 @@ class ModelUpdateService:
|
|||||||
is_in_library INTEGER NOT NULL DEFAULT 0,
|
is_in_library INTEGER NOT NULL DEFAULT 0,
|
||||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
usage_control TEXT,
|
usage_control TEXT,
|
||||||
|
paid_access TEXT,
|
||||||
|
is_paid INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (model_id, version_id),
|
PRIMARY KEY (model_id, version_id),
|
||||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
@@ -491,6 +512,14 @@ class ModelUpdateService:
|
|||||||
"ALTER TABLE model_update_versions "
|
"ALTER TABLE model_update_versions "
|
||||||
"ADD COLUMN usage_control TEXT"
|
"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():
|
for column, statement in migrations.items():
|
||||||
@@ -592,6 +621,8 @@ class ModelUpdateService:
|
|||||||
should_ignore INTEGER NOT NULL DEFAULT 0,
|
should_ignore INTEGER NOT NULL DEFAULT 0,
|
||||||
early_access_ends_at TEXT,
|
early_access_ends_at TEXT,
|
||||||
is_early_access INTEGER NOT NULL DEFAULT 0,
|
is_early_access INTEGER NOT NULL DEFAULT 0,
|
||||||
|
paid_access TEXT,
|
||||||
|
is_paid INTEGER NOT NULL DEFAULT 0,
|
||||||
PRIMARY KEY (model_id, version_id),
|
PRIMARY KEY (model_id, version_id),
|
||||||
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
FOREIGN KEY(model_id) REFERENCES model_update_status(model_id) ON DELETE CASCADE
|
||||||
)
|
)
|
||||||
@@ -611,6 +642,8 @@ class ModelUpdateService:
|
|||||||
"should_ignore",
|
"should_ignore",
|
||||||
"early_access_ends_at",
|
"early_access_ends_at",
|
||||||
"is_early_access",
|
"is_early_access",
|
||||||
|
"paid_access",
|
||||||
|
"is_paid",
|
||||||
]
|
]
|
||||||
defaults = {
|
defaults = {
|
||||||
"sort_index": "0",
|
"sort_index": "0",
|
||||||
@@ -623,6 +656,8 @@ class ModelUpdateService:
|
|||||||
"should_ignore": "0",
|
"should_ignore": "0",
|
||||||
"early_access_ends_at": "NULL",
|
"early_access_ends_at": "NULL",
|
||||||
"is_early_access": "0",
|
"is_early_access": "0",
|
||||||
|
"paid_access": "NULL",
|
||||||
|
"is_paid": "0",
|
||||||
}
|
}
|
||||||
|
|
||||||
select_parts = []
|
select_parts = []
|
||||||
@@ -936,17 +971,30 @@ class ModelUpdateService:
|
|||||||
async with self._lock:
|
async with self._lock:
|
||||||
return self._get_record(model_type, model_id)
|
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."""
|
"""Determine if a model has updates pending."""
|
||||||
|
|
||||||
record = await self.get_record(model_type, model_id)
|
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(
|
async def has_updates_bulk(
|
||||||
self,
|
self,
|
||||||
model_type: str,
|
model_type: str,
|
||||||
model_ids: Sequence[int],
|
model_ids: Sequence[int],
|
||||||
hide_early_access: bool = False,
|
hide_early_access: bool = False,
|
||||||
|
hide_paid: bool = False,
|
||||||
) -> Dict[int, bool]:
|
) -> Dict[int, bool]:
|
||||||
"""Return update availability for each model id in a single database pass."""
|
"""Return update availability for each model id in a single database pass."""
|
||||||
|
|
||||||
@@ -959,7 +1007,9 @@ class ModelUpdateService:
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
model_id: (
|
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
|
if model_id in records
|
||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
@@ -1190,6 +1240,7 @@ class ModelUpdateService:
|
|||||||
"earlyAccessEndsAt": _normalize_string(
|
"earlyAccessEndsAt": _normalize_string(
|
||||||
entry.get("earlyAccessEndsAt")
|
entry.get("earlyAccessEndsAt")
|
||||||
),
|
),
|
||||||
|
"paidAccess": entry.get("paidAccess"),
|
||||||
}
|
}
|
||||||
except RateLimitError:
|
except RateLimitError:
|
||||||
raise
|
raise
|
||||||
@@ -1214,6 +1265,17 @@ class ModelUpdateService:
|
|||||||
"earlyAccessEndsAt"
|
"earlyAccessEndsAt"
|
||||||
):
|
):
|
||||||
version["earlyAccessEndsAt"] = extra["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
|
@staticmethod
|
||||||
def _collect_hashes_from_response(response: Mapping[str, Any]) -> Dict[int, str]:
|
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,
|
early_access_ends_at=remote_version.early_access_ends_at,
|
||||||
is_early_access=remote_version.is_early_access,
|
is_early_access=remote_version.is_early_access,
|
||||||
usage_control=remote_version.usage_control,
|
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"
|
is_early_access = availability == "EarlyAccess"
|
||||||
usage_control = _normalize_string(entry.get("usageControl"))
|
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(
|
return ModelVersionRecord(
|
||||||
version_id=version_id,
|
version_id=version_id,
|
||||||
name=name,
|
name=name,
|
||||||
@@ -1577,8 +1653,36 @@ class ModelUpdateService:
|
|||||||
sort_index=index,
|
sort_index=index,
|
||||||
is_early_access=is_early_access,
|
is_early_access=is_early_access,
|
||||||
usage_control=usage_control,
|
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]:
|
def _extract_size_bytes(self, files) -> Optional[int]:
|
||||||
if not isinstance(files, Iterable):
|
if not isinstance(files, Iterable):
|
||||||
return None
|
return None
|
||||||
@@ -1691,7 +1795,7 @@ class ModelUpdateService:
|
|||||||
f"""
|
f"""
|
||||||
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
SELECT model_id, version_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access, usage_control
|
is_early_access, usage_control, paid_access, is_paid
|
||||||
FROM model_update_versions
|
FROM model_update_versions
|
||||||
WHERE model_id IN ({placeholders})
|
WHERE model_id IN ({placeholders})
|
||||||
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
ORDER BY model_id ASC, sort_index ASC, version_id ASC
|
||||||
@@ -1720,6 +1824,8 @@ class ModelUpdateService:
|
|||||||
sort_index=_normalize_int(row["sort_index"]) or 0,
|
sort_index=_normalize_int(row["sort_index"]) or 0,
|
||||||
is_early_access=bool(row["is_early_access"]),
|
is_early_access=bool(row["is_early_access"]),
|
||||||
usage_control=row["usage_control"],
|
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,),
|
(record.model_id,),
|
||||||
)
|
)
|
||||||
for version in record.versions:
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO model_update_versions (
|
INSERT INTO model_update_versions (
|
||||||
version_id, model_id, sort_index, name, base_model, released_at,
|
version_id, model_id, sort_index, name, base_model, released_at,
|
||||||
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
size_bytes, preview_url, is_in_library, should_ignore, early_access_ends_at,
|
||||||
is_early_access, usage_control
|
is_early_access, usage_control, paid_access, is_paid
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
version.version_id,
|
version.version_id,
|
||||||
@@ -1793,6 +1905,8 @@ class ModelUpdateService:
|
|||||||
version.early_access_ends_at,
|
version.early_access_ends_at,
|
||||||
1 if version.is_early_access else 0,
|
1 if version.is_early_access else 0,
|
||||||
version.usage_control,
|
version.usage_control,
|
||||||
|
paid_access_value,
|
||||||
|
1 if version.is_paid else 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+253
-48
@@ -8,11 +8,13 @@ import asyncio
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import time
|
import time
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, Union, cast
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
from ..utils.constants import VALID_CHECKPOINT_SUB_TYPES, VALID_LORA_TYPES
|
||||||
from ..utils.file_utils import calculate_autov3
|
from ..utils.file_utils import calculate_autov3
|
||||||
|
from ..utils.recipe_open_stats import RecipeOpenStats
|
||||||
from .recipe_cache import RecipeCache
|
from .recipe_cache import RecipeCache
|
||||||
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
from .recipes.errors import RecipeNotFoundError, RecipePersistenceError
|
||||||
from natsort import natsorted
|
from natsort import natsorted
|
||||||
@@ -34,6 +36,11 @@ logger = logging.getLogger(__name__)
|
|||||||
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
# explicitly to "diffusion_model" (mirrors Oracle R2-F1).
|
||||||
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
_CHECKPOINT_MODEL_TYPE_ALIASES = {"diffusionmodel": "diffusion_model"}
|
||||||
|
|
||||||
|
# Known weight-file extensions stripped by _normalize_filename_key. Names are
|
||||||
|
# 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:
|
class RecipeScanner:
|
||||||
"""Service for scanning and managing recipe images"""
|
"""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_cache: dict[str, dict[str, Any]] | None = None
|
||||||
self._rematch_autov3_versions: tuple[int, int] | None = None
|
self._rematch_autov3_versions: tuple[int, int] | None = None
|
||||||
self._rematch_autov3_lock = asyncio.Lock()
|
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
|
self._initialized = True
|
||||||
|
|
||||||
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
|
async def build_local_hash_cache(self) -> dict[str, dict[str, Any]]:
|
||||||
@@ -160,6 +173,70 @@ class RecipeScanner:
|
|||||||
self._local_hash_cache_versions = versions
|
self._local_hash_cache_versions = versions
|
||||||
return cache
|
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:
|
def _is_rematch_candidate(self, entry: dict[str, Any]) -> bool:
|
||||||
"""Return True when a recipe entry is eligible for local re-matching."""
|
"""Return True when a recipe entry is eligible for local re-matching."""
|
||||||
if not isinstance(entry, dict):
|
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")
|
entry.get("isDeleted") or not entry.get("hash") or not entry.get("file_name")
|
||||||
)
|
)
|
||||||
has_identifier = (
|
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)
|
return bool(unresolved and has_identifier)
|
||||||
|
|
||||||
@@ -219,6 +299,97 @@ class RecipeScanner:
|
|||||||
self._rematch_autov3_versions = versions
|
self._rematch_autov3_versions = versions
|
||||||
return cache
|
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(
|
async def _match_rematch_entry(
|
||||||
self,
|
self,
|
||||||
entry: dict[str, Any],
|
entry: dict[str, Any],
|
||||||
@@ -245,19 +416,23 @@ class RecipeScanner:
|
|||||||
autov3_cache: dict[str, Any],
|
autov3_cache: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
is_checkpoint: bool,
|
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]]:
|
) -> 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
|
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
|
falls back to the version index via ``modelVersionId`` or ``id``; L3
|
||||||
resolves 12-char hashes through the computed AutoV3 cache. Matched
|
resolves 12-char hashes through the computed AutoV3 cache; L4
|
||||||
items are type-verified against the entry kind before being returned.
|
(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:
|
Returns:
|
||||||
Tuple of (matched item, match level) where level is "L1", "L2" or
|
Tuple of (matched item, match level) where level is "L1", "L2",
|
||||||
"L3" — or ``(None, None)`` when no usable match exists. A missing
|
"L3" or "L4" — or ``(None, None)`` when no usable match exists. A
|
||||||
local match is an expected outcome (the model may simply not be
|
missing local match is an expected outcome (the model may simply
|
||||||
present locally), not an error.
|
not be present locally), not an error.
|
||||||
"""
|
"""
|
||||||
entry_hash = (entry.get("hash") or "").lower()
|
entry_hash = (entry.get("hash") or "").lower()
|
||||||
|
|
||||||
@@ -277,33 +452,20 @@ class RecipeScanner:
|
|||||||
item = autov3_cache.get(entry_hash)
|
item = autov3_cache.get(entry_hash)
|
||||||
level = "L3" if item is not None else None
|
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:
|
if item is None:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
# Type gate: the L1 cache merges lora and checkpoint items and is
|
if not self._is_type_compatible(item, is_checkpoint=is_checkpoint):
|
||||||
# type-blind, so a match must be verified against the entry kind.
|
return (None, None)
|
||||||
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)
|
|
||||||
|
|
||||||
return (item, level)
|
return (item, level)
|
||||||
|
|
||||||
@@ -615,10 +777,11 @@ class RecipeScanner:
|
|||||||
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
async def _rematch_recipe_by_id(self, recipe_id: str) -> Dict[str, Any]:
|
||||||
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
"""Rematch a single recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
Match snapshots (local hash cache + computed autov3 cache) are built
|
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||||
BEFORE acquiring the mutation lock — both are read-only snapshots and
|
cache) are built BEFORE acquiring the mutation lock — all three are
|
||||||
the version-cached hash dict would otherwise rebuild mid-run if a scan
|
read-only snapshots and the version-cached dicts would otherwise
|
||||||
bumps a scanner's cache_version while we hold the lock.
|
rebuild mid-run if a scan bumps a scanner's cache_version while we
|
||||||
|
hold the lock.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
recipe_id: ID of the recipe to rematch
|
recipe_id: ID of the recipe to rematch
|
||||||
@@ -634,6 +797,7 @@ class RecipeScanner:
|
|||||||
"""
|
"""
|
||||||
local_cache = await self.build_local_hash_cache()
|
local_cache = await self.build_local_hash_cache()
|
||||||
autov3_cache = await self._build_rematch_autov3_cache()
|
autov3_cache = await self._build_rematch_autov3_cache()
|
||||||
|
filename_cache = await self._build_local_filename_cache()
|
||||||
|
|
||||||
async with self._mutation_lock:
|
async with self._mutation_lock:
|
||||||
# Get raw recipe from cache directly to avoid formatted fields
|
# Get raw recipe from cache directly to avoid formatted fields
|
||||||
@@ -647,7 +811,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
rematched, _errors, details = await self._rematch_single_recipe(
|
rematched, _errors, details = await self._rematch_single_recipe(
|
||||||
recipe, local_cache, autov3_cache
|
recipe, local_cache, autov3_cache, filename_cache
|
||||||
)
|
)
|
||||||
except RecipePersistenceError as exc:
|
except RecipePersistenceError as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -704,6 +868,7 @@ class RecipeScanner:
|
|||||||
recipe: Dict[str, Any],
|
recipe: Dict[str, Any],
|
||||||
local_cache: dict[str, dict[str, Any]],
|
local_cache: dict[str, dict[str, Any]],
|
||||||
autov3_cache: dict[str, dict[str, Any]],
|
autov3_cache: dict[str, dict[str, Any]],
|
||||||
|
filename_cache: Optional[dict[str, list[dict[str, Any]]]] = None,
|
||||||
) -> Tuple[int, int, Dict[str, Any]]:
|
) -> Tuple[int, int, Dict[str, Any]]:
|
||||||
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
"""Rematch a single recipe's lora/checkpoint entries against local models.
|
||||||
|
|
||||||
@@ -717,6 +882,8 @@ class RecipeScanner:
|
|||||||
recipe: The recipe dictionary to rematch (modified in-place)
|
recipe: The recipe dictionary to rematch (modified in-place)
|
||||||
local_cache: L1 hash cache snapshot (build_local_hash_cache)
|
local_cache: L1 hash cache snapshot (build_local_hash_cache)
|
||||||
autov3_cache: L3 computed-autov3 cache snapshot
|
autov3_cache: L3 computed-autov3 cache snapshot
|
||||||
|
filename_cache: L4 filename cache snapshot, or None to disable
|
||||||
|
the filename fallback
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (rematched_entries, errors, details). The errors element
|
Tuple of (rematched_entries, errors, details). The errors element
|
||||||
@@ -742,7 +909,13 @@ class RecipeScanner:
|
|||||||
if not self._is_rematch_candidate(entry):
|
if not self._is_rematch_candidate(entry):
|
||||||
continue
|
continue
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
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:
|
if item is None:
|
||||||
details["unresolved"].append(
|
details["unresolved"].append(
|
||||||
@@ -768,7 +941,13 @@ class RecipeScanner:
|
|||||||
if isinstance(checkpoint, dict):
|
if isinstance(checkpoint, dict):
|
||||||
if self._is_rematch_candidate(checkpoint):
|
if self._is_rematch_candidate(checkpoint):
|
||||||
item, level = await self._match_rematch_entry_with_level(
|
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:
|
if item is None:
|
||||||
details["unresolved"].append(
|
details["unresolved"].append(
|
||||||
@@ -830,12 +1009,13 @@ class RecipeScanner:
|
|||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
"""Rematch every recipe's deleted lora/checkpoint entries locally.
|
||||||
|
|
||||||
Match snapshots (local hash cache + computed autov3 cache) are built
|
Match snapshots (local hash cache, computed autov3 cache, filename
|
||||||
ONCE before the loop — both are read-only and the version-cached hash
|
cache) are built ONCE before the loop — all three are read-only and
|
||||||
dict would otherwise rebuild mid-run if a scan bumps a scanner's
|
the version-cached dicts would otherwise rebuild mid-run if a scan
|
||||||
cache_version while the mutation lock is held. ``_schedule_resort`` is
|
bumps a scanner's cache_version while the mutation lock is held.
|
||||||
called exactly once after the loop: it spawns an asyncio task per call,
|
``_schedule_resort`` is called exactly once after the loop: it spawns
|
||||||
so per-recipe calls would race one resort task per recipe.
|
an asyncio task per call, so per-recipe calls would race one resort
|
||||||
|
task per recipe.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
progress_callback: Optional callback for progress updates
|
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.
|
# Match snapshots built once and shared by every recipe in the loop.
|
||||||
local_cache = await self.build_local_hash_cache()
|
local_cache = await self.build_local_hash_cache()
|
||||||
autov3_cache = await self._build_rematch_autov3_cache()
|
autov3_cache = await self._build_rematch_autov3_cache()
|
||||||
|
filename_cache = await self._build_local_filename_cache()
|
||||||
|
|
||||||
async with self._mutation_lock:
|
async with self._mutation_lock:
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
@@ -923,7 +1104,7 @@ class RecipeScanner:
|
|||||||
)
|
)
|
||||||
|
|
||||||
rematched, _errors, details = await self._rematch_single_recipe(
|
rematched, _errors, details = await self._rematch_single_recipe(
|
||||||
recipe, local_cache, autov3_cache
|
recipe, local_cache, autov3_cache, filename_cache
|
||||||
)
|
)
|
||||||
if rematched > 0:
|
if rematched > 0:
|
||||||
matched_recipes += 1
|
matched_recipes += 1
|
||||||
@@ -2781,7 +2962,11 @@ class RecipeScanner:
|
|||||||
Args:
|
Args:
|
||||||
page: Current page number (1-based)
|
page: Current page number (1-based)
|
||||||
page_size: Number of items per page
|
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
|
search: Search term
|
||||||
filters: Dictionary of filters to apply
|
filters: Dictionary of filters to apply
|
||||||
search_options: Dictionary of search options 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
|
# 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]
|
field, order = (sort_by.split(":") + ["desc"])[:2]
|
||||||
reverse = order.lower() == "desc"
|
reverse = order.lower() == "desc"
|
||||||
|
|
||||||
@@ -2981,10 +3166,30 @@ class RecipeScanner:
|
|||||||
),
|
),
|
||||||
reverse=reverse,
|
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":
|
elif field == "loras_count":
|
||||||
filtered_data.sort(
|
filtered_data.sort(
|
||||||
key=lambda x: len(x.get("loras", [])), reverse=reverse
|
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
|
# Calculate pagination
|
||||||
total_items = len(filtered_data)
|
total_items = len(filtered_data)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any, Awaitable, Dict, Iterable, Optional, cast
|
|||||||
from ...config import config
|
from ...config import config
|
||||||
from ...recipes.constants import GEN_PARAM_KEYS
|
from ...recipes.constants import GEN_PARAM_KEYS
|
||||||
from ...utils.utils import calculate_recipe_fingerprint
|
from ...utils.utils import calculate_recipe_fingerprint
|
||||||
|
from ..pending_delete_service import get_pending_delete_service
|
||||||
from .errors import RecipeNotFoundError, RecipeValidationError
|
from .errors import RecipeNotFoundError, RecipeValidationError
|
||||||
|
|
||||||
|
|
||||||
@@ -201,12 +202,31 @@ class RecipePersistenceService:
|
|||||||
recipe_data = json.load(file_obj)
|
recipe_data = json.load(file_obj)
|
||||||
|
|
||||||
image_path = recipe_data.get("file_path")
|
image_path = recipe_data.get("file_path")
|
||||||
|
|
||||||
|
# Stage the delete so the recipe can be undone within the undo window.
|
||||||
|
# The staging service COPIES the JSON (and existing image) into the
|
||||||
|
# global staging dir and stores recipe_data as the manifest snapshot;
|
||||||
|
# the originals are removed below as before. When staging is skipped
|
||||||
|
# (undo disabled / staging failure) the existing hard delete runs.
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
batch_id = await pending_delete_service.stage_recipe_delete(
|
||||||
|
recipe_json_path=recipe_json_path,
|
||||||
|
image_path=image_path,
|
||||||
|
recipe_data=recipe_data,
|
||||||
|
)
|
||||||
|
|
||||||
os.remove(recipe_json_path)
|
os.remove(recipe_json_path)
|
||||||
if image_path and os.path.exists(image_path):
|
if image_path and os.path.exists(image_path):
|
||||||
os.remove(image_path)
|
os.remove(image_path)
|
||||||
|
|
||||||
await recipe_scanner.remove_recipe(recipe_id)
|
await recipe_scanner.remove_recipe(recipe_id)
|
||||||
return PersistenceResult({"success": True, "message": "Recipe deleted successfully"})
|
return PersistenceResult(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": "Recipe deleted successfully",
|
||||||
|
"batch_id": batch_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||||
"""Update persisted metadata for a recipe."""
|
"""Update persisted metadata for a recipe."""
|
||||||
@@ -450,6 +470,9 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
deleted_recipes: list[str] = []
|
deleted_recipes: list[str] = []
|
||||||
failed_recipes: list[dict[str, Any]] = []
|
failed_recipes: list[dict[str, Any]] = []
|
||||||
|
batch_ids: list[str] = []
|
||||||
|
|
||||||
|
pending_delete_service = await get_pending_delete_service()
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
@@ -461,6 +484,17 @@ class RecipePersistenceService:
|
|||||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||||
recipe_data = json.load(file_obj)
|
recipe_data = json.load(file_obj)
|
||||||
image_path = recipe_data.get("file_path")
|
image_path = recipe_data.get("file_path")
|
||||||
|
|
||||||
|
# Stage each recipe into its own batch; collect the ids so the
|
||||||
|
# whole bulk action can be merged into ONE undoable batch.
|
||||||
|
batch_id = await pending_delete_service.stage_recipe_delete(
|
||||||
|
recipe_json_path=recipe_json_path,
|
||||||
|
image_path=image_path,
|
||||||
|
recipe_data=recipe_data,
|
||||||
|
)
|
||||||
|
if batch_id:
|
||||||
|
batch_ids.append(batch_id)
|
||||||
|
|
||||||
os.remove(recipe_json_path)
|
os.remove(recipe_json_path)
|
||||||
if image_path and os.path.exists(image_path):
|
if image_path and os.path.exists(image_path):
|
||||||
os.remove(image_path)
|
os.remove(image_path)
|
||||||
@@ -471,15 +505,27 @@ class RecipePersistenceService:
|
|||||||
if deleted_recipes:
|
if deleted_recipes:
|
||||||
await recipe_scanner.bulk_remove(deleted_recipes)
|
await recipe_scanner.bulk_remove(deleted_recipes)
|
||||||
|
|
||||||
return PersistenceResult(
|
payload: dict[str, Any] = {
|
||||||
{
|
"success": True,
|
||||||
"success": True,
|
"deleted": deleted_recipes,
|
||||||
"deleted": deleted_recipes,
|
"failed": failed_recipes,
|
||||||
"failed": failed_recipes,
|
"total_deleted": len(deleted_recipes),
|
||||||
"total_deleted": len(deleted_recipes),
|
"total_failed": len(failed_recipes),
|
||||||
"total_failed": len(failed_recipes),
|
}
|
||||||
}
|
|
||||||
)
|
if batch_ids:
|
||||||
|
merged_batch_id = await pending_delete_service.merge_batches(batch_ids)
|
||||||
|
if merged_batch_id:
|
||||||
|
# Merge succeeded: one undo action covers the whole bulk.
|
||||||
|
payload["batch_id"] = merged_batch_id
|
||||||
|
else:
|
||||||
|
# Merge failure (e.g. cross-volume move): expose the constituent
|
||||||
|
# batches so the caller can undo them one at a time.
|
||||||
|
payload["batch_ids"] = batch_ids
|
||||||
|
else:
|
||||||
|
payload["batch_id"] = None
|
||||||
|
|
||||||
|
return PersistenceResult(payload)
|
||||||
|
|
||||||
async def save_recipe_from_widget(
|
async def save_recipe_from_widget(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ MODEL_FILE_EXTENSIONS = {
|
|||||||
".gguf",
|
".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 sub-types for each scanner type
|
||||||
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
VALID_LORA_SUB_TYPES = ["lora", "locon", "dora"]
|
||||||
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
VALID_CHECKPOINT_SUB_TYPES = ["checkpoint", "diffusion_model"]
|
||||||
|
|||||||
@@ -55,6 +55,32 @@ class MetadataManager:
|
|||||||
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
|
logger.error(f"{error_type} in metadata file: {metadata_path}. Error: {str(e)}. Skipping model to preserve existing data.")
|
||||||
return None, True # should_skip = True
|
return None, True # should_skip = True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _fill_local_file_facts(payload: Dict[str, Any], file_path: str) -> None:
|
||||||
|
"""Fill missing local file facts (``file_name``/``size``/``modified``) from disk.
|
||||||
|
|
||||||
|
These three fields are part of the required metadata schema but describe
|
||||||
|
the local file, not remote metadata. Payloads rebuilt by the self-heal
|
||||||
|
refresh flow (sidecar deleted, then recreated from remote data) lack
|
||||||
|
them, which makes the recreated sidecar unparseable by
|
||||||
|
``BaseModelMetadata.from_dict`` and causes the scanner to skip the model.
|
||||||
|
Fill them from the actual file whenever absent.
|
||||||
|
"""
|
||||||
|
if not file_path:
|
||||||
|
return
|
||||||
|
if payload.get("file_name") and "size" in payload and "modified" in payload:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
stat_result = os.stat(file_path)
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
if not payload.get("file_name"):
|
||||||
|
payload["file_name"] = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
if "size" not in payload:
|
||||||
|
payload["size"] = stat_result.st_size
|
||||||
|
if "modified" not in payload:
|
||||||
|
payload["modified"] = stat_result.st_mtime
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def load_metadata_payload(file_path: str) -> Dict[str, Any]:
|
async def load_metadata_payload(file_path: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -96,6 +122,11 @@ class MetadataManager:
|
|||||||
|
|
||||||
if file_path:
|
if file_path:
|
||||||
payload.setdefault("file_path", normalize_path(file_path))
|
payload.setdefault("file_path", normalize_path(file_path))
|
||||||
|
# Required schema fields that are local filesystem facts. When the
|
||||||
|
# sidecar is missing (e.g. deleted and being recreated by the
|
||||||
|
# self-heal refresh flow), restore them so the recreated sidecar
|
||||||
|
# and cache entries stay parseable.
|
||||||
|
MetadataManager._fill_local_file_facts(payload, file_path)
|
||||||
|
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -104,6 +135,14 @@ class MetadataManager:
|
|||||||
"""
|
"""
|
||||||
Replace the provided model data with the authoritative payload from disk.
|
Replace the provided model data with the authoritative payload from disk.
|
||||||
Preserves the cached folder entry if present.
|
Preserves the cached folder entry if present.
|
||||||
|
|
||||||
|
When the sidecar is missing entirely (self-heal after manual deletion),
|
||||||
|
the disk payload is nearly empty and the cache snapshot is the only
|
||||||
|
source for the schema fields required by ``BaseModelMetadata.from_dict``
|
||||||
|
(file_name/model_name/size/modified/sha256/base_model/preview_url), so
|
||||||
|
every missing key is restored from it to keep any recreated sidecar
|
||||||
|
parseable and avoid data loss on failed refreshes. When the sidecar
|
||||||
|
exists, disk data stays authoritative and no cache key is resurrected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
file_path = model_data.get("file_path")
|
file_path = model_data.get("file_path")
|
||||||
@@ -111,12 +150,29 @@ class MetadataManager:
|
|||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
folder = model_data.get("folder")
|
folder = model_data.get("folder")
|
||||||
|
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
|
||||||
|
sidecar_exists = os.path.exists(metadata_path)
|
||||||
|
cached = model_data.copy()
|
||||||
payload = await MetadataManager.load_metadata_payload(file_path)
|
payload = await MetadataManager.load_metadata_payload(file_path)
|
||||||
if folder is not None:
|
if folder is not None:
|
||||||
payload["folder"] = folder
|
payload["folder"] = folder
|
||||||
|
|
||||||
model_data.clear()
|
model_data.clear()
|
||||||
model_data.update(payload)
|
model_data.update(payload)
|
||||||
|
|
||||||
|
if not sidecar_exists:
|
||||||
|
for key, value in cached.items():
|
||||||
|
if key not in model_data and key != "folder":
|
||||||
|
model_data[key] = value
|
||||||
|
# The schema defines `modified` as the import timestamp; keep the
|
||||||
|
# cache's value over the stat-derived fallback from
|
||||||
|
# load_metadata_payload.
|
||||||
|
if "modified" in cached:
|
||||||
|
model_data["modified"] = cached["modified"]
|
||||||
|
|
||||||
|
# file_name/size are local file facts; prefer fresh stat values over
|
||||||
|
# the possibly stale cache snapshot.
|
||||||
|
MetadataManager._fill_local_file_facts(model_data, file_path)
|
||||||
return model_data
|
return model_data
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -155,7 +211,12 @@ class MetadataManager:
|
|||||||
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
|
metadata_dict['file_path'] = normalize_path(metadata_dict['file_path'])
|
||||||
if 'preview_url' in metadata_dict:
|
if 'preview_url' in metadata_dict:
|
||||||
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
|
metadata_dict['preview_url'] = normalize_path(metadata_dict['preview_url'])
|
||||||
|
|
||||||
|
# Local file facts are required schema fields; fill them when a
|
||||||
|
# payload rebuilt without them (e.g. self-heal) is being persisted.
|
||||||
|
if metadata_dict.get("file_path"):
|
||||||
|
MetadataManager._fill_local_file_facts(metadata_dict, metadata_dict["file_path"])
|
||||||
|
|
||||||
# Write to temporary file first
|
# Write to temporary file first
|
||||||
with open(temp_path, 'w', encoding='utf-8') as f:
|
with open(temp_path, 'w', encoding='utf-8') as f:
|
||||||
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)
|
json.dump(metadata_dict, f, indent=2, ensure_ascii=False)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,6 +10,7 @@ from typing import Any, Awaitable, Dict, Set, cast
|
|||||||
|
|
||||||
from ..config import config
|
from ..config import config
|
||||||
from ..services.service_registry import ServiceRegistry
|
from ..services.service_registry import ServiceRegistry
|
||||||
|
from ..services.model_scanner import _is_excluded_dir
|
||||||
from ..utils.settings_paths import get_settings_dir
|
from ..utils.settings_paths import get_settings_dir
|
||||||
|
|
||||||
# Check if running in standalone mode
|
# Check if running in standalone mode
|
||||||
@@ -421,7 +422,8 @@ class UsageStats:
|
|||||||
if not os.path.exists(root_path):
|
if not os.path.exists(root_path):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for dirpath, _dirnames, filenames in os.walk(root_path):
|
for dirpath, dirnames, filenames in os.walk(root_path):
|
||||||
|
dirnames[:] = [d for d in dirnames if not _is_excluded_dir(d)]
|
||||||
for filename in filenames:
|
for filename in filenames:
|
||||||
extension = os.path.splitext(filename)[1].lower()
|
extension = os.path.splitext(filename)[1].lower()
|
||||||
if extension not in supported_extensions:
|
if extension not in supported_extensions:
|
||||||
|
|||||||
@@ -323,6 +323,42 @@ def model_patcher_to_name(model_patcher: Any) -> Optional[str]:
|
|||||||
return _abs_model_path_to_name(abs_path)
|
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:
|
def _abs_model_path_to_name(abs_path: str) -> str:
|
||||||
"""Convert an absolute model path to a ComfyUI-style relative name.
|
"""Convert an absolute model path to a ComfyUI-style relative name.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "comfyui-lora-manager"
|
name = "comfyui-lora-manager"
|
||||||
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
description = "Revolutionize your workflow with the ultimate LoRA companion for ComfyUI!"
|
||||||
version = "1.2.0"
|
version = "1.2.1"
|
||||||
license = {file = "LICENSE"}
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aiohttp",
|
"aiohttp",
|
||||||
|
|||||||
@@ -339,6 +339,7 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
from py.routes.recipe_routes import RecipeRoutes
|
from py.routes.recipe_routes import RecipeRoutes
|
||||||
from py.routes.update_routes import UpdateRoutes
|
from py.routes.update_routes import UpdateRoutes
|
||||||
from py.routes.misc_routes import MiscRoutes
|
from py.routes.misc_routes import MiscRoutes
|
||||||
|
from py.routes.pending_delete_routes import PendingDeleteRoutes
|
||||||
from py.routes.example_images_routes import ExampleImagesRoutes
|
from py.routes.example_images_routes import ExampleImagesRoutes
|
||||||
from py.routes.preview_routes import PreviewRoutes
|
from py.routes.preview_routes import PreviewRoutes
|
||||||
from py.routes.stats_routes import StatsRoutes
|
from py.routes.stats_routes import StatsRoutes
|
||||||
@@ -356,6 +357,7 @@ class StandaloneLoraManager(LoraManager):
|
|||||||
RecipeRoutes.setup_routes(app)
|
RecipeRoutes.setup_routes(app)
|
||||||
UpdateRoutes.setup_routes(app)
|
UpdateRoutes.setup_routes(app)
|
||||||
MiscRoutes.setup_routes(app)
|
MiscRoutes.setup_routes(app)
|
||||||
|
PendingDeleteRoutes.setup_routes(app)
|
||||||
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
ExampleImagesRoutes.setup_routes(app, ws_manager=ws_manager)
|
||||||
PreviewRoutes.setup_routes(app)
|
PreviewRoutes.setup_routes(app)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,11 @@
|
|||||||
border-color: var(--lora-accent);
|
border-color: var(--lora-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.model-card.drag-over {
|
||||||
|
outline: 2px dashed var(--lora-accent);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
.model-card:focus-visible {
|
.model-card:focus-visible {
|
||||||
outline: 2px solid var(--lora-accent);
|
outline: 2px solid var(--lora-accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|||||||
@@ -447,6 +447,19 @@
|
|||||||
border-color: color-mix(in oklch, #F59F00 45%, transparent);
|
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 {
|
.version-meta-ea {
|
||||||
color: #E67700;
|
color: #E67700;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
@@ -911,6 +911,93 @@
|
|||||||
outline: none;
|
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 Slider Control */
|
||||||
.range-control {
|
.range-control {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
@@ -80,6 +80,51 @@
|
|||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Action toast: ghost action button + countdown (e.g. Undo delete) */
|
||||||
|
.toast-action-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--lora-accent);
|
||||||
|
border: 1px solid var(--lora-accent);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 0.85em;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-action-btn:hover {
|
||||||
|
background: var(--lora-accent);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-countdown {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.75em;
|
||||||
|
opacity: 0.65;
|
||||||
|
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 */
|
/* Responsive adjustments */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.toast {
|
.toast {
|
||||||
|
|||||||
@@ -168,6 +168,34 @@
|
|||||||
border-color: var(--lora-accent);
|
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 */
|
/* Keyboard shortcut indicator styling */
|
||||||
.shortcut-key {
|
.shortcut-key {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -201,8 +201,13 @@ export class BaseModelApiClient {
|
|||||||
if (state.virtualScroller) {
|
if (state.virtualScroller) {
|
||||||
state.virtualScroller.removeItemByFilePath(filePath);
|
state.virtualScroller.removeItemByFilePath(filePath);
|
||||||
}
|
}
|
||||||
showToast('toast.api.deleteSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
const batchId = data.batch_id || null;
|
||||||
return true;
|
if (!batchId) {
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
return { success: true, batch_id: batchId };
|
||||||
} else {
|
} else {
|
||||||
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
throw new Error(data.error || `Failed to delete ${this.apiConfig.config.singularName}`);
|
||||||
}
|
}
|
||||||
@@ -1228,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 {
|
try {
|
||||||
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
const response = await fetch(DOWNLOAD_ENDPOINTS.download, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -1239,6 +1244,7 @@ export class BaseModelApiClient {
|
|||||||
model_root: modelRoot,
|
model_root: modelRoot,
|
||||||
relative_path: relativePath,
|
relative_path: relativePath,
|
||||||
use_default_paths: useDefaultPaths,
|
use_default_paths: useDefaultPaths,
|
||||||
|
use_save_dir_as_root: useSaveDirAsRoot,
|
||||||
download_id: downloadId,
|
download_id: downloadId,
|
||||||
...(source ? { source } : {}),
|
...(source ? { source } : {}),
|
||||||
...(fileParams ? { file_params: fileParams } : {})
|
...(fileParams ? { file_params: fileParams } : {})
|
||||||
@@ -1622,9 +1628,14 @@ export class BaseModelApiClient {
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
deleted_count: result.deleted_count,
|
deleted_count: result.deleted_count ?? result.total_deleted,
|
||||||
failed_count: result.failed_count || 0,
|
failed_count: result.failed_count || 0,
|
||||||
errors: result.errors || []
|
errors: result.errors || [],
|
||||||
|
// Undo batch fields — batch_id on merge success, batch_ids
|
||||||
|
// array on merge failure (same success dict for the
|
||||||
|
// status='cancelled' staged-subset path)
|
||||||
|
batch_id: result.batch_id || null,
|
||||||
|
batch_ids: result.batch_ids || null
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
|
||||||
|
|||||||
@@ -657,6 +657,10 @@ export class RecipeSidebarApiClient {
|
|||||||
deleted_count: result.total_deleted,
|
deleted_count: result.total_deleted,
|
||||||
failed_count: result.total_failed || 0,
|
failed_count: result.total_failed || 0,
|
||||||
errors: result.failed || [],
|
errors: result.failed || [],
|
||||||
|
// Undo batch fields — batch_id on merge success, batch_ids
|
||||||
|
// array on merge failure
|
||||||
|
batch_id: result.batch_id || null,
|
||||||
|
batch_ids: result.batch_ids || null,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
state.loadingManager?.hide();
|
state.loadingManager?.hide();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BaseContextMenu } from './BaseContextMenu.js';
|
import { BaseContextMenu } from './BaseContextMenu.js';
|
||||||
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
import { ModelContextMenuMixin } from './ModelContextMenuMixin.js';
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHelpers.js';
|
||||||
|
import { isModelWeightFile } from '../../utils/modelFileTypes.js';
|
||||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
@@ -255,7 +256,7 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
loras: validLoras.map(lora => {
|
loras: validLoras.map(lora => {
|
||||||
const civitaiInfo = lora.civitaiInfo;
|
const civitaiInfo = lora.civitaiInfo;
|
||||||
const modelFile = civitaiInfo.files ?
|
const modelFile = civitaiInfo.files ?
|
||||||
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Basic lora info
|
// Basic lora info
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Duplicates Manager Component
|
// Duplicates Manager Component
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||||
|
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { RecipeCard } from './RecipeCard.js';
|
import { RecipeCard } from './RecipeCard.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
@@ -479,8 +480,34 @@ export class DuplicatesManager {
|
|||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Unknown error deleting recipes');
|
throw new Error(data.error || 'Unknown error deleting recipes');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
|
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
|
||||||
|
? data.batch_ids
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (data.batch_id || batchIds) {
|
||||||
|
// One undo action restores the whole selected group
|
||||||
|
const refreshFn = () => window.recipeManager.loadRecipes(true);
|
||||||
|
const onAction = data.batch_id
|
||||||
|
? () => handleUndoDelete(data.batch_id, refreshFn)
|
||||||
|
: async () => {
|
||||||
|
for (const id of batchIds) {
|
||||||
|
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
|
||||||
|
if (!succeeded) {
|
||||||
|
showToast('toast.undo.failed', { error: '' }, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshFn();
|
||||||
|
showToast('toast.undo.restored', {}, 'success');
|
||||||
|
};
|
||||||
|
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
|
||||||
|
actionText: translate('toast.undo.action'),
|
||||||
|
onAction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: 'recipes' }, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
// Exit duplicate mode if deletions were successful
|
// Exit duplicate mode if deletions were successful
|
||||||
if (data.total_deleted > 0) {
|
if (data.total_deleted > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Model Duplicates Manager Component for LoRAs and Checkpoints
|
// Model Duplicates Manager Component for LoRAs and Checkpoints
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast, showActionToast } from '../utils/uiHelpers.js';
|
||||||
|
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { formatDate } from '../utils/formatters.js';
|
import { formatDate } from '../utils/formatters.js';
|
||||||
import { resetAndReload} from '../api/modelApiFactory.js';
|
import { resetAndReload} from '../api/modelApiFactory.js';
|
||||||
@@ -732,8 +734,34 @@ export class ModelDuplicatesManager {
|
|||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Unknown error deleting models');
|
throw new Error(data.error || 'Unknown error deleting models');
|
||||||
}
|
}
|
||||||
|
|
||||||
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
|
const batchIds = !data.batch_id && Array.isArray(data.batch_ids) && data.batch_ids.length
|
||||||
|
? data.batch_ids
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (data.batch_id || batchIds) {
|
||||||
|
// One undo action restores the whole selected group
|
||||||
|
const refreshFn = () => resetAndReload(true);
|
||||||
|
const onAction = data.batch_id
|
||||||
|
? () => handleUndoDelete(data.batch_id, refreshFn)
|
||||||
|
: async () => {
|
||||||
|
for (const id of batchIds) {
|
||||||
|
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
|
||||||
|
if (!succeeded) {
|
||||||
|
showToast('toast.undo.failed', { error: '' }, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshFn();
|
||||||
|
showToast('toast.undo.restored', {}, 'success');
|
||||||
|
};
|
||||||
|
showActionToast('toast.undo.deletedBulk', { count: data.total_deleted }, 'success', {
|
||||||
|
actionText: translate('toast.undo.action'),
|
||||||
|
onAction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('toast.duplicates.deleteSuccess', { count: data.total_deleted, type: this.modelType }, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
// If models were successfully deleted
|
// If models were successfully deleted
|
||||||
if (data.total_deleted > 0) {
|
if (data.total_deleted > 0) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Recipe Card Component
|
// Recipe Card Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { configureModelCardVideo } from './shared/ModelCard.js';
|
import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
@@ -7,6 +7,8 @@ import { getCurrentPageState } from '../state/index.js';
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { bulkManager } from '../managers/BulkManager.js';
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation, getMatureBlurThreshold } from '../utils/constants.js';
|
||||||
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
|
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||||
|
|
||||||
class RecipeCard {
|
class RecipeCard {
|
||||||
constructor(recipe, clickHandler) {
|
constructor(recipe, clickHandler) {
|
||||||
@@ -363,7 +365,7 @@ class RecipeCard {
|
|||||||
</div>
|
</div>
|
||||||
<div class="delete-info">
|
<div class="delete-info">
|
||||||
<h3>${this.recipe.title}</h3>
|
<h3>${this.recipe.title}</h3>
|
||||||
<p>This action cannot be undone.</p>
|
<p>${translate('modals.deleteRecipe.recoverableWarning')}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
<p class="delete-note">Note: Deleting this recipe will not affect the LoRA files used in it.</p>
|
||||||
@@ -432,7 +434,16 @@ class RecipeCard {
|
|||||||
return response.json();
|
return response.json();
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
if (data.batch_id) {
|
||||||
|
// Staged delete: offer undo instead of the plain success toast
|
||||||
|
const batchId = data.batch_id;
|
||||||
|
showActionToast('toast.undo.deleted', { name: this.recipe.title }, 'success', {
|
||||||
|
actionText: translate('toast.undo.action'),
|
||||||
|
onAction: () => handleUndoDelete(batchId, () => window.recipeManager.loadRecipes(true)),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
state.virtualScroller.removeItemByFilePath(deleteModal.dataset.filePath);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// Recipe Modal Component
|
// Recipe Modal Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendModelPathToWorkflow, openCivitaiByMetadata, stripLoraTags, sendPromptToWorkflow, sendGenParamsToWorkflow } from '../utils/uiHelpers.js';
|
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 { translate } from '../utils/i18nHelpers.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem, getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
@@ -305,6 +306,14 @@ class RecipeModal {
|
|||||||
modalManager.showModal('recipeModal');
|
modalManager.showModal('recipeModal');
|
||||||
|
|
||||||
if (this.recipeId) {
|
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 hydrationRequestId = ++this.recipeHydrationRequestId;
|
||||||
const requestEditVersions = this.captureLocalEditVersions();
|
const requestEditVersions = this.captureLocalEditVersions();
|
||||||
this.hydrateRecipeDetails(
|
this.hydrateRecipeDetails(
|
||||||
@@ -1412,7 +1421,7 @@ class RecipeModal {
|
|||||||
loras: validLoras.map(lora => {
|
loras: validLoras.map(lora => {
|
||||||
const civitaiInfo = lora.civitaiInfo;
|
const civitaiInfo = lora.civitaiInfo;
|
||||||
const modelFile = civitaiInfo.files ?
|
const modelFile = civitaiInfo.files ?
|
||||||
civitaiInfo.files.find(file => file.type === 'Model') : null;
|
civitaiInfo.files.find(file => isModelWeightFile(file.type)) : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Basic lora info
|
// Basic lora info
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getStorageItem, setStorageItem, removeStorageItem, getSessionItem, setS
|
|||||||
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
import { showToast, openCivitaiByMetadata } from '../../utils/uiHelpers.js';
|
||||||
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
import { performModelUpdateCheck } from '../../utils/updateCheckHelpers.js';
|
||||||
import { sidebarManager } from '../SidebarManager.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
|
* PageControls class - Unified control management for model pages
|
||||||
@@ -108,20 +108,20 @@ export class PageControls {
|
|||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
initSortDropdown(sortSelect);
|
initSortDropdown(sortSelect);
|
||||||
this.applySortToSelect(this.pageState.sortBy);
|
applySortToSelect(this.pageState.sortBy);
|
||||||
sortSelect.addEventListener('change', async (e) => {
|
sortSelect.addEventListener('change', async (e) => {
|
||||||
let value = e.target.value;
|
let value = e.target.value;
|
||||||
if (value.startsWith('random')) {
|
if (value.startsWith('random')) {
|
||||||
// Every pick of Random reshuffles the list: generate a
|
// Every pick of Random reshuffles the list: generate a
|
||||||
// fresh seed so the backend keeps a stable order across
|
// fresh seed so the backend keeps a stable order across
|
||||||
// paginated requests.
|
// paginated requests.
|
||||||
value = this._randomizeSortValue();
|
value = randomizeSortValue();
|
||||||
}
|
}
|
||||||
this.pageState.sortBy = value;
|
this.pageState.sortBy = value;
|
||||||
this.saveSortPreference(value);
|
this.saveSortPreference(value);
|
||||||
// Reset the seeded Random option when switching away from
|
// Reset the seeded Random option when switching away from
|
||||||
// Random, or re-apply the fresh seed when picking it again.
|
// Random, or re-apply the fresh seed when picking it again.
|
||||||
this.applySortToSelect(value);
|
applySortToSelect(value);
|
||||||
await this.resetAndReload();
|
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
|
* Load sort preference from storage
|
||||||
*/
|
*/
|
||||||
@@ -374,7 +336,7 @@ export class PageControls {
|
|||||||
// Handle legacy format conversion
|
// Handle legacy format conversion
|
||||||
const convertedSort = this.convertLegacySortFormat(savedSort);
|
const convertedSort = this.convertLegacySortFormat(savedSort);
|
||||||
this.pageState.sortBy = convertedSort;
|
this.pageState.sortBy = convertedSort;
|
||||||
this.applySortToSelect(convertedSort);
|
applySortToSelect(convertedSort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,7 +530,7 @@ export class PageControls {
|
|||||||
this.pageState.sortBy = restoredSort;
|
this.pageState.sortBy = restoredSort;
|
||||||
this.saveSortPreference(restoredSort);
|
this.saveSortPreference(restoredSort);
|
||||||
this._removeVlmSortOption();
|
this._removeVlmSortOption();
|
||||||
this.applySortToSelect(restoredSort);
|
applySortToSelect(restoredSort);
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
sortSelect.disabled = false;
|
sortSelect.disabled = false;
|
||||||
@@ -620,7 +582,7 @@ export class PageControls {
|
|||||||
const savedGroupedSort = getStorageItem(groupedKey);
|
const savedGroupedSort = getStorageItem(groupedKey);
|
||||||
if (savedGroupedSort) {
|
if (savedGroupedSort) {
|
||||||
this.pageState.sortBy = savedGroupedSort;
|
this.pageState.sortBy = savedGroupedSort;
|
||||||
this.applySortToSelect(savedGroupedSort);
|
applySortToSelect(savedGroupedSort);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Leaving group mode: persist current sort for next time, restore non-group sort
|
// 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`);
|
const savedNormalSort = getStorageItem(`${this.pageType}_sort`);
|
||||||
if (savedNormalSort) {
|
if (savedNormalSort) {
|
||||||
this.pageState.sortBy = savedNormalSort;
|
this.pageState.sortBy = savedNormalSort;
|
||||||
this.applySortToSelect(savedNormalSort);
|
applySortToSelect(savedNormalSort);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -913,7 +875,7 @@ export class PageControls {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
this.applySortToSelect(this.pageState.sortBy);
|
applySortToSelect(this.pageState.sortBy);
|
||||||
}
|
}
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.value = this.pageState.filters?.search || '';
|
searchInput.value = this.pageState.filters?.search || '';
|
||||||
|
|||||||
@@ -18,6 +18,44 @@
|
|||||||
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
|
const SORT_GROUP_SELECTOR = '.sort-dropdown-group';
|
||||||
const ACTIVE_GROUP_SELECTOR = '.sort-dropdown-group.active, .dropdown-group.active';
|
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>.
|
* Initialize a decoupled sort dropdown around a native <select>.
|
||||||
* Idempotent: safe to call more than once on the same element.
|
* Idempotent: safe to call more than once on the same element.
|
||||||
|
|||||||
@@ -741,6 +741,46 @@ export function createModelCard(model, modelType) {
|
|||||||
configureModelCardVideo(videoElement, autoplayOnHover);
|
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;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ function isEarlyAccessActive(version) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPaidPermanent(version) {
|
||||||
|
return version && version.isPaid === true;
|
||||||
|
}
|
||||||
|
|
||||||
function isDownloadAllowed(version) {
|
function isDownloadAllowed(version) {
|
||||||
if (!version.usageControl) {
|
if (!version.usageControl) {
|
||||||
return true;
|
return true;
|
||||||
@@ -342,6 +346,7 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|||||||
const strategy = state?.global?.settings?.version_grouping;
|
const strategy = state?.global?.settings?.version_grouping;
|
||||||
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
const sameBaseMode = strategy === DISPLAY_FILTER_MODES.SAME_BASE;
|
||||||
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
const hideEarlyAccess = state?.global?.settings?.hide_early_access_updates;
|
||||||
|
const hidePaid = state?.global?.settings?.hide_paid_updates;
|
||||||
|
|
||||||
if (!sameBaseMode) {
|
if (!sameBaseMode) {
|
||||||
return Boolean(record?.hasUpdate);
|
return Boolean(record?.hasUpdate);
|
||||||
@@ -388,6 +393,9 @@ function resolveUpdateAvailability(record, baseModel, currentVersionId) {
|
|||||||
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
if (hideEarlyAccess && isEarlyAccessActive(version)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (hidePaid && isPaidPermanent(version)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!isDownloadAllowed(version)) {
|
if (!isDownloadAllowed(version)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -469,6 +477,7 @@ function renderRow(version, options) {
|
|||||||
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
|
const downloadedBadgeLabel = translate('modals.model.versions.badges.downloaded', {}, 'Downloaded');
|
||||||
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
|
const newerBadgeLabel = translate('modals.model.versions.badges.newer', {}, 'Newer Version');
|
||||||
const earlyAccessBadgeLabel = translate('modals.model.versions.badges.earlyAccess', {}, 'Early Access');
|
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 ignoredBadgeLabel = translate('modals.model.versions.badges.ignored', {}, 'Ignored');
|
||||||
const versionName = version.name || translate('modals.model.versions.labels.unnamed', {}, 'Untitled Version');
|
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)) {
|
if (!isDownloadAllowed(version)) {
|
||||||
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
|
const onSiteOnlyBadgeLabel = translate('modals.model.versions.badges.onSiteOnly', {}, 'On-Site Only');
|
||||||
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
|
badges.push(buildBadge(onSiteOnlyBadgeLabel, 'info', {
|
||||||
@@ -564,6 +583,12 @@ function renderRow(version, options) {
|
|||||||
{},
|
{},
|
||||||
'This version is only available for on-site generation on Civitai'
|
'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) {
|
} else if (isEarlyAccess) {
|
||||||
downloadTitle = translate(
|
downloadTitle = translate(
|
||||||
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
'modals.model.versions.actions.downloadEarlyAccessTooltip',
|
||||||
@@ -1307,15 +1332,41 @@ export function initVersionsTab({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveDownloadPathFromCurrentVersion() {
|
function getCurrentInLibraryVersion() {
|
||||||
if (!normalizedCurrentVersionId || !controller.record?.versions) {
|
if (!normalizedCurrentVersionId || !controller.record?.versions) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return controller.record.versions.find(
|
||||||
const currentVersion = controller.record.versions.find(
|
|
||||||
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
|
v => v.versionId === normalizedCurrentVersionId && v.isInLibrary && v.filePath
|
||||||
);
|
) || null;
|
||||||
if (!currentVersion?.filePath) {
|
}
|
||||||
|
|
||||||
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1372,10 +1423,13 @@ export function initVersionsTab({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
const pathInfo = await resolveDownloadPathFromCurrentVersion();
|
||||||
|
const resolveTemplatePath = shouldResolveTemplatePath(version, pathInfo);
|
||||||
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
const success = await downloadManager.downloadVersionWithDefaults(modelType, modelId, versionId, {
|
||||||
versionName: version.name || `#${version.versionId}`,
|
versionName: version.name || `#${version.versionId}`,
|
||||||
modelRoot: pathInfo?.modelRoot || '',
|
modelRoot: pathInfo?.modelRoot || '',
|
||||||
targetFolder: pathInfo?.targetFolder || '',
|
targetFolder: resolveTemplatePath ? '' : (pathInfo?.targetFolder || ''),
|
||||||
|
useDefaultPaths: resolveTemplatePath ? true : null,
|
||||||
|
useSaveDirAsRoot: resolveTemplatePath,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
|
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
|
||||||
|
import { handleUndoDelete } from '../utils/undoHelpers.js';
|
||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
@@ -649,10 +650,38 @@ export class BulkManager {
|
|||||||
showToast('toast.api.operationCancelled', {}, 'info');
|
showToast('toast.api.operationCancelled', {}, 'info');
|
||||||
} else if (result.success) {
|
} else if (result.success) {
|
||||||
const currentConfig = this.getCurrentDisplayConfig();
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.deletedSuccessfully', {
|
const isRecipes = state.currentPageType === 'recipes';
|
||||||
count: result.deleted_count,
|
const refreshFn = isRecipes
|
||||||
type: currentConfig.displayName.toLowerCase()
|
? () => window.recipeManager.loadRecipes(true)
|
||||||
}, 'success');
|
: () => resetAndReload(true);
|
||||||
|
|
||||||
|
if (result.batch_id || (result.batch_ids && result.batch_ids.length)) {
|
||||||
|
// One undo action for the whole bulk action — the backend
|
||||||
|
// merges staged per-file batches into a single batch, with
|
||||||
|
// a batch_ids fallback array when the merge failed
|
||||||
|
const onAction = result.batch_id
|
||||||
|
? () => handleUndoDelete(result.batch_id, refreshFn)
|
||||||
|
: async () => {
|
||||||
|
for (const id of result.batch_ids) {
|
||||||
|
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
|
||||||
|
if (!succeeded) {
|
||||||
|
showToast('toast.undo.failed', { error: '' }, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
refreshFn();
|
||||||
|
showToast('toast.undo.restored', {}, 'success');
|
||||||
|
};
|
||||||
|
showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', {
|
||||||
|
actionText: translate('toast.undo.action'),
|
||||||
|
onAction,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
showToast('toast.models.deletedSuccessfully', {
|
||||||
|
count: result.deleted_count,
|
||||||
|
type: currentConfig.displayName.toLowerCase()
|
||||||
|
}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
filePaths.forEach(path => {
|
filePaths.forEach(path => {
|
||||||
state.virtualScroller.removeItemByFilePath(path);
|
state.virtualScroller.removeItemByFilePath(path);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { showToast, setupAutoNewlineOnPaste } from '../utils/uiHelpers.js';
|
|||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
import { LoadingManager } from './LoadingManager.js';
|
import { LoadingManager } from './LoadingManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
|
import { isModelWeightFile } from '../utils/modelFileTypes.js';
|
||||||
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
import { getStorageItem, setStorageItem } from '../utils/storageHelpers.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { translate } from '../utils/i18nHelpers.js';
|
import { translate } from '../utils/i18nHelpers.js';
|
||||||
@@ -557,8 +558,7 @@ export class DownloadManager {
|
|||||||
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
const firstImage = version.images?.find(img => !img.url.endsWith('.mp4'));
|
||||||
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
const thumbnailUrl = firstImage ? firstImage.url : '/loras_static/images/no-preview.png';
|
||||||
|
|
||||||
// Count model-type files per version
|
const modelFiles = (version.files || []).filter(f => isModelWeightFile(f.type));
|
||||||
const modelFiles = (version.files || []).filter(f => f.type === 'Model' || f.type === 'UNet' || f.type === 'Diffusion Model');
|
|
||||||
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
const primaryFile = modelFiles.find(f => f.primary) || modelFiles[0] || {};
|
||||||
const fileSize = version.modelSizeKB ?
|
const fileSize = version.modelSizeKB ?
|
||||||
(version.modelSizeKB / 1024).toFixed(2) :
|
(version.modelSizeKB / 1024).toFixed(2) :
|
||||||
@@ -685,7 +685,7 @@ export class DownloadManager {
|
|||||||
if (!version) return;
|
if (!version) return;
|
||||||
|
|
||||||
this.currentVersion = version;
|
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('versionStep').style.display = 'none';
|
||||||
document.getElementById('fileSelectionStep').style.display = 'block';
|
document.getElementById('fileSelectionStep').style.display = 'block';
|
||||||
@@ -747,7 +747,7 @@ export class DownloadManager {
|
|||||||
return;
|
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);
|
this.selectedFile = modelFiles.find(f => f.id.toString() === selectedRadio.value);
|
||||||
|
|
||||||
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
console.log('[download] confirmFileSelection: selected file id=%s, name="%s", type="%s", metadata=%o',
|
||||||
@@ -912,6 +912,7 @@ export class DownloadManager {
|
|||||||
modelRoot = '',
|
modelRoot = '',
|
||||||
targetFolder = '',
|
targetFolder = '',
|
||||||
useDefaultPaths = false,
|
useDefaultPaths = false,
|
||||||
|
useSaveDirAsRoot = false,
|
||||||
source = null,
|
source = null,
|
||||||
fileParams = null,
|
fileParams = null,
|
||||||
closeModal = false,
|
closeModal = false,
|
||||||
@@ -923,7 +924,7 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const displayName = versionName || `#${versionId}`;
|
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 ws = null;
|
||||||
let updateProgress = () => { };
|
let updateProgress = () => { };
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -995,7 +996,8 @@ export class DownloadManager {
|
|||||||
useDefaultPaths,
|
useDefaultPaths,
|
||||||
downloadId,
|
downloadId,
|
||||||
source,
|
source,
|
||||||
fileParams
|
fileParams,
|
||||||
|
useSaveDirAsRoot
|
||||||
);
|
);
|
||||||
|
|
||||||
if (cancelled) {
|
if (cancelled) {
|
||||||
@@ -1809,7 +1811,9 @@ export class DownloadManager {
|
|||||||
versionName = '',
|
versionName = '',
|
||||||
source = null,
|
source = null,
|
||||||
modelRoot = '',
|
modelRoot = '',
|
||||||
targetFolder = ''
|
targetFolder = '',
|
||||||
|
useDefaultPaths = null,
|
||||||
|
useSaveDirAsRoot = false
|
||||||
} = {}) {
|
} = {}) {
|
||||||
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
|
console.warn('[download] downloadVersionWithDefaults: NO fileParams will be sent — backend will always use primary file. '
|
||||||
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
|
+ 'modelType=%s, modelId=%s, versionId=%s, versionName="%s"',
|
||||||
@@ -1824,14 +1828,14 @@ export class DownloadManager {
|
|||||||
this.modelId = modelId ? modelId.toString() : null;
|
this.modelId = modelId ? modelId.toString() : null;
|
||||||
this.source = source;
|
this.source = source;
|
||||||
|
|
||||||
const useDefaultPaths = !modelRoot;
|
|
||||||
return this.executeDownloadWithProgress({
|
return this.executeDownloadWithProgress({
|
||||||
modelId,
|
modelId,
|
||||||
versionId,
|
versionId,
|
||||||
versionName,
|
versionName,
|
||||||
modelRoot: modelRoot || '',
|
modelRoot: modelRoot || '',
|
||||||
targetFolder: targetFolder || '',
|
targetFolder: targetFolder || '',
|
||||||
useDefaultPaths,
|
useDefaultPaths: useDefaultPaths ?? !modelRoot,
|
||||||
|
useSaveDirAsRoot,
|
||||||
source,
|
source,
|
||||||
closeModal: false,
|
closeModal: false,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -434,6 +434,22 @@ export class ModalManager {
|
|||||||
this.currentOpenModal = id; // Update currently open modal
|
this.currentOpenModal = id; // Update currently open modal
|
||||||
document.body.style.top = `-${this.scrollPosition}px`;
|
document.body.style.top = `-${this.scrollPosition}px`;
|
||||||
document.body.classList.add('modal-open');
|
document.body.classList.add('modal-open');
|
||||||
|
|
||||||
|
modal.restoreFocusTo = null;
|
||||||
|
if (this._isDeleteConfirmModal(modal.element)) {
|
||||||
|
const activeElement = document.activeElement;
|
||||||
|
modal.restoreFocusTo = activeElement && activeElement !== document.body
|
||||||
|
? activeElement
|
||||||
|
: null;
|
||||||
|
modal.element.querySelector('.cancel-btn')?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Several non-delete modals share the delete-modal styling class, so an
|
||||||
|
// actual .delete-btn is required before focus is moved to Cancel.
|
||||||
|
_isDeleteConfirmModal(element) {
|
||||||
|
return element.classList.contains('delete-modal') &&
|
||||||
|
Boolean(element.querySelector('.delete-btn'));
|
||||||
}
|
}
|
||||||
|
|
||||||
closeModal(id) {
|
closeModal(id) {
|
||||||
@@ -463,6 +479,13 @@ export class ModalManager {
|
|||||||
modal.cleanupCallback();
|
modal.cleanupCallback();
|
||||||
modal.cleanupCallback = null;
|
modal.cleanupCallback = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (modal.restoreFocusTo) {
|
||||||
|
if (modal.restoreFocusTo.isConnected) {
|
||||||
|
modal.restoreFocusTo.focus();
|
||||||
|
}
|
||||||
|
modal.restoreFocusTo = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEscape(e) {
|
handleEscape(e) {
|
||||||
|
|||||||
@@ -1017,11 +1017,8 @@ export class SettingsManager {
|
|||||||
displayDensitySelect.value = state.global.settings.display_density || 'default';
|
displayDensitySelect.value = state.global.settings.display_density || 'default';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set recipes layout setting
|
// Set recipes layout setting (segmented control active state)
|
||||||
const recipesLayoutSelect = document.getElementById('recipesLayout');
|
this.updateRecipesLayoutControls(state.global.settings.recipes_layout || 'grid');
|
||||||
if (recipesLayoutSelect) {
|
|
||||||
recipesLayoutSelect.value = state.global.settings.recipes_layout || 'grid';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set card info display setting
|
// Set card info display setting
|
||||||
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
|
const cardInfoDisplaySelect = document.getElementById('cardInfoDisplay');
|
||||||
@@ -1064,6 +1061,12 @@ export class SettingsManager {
|
|||||||
hideEarlyAccessUpdatesCheckbox.checked = state.global.settings.hide_early_access_updates || false;
|
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');
|
const skipPreviouslyDownloadedModelVersionsCheckbox = document.getElementById('skipPreviouslyDownloadedModelVersions');
|
||||||
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
|
if (skipPreviouslyDownloadedModelVersionsCheckbox) {
|
||||||
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
|
skipPreviouslyDownloadedModelVersionsCheckbox.checked =
|
||||||
@@ -2288,19 +2291,18 @@ export class SettingsManager {
|
|||||||
: element.value;
|
: element.value;
|
||||||
|
|
||||||
try {
|
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
|
// Update frontend state with mapped keys
|
||||||
await this.saveSetting(settingKey, value);
|
await this.saveSetting(settingKey, value);
|
||||||
|
|
||||||
// Apply frontend settings immediately
|
// Apply frontend settings immediately
|
||||||
this.applyFrontendSettings();
|
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
|
// Recalculate layout when display density changes
|
||||||
if (settingKey === 'display_density' && state.virtualScroller) {
|
if (settingKey === 'display_density' && state.virtualScroller) {
|
||||||
state.virtualScroller.calculateLayout();
|
state.virtualScroller.calculateLayout();
|
||||||
@@ -2328,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) {
|
async saveRangeSetting(elementId, displayId, settingKey) {
|
||||||
const element = document.getElementById(elementId);
|
const element = document.getElementById(elementId);
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|||||||
+38
-4
@@ -10,7 +10,7 @@ import { DuplicatesManager } from './components/DuplicatesManager.js';
|
|||||||
import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll, recreateVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
import { sidebarManager } from './components/SidebarManager.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 {
|
class RecipePageControls {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -245,10 +245,20 @@ class RecipeManager {
|
|||||||
this.pageState.sortBy = savedSort;
|
this.pageState.sortBy = savedSort;
|
||||||
}
|
}
|
||||||
initSortDropdown(sortSelect);
|
initSortDropdown(sortSelect);
|
||||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
applySortToSelect(this.pageState.sortBy || 'date:desc');
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
let value = sortSelect.value;
|
||||||
setStorageItem('recipes_sort', 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();
|
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
|
// Rebuild the scroller on layout switch; in duplicates mode defer until
|
||||||
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
|
// exitDuplicateMode re-enables the scroller (direct recreation would dispose
|
||||||
// the old instance while initializeVirtualScroll skips duplicates mode)
|
// the old instance while initializeVirtualScroll skips duplicates mode)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
version_grouping: 'same_base',
|
version_grouping: 'same_base',
|
||||||
hide_early_access_updates: false,
|
hide_early_access_updates: false,
|
||||||
|
hide_paid_updates: false,
|
||||||
auto_organize_exclusions: [],
|
auto_organize_exclusions: [],
|
||||||
metadata_refresh_skip_paths: [],
|
metadata_refresh_skip_paths: [],
|
||||||
skip_previously_downloaded_model_versions: false,
|
skip_previously_downloaded_model_versions: false,
|
||||||
|
|||||||
@@ -646,10 +646,17 @@ export class MasonryScroller {
|
|||||||
const pageType = state.currentPageType;
|
const pageType = state.currentPageType;
|
||||||
|
|
||||||
if (pageType === 'recipes') {
|
if (pageType === 'recipes') {
|
||||||
placeholderText = `
|
if (String(getCurrentPageState().sortBy).startsWith('opened')) {
|
||||||
<p>No recipes found</p>
|
placeholderText = `
|
||||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
<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') {
|
} else if (pageType === 'loras') {
|
||||||
placeholderText = `
|
placeholderText = `
|
||||||
<p>No LoRAs found</p>
|
<p>No LoRAs found</p>
|
||||||
|
|||||||
@@ -699,10 +699,17 @@ export class VirtualScroller {
|
|||||||
const pageType = state.currentPageType;
|
const pageType = state.currentPageType;
|
||||||
|
|
||||||
if (pageType === 'recipes') {
|
if (pageType === 'recipes') {
|
||||||
placeholderText = `
|
if (String(getCurrentPageState().sortBy).startsWith('opened')) {
|
||||||
<p>No recipes found</p>
|
placeholderText = `
|
||||||
<p>Add recipe images to your recipes folder to see them here.</p>
|
<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') {
|
} else if (pageType === 'loras') {
|
||||||
placeholderText = `
|
placeholderText = `
|
||||||
<p>No LoRAs found</p>
|
<p>No LoRAs found</p>
|
||||||
|
|||||||
@@ -1,37 +1,59 @@
|
|||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
|
import { showActionToast } from './uiHelpers.js';
|
||||||
|
import { translate } from './i18nHelpers.js';
|
||||||
|
import { handleUndoDelete } from './undoHelpers.js';
|
||||||
|
import { formatFileSize } from '../components/shared/utils.js';
|
||||||
|
|
||||||
let pendingDeletePath = null;
|
let pendingDeletePath = null;
|
||||||
|
let pendingDeleteName = null;
|
||||||
let pendingExcludePath = null;
|
let pendingExcludePath = null;
|
||||||
|
|
||||||
export function showDeleteModal(filePath) {
|
export function showDeleteModal(filePath) {
|
||||||
pendingDeletePath = filePath;
|
pendingDeletePath = filePath;
|
||||||
|
|
||||||
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
const escapedPath = window.CSS && typeof window.CSS.escape === 'function'
|
||||||
? window.CSS.escape(filePath)
|
? window.CSS.escape(filePath)
|
||||||
: filePath.replace(/["\\]/g, '\\$&');
|
: filePath.replace(/["\\]/g, '\\$&');
|
||||||
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
|
||||||
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
const modelName = card ? card.dataset.name : filePath.split('/').pop();
|
||||||
|
pendingDeleteName = modelName;
|
||||||
const modal = modalManager.getModal('deleteModal').element;
|
const modal = modalManager.getModal('deleteModal').element;
|
||||||
const modelInfo = modal.querySelector('.delete-model-info');
|
const modelInfo = modal.querySelector('.delete-model-info');
|
||||||
|
|
||||||
|
const fileSize = card?.dataset.file_size;
|
||||||
|
const sizeLine = fileSize
|
||||||
|
? `<br>${translate('modals.deleteModel.freesSpace', { size: formatFileSize(parseInt(fileSize, 10)) })}`
|
||||||
|
: '';
|
||||||
|
|
||||||
modelInfo.innerHTML = `
|
modelInfo.innerHTML = `
|
||||||
<strong>Model:</strong> ${modelName}
|
<strong>Model:</strong> ${modelName}
|
||||||
<br>
|
<br>
|
||||||
<strong>File:</strong> ${filePath}
|
<strong>File:</strong> ${filePath}
|
||||||
|
<br>
|
||||||
|
${translate('modals.deleteModel.recoverableWarning')}${sizeLine}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
modalManager.showModal('deleteModal');
|
modalManager.showModal('deleteModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmDelete() {
|
export async function confirmDelete() {
|
||||||
if (!pendingDeletePath) return;
|
if (!pendingDeletePath) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getModelApiClient().deleteModel(pendingDeletePath);
|
const modelName = pendingDeleteName;
|
||||||
|
const result = await getModelApiClient().deleteModel(pendingDeletePath);
|
||||||
|
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
|
|
||||||
|
if (result?.batch_id) {
|
||||||
|
const batchId = result.batch_id;
|
||||||
|
showActionToast('toast.undo.deleted', { name: modelName }, 'success', {
|
||||||
|
actionText: translate('toast.undo.action'),
|
||||||
|
onAction: () => handleUndoDelete(batchId, () => resetAndReload(true)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (window.modelDuplicatesManager) {
|
if (window.modelDuplicatesManager) {
|
||||||
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
|
||||||
}
|
}
|
||||||
@@ -44,6 +66,7 @@ export async function confirmDelete() {
|
|||||||
export function closeDeleteModal() {
|
export function closeDeleteModal() {
|
||||||
modalManager.closeModal('deleteModal');
|
modalManager.closeModal('deleteModal');
|
||||||
pendingDeletePath = null;
|
pendingDeletePath = null;
|
||||||
|
pendingDeleteName = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Functions for the exclude modal
|
// Functions for the exclude modal
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
+169
-29
@@ -133,15 +133,28 @@ export async function copyToClipboard(text, successMessage = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
/**
|
||||||
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
* Build a toast element (internal — not exported).
|
||||||
// to avoid spurious "Translation key not found" warnings from i18next
|
* @param {string} message - Already-resolved message text
|
||||||
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
* @param {string} type - Toast type (info/success/warning/error)
|
||||||
const message = isPlainMessage ? key : translate(key, params, fallback);
|
* @returns {HTMLElement} The toast element (not yet attached to the DOM)
|
||||||
|
*/
|
||||||
|
function createToastElement(message, type) {
|
||||||
const toast = document.createElement('div');
|
const toast = document.createElement('div');
|
||||||
toast.className = `toast toast-${type}`;
|
toast.className = `toast toast-${type}`;
|
||||||
toast.textContent = message;
|
toast.textContent = message;
|
||||||
|
return toast;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach a toast to the shared container, position it, and schedule its
|
||||||
|
* dismissal (internal — not exported).
|
||||||
|
* @param {HTMLElement} toast - The toast element to display
|
||||||
|
* @param {number} durationMs - How long the toast stays visible
|
||||||
|
* @param {Function} [onDismiss] - Optional callback fired once when dismissal begins
|
||||||
|
* @returns {Function} Manual dismiss function (idempotent)
|
||||||
|
*/
|
||||||
|
function appendToast(toast, durationMs, onDismiss = null) {
|
||||||
// Get or create toast container
|
// Get or create toast container
|
||||||
let toastContainer = document.querySelector('.toast-container');
|
let toastContainer = document.querySelector('.toast-container');
|
||||||
if (!toastContainer) {
|
if (!toastContainer) {
|
||||||
@@ -161,35 +174,141 @@ export function showToast(key, params = {}, type = 'info', fallback = null) {
|
|||||||
// Set position based on existing toasts
|
// Set position based on existing toasts
|
||||||
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
toast.style.top = `${topOffset + (toastIndex * (toast.offsetHeight || 60 + spacing))}px`;
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
let dismissed = false;
|
||||||
toast.classList.add('show');
|
const dismiss = () => {
|
||||||
|
if (dismissed) return;
|
||||||
|
dismissed = true;
|
||||||
|
|
||||||
// Set timeout based on type
|
if (typeof onDismiss === 'function') {
|
||||||
let timeout = 2000; // Default (info)
|
onDismiss();
|
||||||
if (type === 'warning' || type === 'error') {
|
|
||||||
timeout = 5000;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTimeout(() => {
|
toast.classList.remove('show');
|
||||||
toast.classList.remove('show');
|
toast.addEventListener('transitionend', () => {
|
||||||
toast.addEventListener('transitionend', () => {
|
toast.remove();
|
||||||
toast.remove();
|
|
||||||
|
|
||||||
// Reposition remaining toasts
|
// Reposition remaining toasts
|
||||||
if (toastContainer) {
|
if (toastContainer) {
|
||||||
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
const remainingToasts = Array.from(toastContainer.querySelectorAll('.toast'));
|
||||||
remainingToasts.forEach((t, index) => {
|
remainingToasts.forEach((t, index) => {
|
||||||
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
t.style.top = `${topOffset + (index * (t.offsetHeight || 60 + spacing))}px`;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remove container if empty
|
// Remove container if empty
|
||||||
if (remainingToasts.length === 0) {
|
if (remainingToasts.length === 0) {
|
||||||
toastContainer.remove();
|
toastContainer.remove();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}, timeout);
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.classList.add('show');
|
||||||
|
setTimeout(dismiss, durationMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return dismiss;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showToast(key, params = {}, type = 'info', fallback = null) {
|
||||||
|
// Plain messages (contain spaces) are not i18n dot-notation keys — use verbatim
|
||||||
|
// to avoid spurious "Translation key not found" warnings from i18next
|
||||||
|
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||||
|
const message = isPlainMessage ? key : translate(key, params, fallback);
|
||||||
|
const toast = createToastElement(message, type);
|
||||||
|
|
||||||
|
// Set timeout based on type
|
||||||
|
let duration = 2000; // Default (info)
|
||||||
|
if (type === 'warning' || type === 'error') {
|
||||||
|
duration = 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
appendToast(toast, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a toast with an action button (e.g. Undo) and an optional countdown.
|
||||||
|
* The message accepts the same key/plain-string contract as showToast, so
|
||||||
|
* callers may pass either an i18n key or an already-translated string.
|
||||||
|
* @param {string} key - i18n key or plain message
|
||||||
|
* @param {Object} [params] - i18n interpolation params
|
||||||
|
* @param {string} [type] - Toast type (info/success/warning/error)
|
||||||
|
* @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=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 = 20000, countdown = true } = options;
|
||||||
|
|
||||||
|
const isPlainMessage = typeof key === 'string' && /\s/.test(key);
|
||||||
|
const message = isPlainMessage ? key : translate(key, params);
|
||||||
|
const toast = createToastElement(message, type);
|
||||||
|
|
||||||
|
let countdownInterval = null;
|
||||||
|
const clearCountdown = () => {
|
||||||
|
if (countdownInterval !== null) {
|
||||||
|
clearInterval(countdownInterval);
|
||||||
|
countdownInterval = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// The interval must be cleared on EVERY dismiss path (timeout, countdown end,
|
||||||
|
// manual button click) — the onDismiss hook covers the appendToast timeout path.
|
||||||
|
const dismiss = appendToast(toast, durationMs, clearCountdown);
|
||||||
|
|
||||||
|
let actionFired = false;
|
||||||
|
if (actionText) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'toast-action-btn';
|
||||||
|
button.textContent = actionText;
|
||||||
|
button.addEventListener('click', (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
// Guard against double-click firing the action twice
|
||||||
|
if (actionFired) return;
|
||||||
|
actionFired = true;
|
||||||
|
|
||||||
|
clearCountdown();
|
||||||
|
if (typeof onAction === 'function') {
|
||||||
|
onAction();
|
||||||
|
}
|
||||||
|
dismiss();
|
||||||
|
});
|
||||||
|
toast.append(button);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (countdown) {
|
||||||
|
const countdownEl = document.createElement('span');
|
||||||
|
countdownEl.className = 'toast-countdown';
|
||||||
|
let remainingSeconds = Math.max(0, Math.ceil(durationMs / 1000));
|
||||||
|
countdownEl.textContent = `(${remainingSeconds}s)`;
|
||||||
|
toast.append(countdownEl);
|
||||||
|
|
||||||
|
countdownInterval = setInterval(() => {
|
||||||
|
remainingSeconds -= 1;
|
||||||
|
countdownEl.textContent = `(${Math.max(remainingSeconds, 0)}s)`;
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
clearCountdown();
|
||||||
|
dismiss();
|
||||||
|
}
|
||||||
|
}, 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() {
|
export function restoreFolderFilter() {
|
||||||
@@ -987,6 +1106,9 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
|
|||||||
if (!isNodeEnabled(node)) {
|
if (!isNodeEnabled(node)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (node.capabilities?.text_widget_connected === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
node.capabilities?.has_text_widget === true ||
|
node.capabilities?.has_text_widget === true ||
|
||||||
node.marker_role === "send_prompt_target"
|
node.marker_role === "send_prompt_target"
|
||||||
@@ -995,7 +1117,15 @@ export async function sendEmbeddingToWorkflow(embeddingCode, onComplete = null)
|
|||||||
|
|
||||||
const nodeKeys = Object.keys(textNodes);
|
const nodeKeys = Object.keys(textNodes);
|
||||||
if (nodeKeys.length === 0) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,6 +1177,11 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
|
|||||||
if (!isNodeEnabled(node)) {
|
if (!isNodeEnabled(node)) {
|
||||||
return false;
|
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 (
|
return (
|
||||||
node.capabilities?.has_text_widget === true ||
|
node.capabilities?.has_text_widget === true ||
|
||||||
node.marker_role === "send_prompt_target"
|
node.marker_role === "send_prompt_target"
|
||||||
@@ -1055,7 +1190,12 @@ export async function sendPromptToWorkflow(promptText, options = {}) {
|
|||||||
|
|
||||||
const nodeKeys = Object.keys(textNodes);
|
const nodeKeys = Object.keys(textNodes);
|
||||||
if (nodeKeys.length === 0) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { showToast } from './uiHelpers.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo a staged delete batch via the pending-delete endpoint.
|
||||||
|
* @param {string} batchId - The batch id returned by a staged delete response
|
||||||
|
* @param {Function|null} refreshFn - Called once after a successful restore (unless options.refresh is false)
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @param {boolean} [options.showToast=true] - Suppress toasts (used by sequential multi-batch undo loops)
|
||||||
|
* @param {boolean} [options.refresh=true] - Suppress the refresh call (used by sequential multi-batch undo loops)
|
||||||
|
* @returns {Promise<boolean>} Whether the undo succeeded
|
||||||
|
*/
|
||||||
|
export async function handleUndoDelete(batchId, refreshFn, options = {}) {
|
||||||
|
const { showToast: showToastEnabled = true, refresh: refreshEnabled = true } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/lm/undo-delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ batch_id: batchId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (refreshEnabled && typeof refreshFn === 'function') {
|
||||||
|
refreshFn();
|
||||||
|
}
|
||||||
|
if (showToastEnabled) {
|
||||||
|
showToast('toast.undo.restored', {}, 'success');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the error body to distinguish an expired batch from other failures
|
||||||
|
let errorMessage = '';
|
||||||
|
try {
|
||||||
|
const body = await response.json();
|
||||||
|
errorMessage = body?.error || '';
|
||||||
|
} catch {
|
||||||
|
errorMessage = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showToastEnabled) {
|
||||||
|
if (response.status === 404 && errorMessage.toLowerCase().includes('expired')) {
|
||||||
|
showToast('toast.undo.expired', {}, 'error');
|
||||||
|
} else {
|
||||||
|
showToast('toast.undo.failed', { error: errorMessage || response.statusText }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
if (showToastEnabled) {
|
||||||
|
showToast('toast.undo.failed', { error: error.message }, 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,17 +48,20 @@
|
|||||||
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
<option value="versions_count:asc">{{ t('loras.controls.sort.versionsCountAsc', default='Fewest versions first') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% 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' %}
|
{% if page_id == 'recipes' %}
|
||||||
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||||
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||||
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||||
</optgroup>
|
</optgroup>
|
||||||
{% endif %}
|
{% 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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div title="{% if page_id == 'recipes' %}{{ t('recipes.controls.refresh.title') }}{% else %}{{ t('loras.controls.refresh.title') }}{% endif %}" class="control-group dropdown-group">
|
<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>
|
||||||
|
|
||||||
<div class="controls-right">
|
<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">
|
<div class="control-group doctor-control-group">
|
||||||
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
<button id="doctorTriggerBtn" class="doctor-trigger" title="{{ t('doctor.buttonTitle', default='Run diagnostics and common fixes') }}">
|
||||||
<i class="fas fa-stethoscope"></i>
|
<i class="fas fa-stethoscope"></i>
|
||||||
|
|||||||
@@ -629,16 +629,22 @@
|
|||||||
<div class="setting-item">
|
<div class="setting-item">
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<div class="setting-info">
|
<div class="setting-info">
|
||||||
<label for="recipesLayout">
|
<label id="recipesLayoutLabel">
|
||||||
{{ t('settings.layoutSettings.recipesLayout') }}
|
{{ t('settings.layoutSettings.recipesLayout') }}
|
||||||
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.layoutSettings.recipesLayoutHelp') }}"></i>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="setting-control select-control">
|
<div class="setting-control layout-options-control">
|
||||||
<select id="recipesLayout" onchange="settingsManager.saveSelectSetting('recipesLayout', 'recipes_layout')">
|
<div id="recipesLayoutOptions" class="layout-options" role="radiogroup" aria-label="{{ t('settings.layoutSettings.recipesLayout') }}" aria-labelledby="recipesLayoutLabel">
|
||||||
<option value="grid">{{ t('settings.layoutSettings.recipesLayoutOptions.grid') }}</option>
|
<button type="button" class="layout-option" data-recipes-layout="grid" onclick="settingsManager.saveRecipesLayout('grid')" role="radio" aria-checked="true">
|
||||||
<option value="masonry">{{ t('settings.layoutSettings.recipesLayoutOptions.masonry') }}</option>
|
<span class="layout-option-preview layout-preview-grid" aria-hidden="true"><span></span><span></span><span></span><span></span></span>
|
||||||
</select>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1263,6 +1269,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Example Images -->
|
<!-- Example Images -->
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
BASE_MODEL_API_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
I18N_MODULE,
|
||||||
|
STORAGE_MODULE,
|
||||||
|
API_CONFIG_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
SIDEBAR_MANAGER_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const showSimpleLoadingMock = vi.fn();
|
||||||
|
const showCancelButtonMock = vi.fn();
|
||||||
|
const hideLoadingMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
loadingManager: {
|
||||||
|
showSimpleLoading: showSimpleLoadingMock,
|
||||||
|
showCancelButton: showCancelButtonMock,
|
||||||
|
hide: hideLoadingMock,
|
||||||
|
},
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_MODULE, () => ({
|
||||||
|
translate: vi.fn((key) => key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STORAGE_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn(),
|
||||||
|
getSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
saveMapToStorage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_CONFIG_MODULE, () => ({
|
||||||
|
getCompleteApiConfig: vi.fn(() => ({
|
||||||
|
endpoints: { bulkDelete: '/api/lm/loras/bulk-delete' },
|
||||||
|
config: { displayName: 'LoRA', singularName: 'LoRA' },
|
||||||
|
})),
|
||||||
|
getCurrentModelType: vi.fn(() => 'loras'),
|
||||||
|
isValidModelType: vi.fn(() => true),
|
||||||
|
DOWNLOAD_ENDPOINTS: {},
|
||||||
|
HF_ENDPOINTS: {},
|
||||||
|
WS_ENDPOINTS: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||||
|
sidebarManager: { refresh: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BaseModelApiClient.bulkDeleteModels undo contract', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||||
|
class TestClient extends BaseModelApiClient {}
|
||||||
|
return new TestClient('loras');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockBulkDeleteResponse(payload) {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('posts the file paths and defaults both batch fields to null', async () => {
|
||||||
|
mockBulkDeleteResponse({
|
||||||
|
success: true,
|
||||||
|
status: 'success',
|
||||||
|
total_deleted: 3,
|
||||||
|
total_attempted: 3,
|
||||||
|
cache_updated: true,
|
||||||
|
results: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/loras/bulk-delete',
|
||||||
|
expect.objectContaining({ method: 'POST' })
|
||||||
|
);
|
||||||
|
expect(result).toEqual({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 3,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: null,
|
||||||
|
});
|
||||||
|
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through the merged batch_id when the backend staged the bulk delete', async () => {
|
||||||
|
mockBulkDeleteResponse({
|
||||||
|
success: true,
|
||||||
|
status: 'success',
|
||||||
|
total_deleted: 2,
|
||||||
|
total_attempted: 2,
|
||||||
|
cache_updated: true,
|
||||||
|
results: [],
|
||||||
|
batch_id: 'merged-batch-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||||
|
|
||||||
|
expect(result.batch_id).toBe('merged-batch-1');
|
||||||
|
expect(result.batch_ids).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through the batch_ids fallback array when the merge failed', async () => {
|
||||||
|
mockBulkDeleteResponse({
|
||||||
|
success: true,
|
||||||
|
status: 'success',
|
||||||
|
total_deleted: 2,
|
||||||
|
total_attempted: 2,
|
||||||
|
cache_updated: true,
|
||||||
|
results: [],
|
||||||
|
batch_ids: ['batch-1', 'batch-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||||
|
|
||||||
|
expect(result.batch_id).toBeNull();
|
||||||
|
expect(result.batch_ids).toEqual(['batch-1', 'batch-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the batch field on the cancelled-status path (staged subset is undoable)', async () => {
|
||||||
|
mockBulkDeleteResponse({
|
||||||
|
success: true,
|
||||||
|
status: 'cancelled',
|
||||||
|
total_deleted: 1,
|
||||||
|
total_attempted: 2,
|
||||||
|
cache_updated: true,
|
||||||
|
results: [],
|
||||||
|
batch_id: 'partial-batch',
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.bulkDeleteModels(['/models/a.safetensors', '/models/b.safetensors']);
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.deleted_count).toBe(1);
|
||||||
|
expect(result.batch_id).toBe('partial-batch');
|
||||||
|
expect(result.batch_ids).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the cancelled marker when the user aborts the fetch', async () => {
|
||||||
|
const abortError = new Error('The user aborted a request.');
|
||||||
|
abortError.name = 'AbortError';
|
||||||
|
global.fetch = vi.fn().mockRejectedValue(abortError);
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.bulkDeleteModels(['/models/a.safetensors']);
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: false, cancelled: true });
|
||||||
|
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the backend error message when the bulk delete fails', async () => {
|
||||||
|
mockBulkDeleteResponse({ success: false, error: 'disk full' });
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
await expect(client.bulkDeleteModels(['/models/a.safetensors'])).rejects.toThrow('disk full');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
BASE_MODEL_API_MODULE,
|
||||||
|
STATE_MODULE,
|
||||||
|
UI_HELPERS_MODULE,
|
||||||
|
I18N_MODULE,
|
||||||
|
STORAGE_MODULE,
|
||||||
|
API_CONFIG_MODULE,
|
||||||
|
API_FACTORY_MODULE,
|
||||||
|
SIDEBAR_MANAGER_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
BASE_MODEL_API_MODULE: new URL('../../../static/js/api/baseModelApi.js', import.meta.url).pathname,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
UI_HELPERS_MODULE: new URL('../../../static/js/utils/uiHelpers.js', import.meta.url).pathname,
|
||||||
|
I18N_MODULE: new URL('../../../static/js/utils/i18nHelpers.js', import.meta.url).pathname,
|
||||||
|
STORAGE_MODULE: new URL('../../../static/js/utils/storageHelpers.js', import.meta.url).pathname,
|
||||||
|
API_CONFIG_MODULE: new URL('../../../static/js/api/apiConfig.js', import.meta.url).pathname,
|
||||||
|
API_FACTORY_MODULE: new URL('../../../static/js/api/modelApiFactory.js', import.meta.url).pathname,
|
||||||
|
SIDEBAR_MANAGER_MODULE: new URL('../../../static/js/components/SidebarManager.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const removeItemByFilePathMock = vi.fn();
|
||||||
|
const showSimpleLoadingMock = vi.fn();
|
||||||
|
const hideLoadingMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
loadingManager: {
|
||||||
|
showSimpleLoading: showSimpleLoadingMock,
|
||||||
|
hide: hideLoadingMock,
|
||||||
|
},
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: removeItemByFilePathMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_MODULE, () => ({
|
||||||
|
translate: vi.fn((key) => key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STORAGE_MODULE, () => ({
|
||||||
|
getStorageItem: vi.fn(),
|
||||||
|
getSessionItem: vi.fn(),
|
||||||
|
removeSessionItem: vi.fn(),
|
||||||
|
saveMapToStorage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_CONFIG_MODULE, () => ({
|
||||||
|
getCompleteApiConfig: vi.fn(() => ({
|
||||||
|
endpoints: { delete: '/api/lm/loras/delete' },
|
||||||
|
config: { displayName: 'LoRA', singularName: 'LoRA' },
|
||||||
|
})),
|
||||||
|
getCurrentModelType: vi.fn(() => 'loras'),
|
||||||
|
isValidModelType: vi.fn(() => true),
|
||||||
|
DOWNLOAD_ENDPOINTS: {},
|
||||||
|
HF_ENDPOINTS: {},
|
||||||
|
WS_ENDPOINTS: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(API_FACTORY_MODULE, () => ({
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(SIDEBAR_MANAGER_MODULE, () => ({
|
||||||
|
sidebarManager: { refresh: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BaseModelApiClient.deleteModel undo contract', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
showToastMock.mockReset();
|
||||||
|
removeItemByFilePathMock.mockReset();
|
||||||
|
showSimpleLoadingMock.mockReset();
|
||||||
|
hideLoadingMock.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createClient() {
|
||||||
|
const { BaseModelApiClient } = await import(BASE_MODEL_API_MODULE);
|
||||||
|
class TestClient extends BaseModelApiClient {}
|
||||||
|
return new TestClient('loras');
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns the batch id and suppresses the legacy success toast when staged', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-42' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.deleteModel('/models/foo.safetensors');
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, batch_id: 'batch-42' });
|
||||||
|
// The card is still removed from the scroller — the file is gone either way
|
||||||
|
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
||||||
|
// No legacy toast: the caller shows the undo action toast instead
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.api.deleteSuccess',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
expect(hideLoadingMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy success toast when the delete was not staged', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, deleted_files: ['/models/foo.safetensors'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.deleteModel('/models/foo.safetensors');
|
||||||
|
|
||||||
|
expect(result).toEqual({ success: true, batch_id: null });
|
||||||
|
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/models/foo.safetensors');
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.api.deleteSuccess', { type: 'LoRA' }, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a truthy result so undo-blind callers keep working (ModelVersionsTab)', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, deleted_files: [], batch_id: 'batch-7' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.deleteModel('/models/v2.safetensors');
|
||||||
|
|
||||||
|
// ModelVersionsTab.js:1136-1144 awaits deleteModel and treats any truthy
|
||||||
|
// result as success — the new object must satisfy that check shape.
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
expect(Boolean(result && result.success)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false and shows the failure toast when the server reports failure', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: false, error: 'disk error' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = await createClient();
|
||||||
|
const result = await client.deleteModel('/models/foo.safetensors');
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
expect(removeItemByFilePathMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.deleteFailed',
|
||||||
|
expect.objectContaining({ type: 'LoRA' }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -142,10 +142,50 @@ describe('RecipeSidebarApiClient bulk operations', () => {
|
|||||||
success: true,
|
success: true,
|
||||||
deleted_count: 2,
|
deleted_count: 2,
|
||||||
failed_count: 0,
|
failed_count: 0,
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: null,
|
||||||
});
|
});
|
||||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes through the merged batch_id from a staged bulk delete', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
total_deleted: 2,
|
||||||
|
total_failed: 0,
|
||||||
|
failed: [],
|
||||||
|
batch_id: 'merged-recipe-batch',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||||
|
|
||||||
|
expect(result.batch_id).toBe('merged-recipe-batch');
|
||||||
|
expect(result.batch_ids).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes through the batch_ids fallback array when the merge failed', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
total_deleted: 2,
|
||||||
|
total_failed: 0,
|
||||||
|
failed: [],
|
||||||
|
batch_ids: ['recipe-batch-1', 'recipe-batch-2'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||||
|
|
||||||
|
expect(result.batch_id).toBeNull();
|
||||||
|
expect(result.batch_ids).toEqual(['recipe-batch-1', 'recipe-batch-2']);
|
||||||
|
});
|
||||||
|
|
||||||
it('encodes recipe IDs when fetching recipe details', async () => {
|
it('encodes recipe IDs when fetching recipe details', async () => {
|
||||||
global.fetch.mockResolvedValue({
|
global.fetch.mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -1667,7 +1667,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(input.value).toBe('looking_to_the_side,');
|
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');
|
const input = document.createElement('textarea');
|
||||||
input.value = '/';
|
input.value = '/';
|
||||||
input.selectionStart = input.value.length;
|
input.selectionStart = input.value.length;
|
||||||
@@ -1682,8 +1682,6 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
const commandNames = autoComplete.items.map((item) => item.command);
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
expect(commandNames).toContain('/af');
|
|
||||||
expect(commandNames).not.toContain('/noaf');
|
|
||||||
expect(commandNames).toContain('/activefilters');
|
expect(commandNames).toContain('/activefilters');
|
||||||
expect(commandNames).not.toContain('/noactivefilters');
|
expect(commandNames).not.toContain('/noactivefilters');
|
||||||
});
|
});
|
||||||
@@ -1710,11 +1708,11 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
const commandNames = autoComplete.items.map((item) => item.command);
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
expect(commandNames).toContain('/af');
|
expect(commandNames).toContain('/activefilters');
|
||||||
expect(previewTooltipMock.show).not.toHaveBeenCalled();
|
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) => {
|
settingGetMock.mockImplementation((key) => {
|
||||||
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
if (key === 'loramanager.lora_active_filters_autocomplete') {
|
||||||
return true;
|
return true;
|
||||||
@@ -1736,8 +1734,6 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
const commandNames = autoComplete.items.map((item) => item.command);
|
const commandNames = autoComplete.items.map((item) => item.command);
|
||||||
expect(commandNames).toContain('/noaf');
|
|
||||||
expect(commandNames).not.toContain('/af');
|
|
||||||
expect(commandNames).toContain('/noactivefilters');
|
expect(commandNames).toContain('/noactivefilters');
|
||||||
expect(commandNames).not.toContain('/activefilters');
|
expect(commandNames).not.toContain('/activefilters');
|
||||||
});
|
});
|
||||||
@@ -1766,7 +1762,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
expect(settingSetMock).toHaveBeenCalledWith('loramanager.lora_active_filters_autocomplete', true);
|
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');
|
const input = document.createElement('textarea');
|
||||||
input.value = '/';
|
input.value = '/';
|
||||||
input.selectionStart = input.value.length;
|
input.selectionStart = input.value.length;
|
||||||
@@ -1782,7 +1778,7 @@ describe('AutoComplete widget interactions', () => {
|
|||||||
|
|
||||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
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();
|
expect(afItem).toBeDefined();
|
||||||
|
|
||||||
// Simulate the input being cleared after the command is accepted so the
|
// Simulate the input being cleared after the command is accepted so the
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
const showToastMock = vi.fn();
|
const showToastMock = vi.fn();
|
||||||
|
const showActionToastMock = vi.fn();
|
||||||
|
const handleUndoDeleteMock = vi.fn();
|
||||||
const recreateVirtualScrollMock = vi.fn();
|
const recreateVirtualScrollMock = vi.fn();
|
||||||
const translateMock = vi.fn((key) => key);
|
const translateMock = vi.fn((key) => key);
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
showToast: showToastMock,
|
showToast: showToastMock,
|
||||||
|
showActionToast: showActionToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
|
||||||
|
handleUndoDelete: handleUndoDeleteMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
@@ -20,6 +27,8 @@ vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||||
recreateVirtualScroll: recreateVirtualScrollMock,
|
recreateVirtualScroll: recreateVirtualScrollMock,
|
||||||
}));
|
}));
|
||||||
@@ -211,3 +220,114 @@ describe('DuplicatesManager prompt matching toggle', () => {
|
|||||||
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
|
expect(document.getElementById('duplicatesBasis').textContent).toBe('recipes.duplicates.basis.loraCombo');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('DuplicatesManager confirmDeleteDuplicates undo flows', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setCurrentPageType('recipes');
|
||||||
|
setupDom();
|
||||||
|
state.pendingLayoutRecreate = false;
|
||||||
|
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
|
||||||
|
handleUndoDeleteMock.mockResolvedValue(true);
|
||||||
|
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||||
|
globalThis.recipeManager = { loadRecipes: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
state.pendingLayoutRecreate = false;
|
||||||
|
state.virtualScroller = null;
|
||||||
|
delete globalThis.modalManager;
|
||||||
|
delete globalThis.recipeManager;
|
||||||
|
delete globalThis.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockBulkDelete(payload) {
|
||||||
|
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActionToastOptions() {
|
||||||
|
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
|
||||||
|
return call[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the undo action toast with the batch id and reloads recipes on undo', async () => {
|
||||||
|
mockBulkDelete({ success: true, total_deleted: 2, batch_id: 'recipe-batch-1' });
|
||||||
|
|
||||||
|
const manager = new DuplicatesManager({});
|
||||||
|
manager.inDuplicateMode = true;
|
||||||
|
manager.selectedForDeletion.add('r1');
|
||||||
|
manager.selectedForDeletion.add('r2');
|
||||||
|
|
||||||
|
await manager.confirmDeleteDuplicates();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.undo.deletedBulk',
|
||||||
|
{ count: 2 },
|
||||||
|
'success',
|
||||||
|
expect.objectContaining({
|
||||||
|
actionText: 'toast.undo.action',
|
||||||
|
onAction: expect.any(Function),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// The legacy duplicates success toast is replaced, not duplicated
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.duplicates.deleteSuccess',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
// exitDuplicateMode still runs for successful deletions
|
||||||
|
expect(manager.inDuplicateMode).toBe(false);
|
||||||
|
|
||||||
|
lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
|
||||||
|
|
||||||
|
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||||
|
refreshFn();
|
||||||
|
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
|
||||||
|
mockBulkDelete({ success: true, total_deleted: 2, batch_ids: ['rb-1', 'rb-2'] });
|
||||||
|
|
||||||
|
const manager = new DuplicatesManager({});
|
||||||
|
manager.inDuplicateMode = true;
|
||||||
|
manager.selectedForDeletion.add('r1');
|
||||||
|
manager.selectedForDeletion.add('r2');
|
||||||
|
|
||||||
|
await manager.confirmDeleteDuplicates();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
await lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['rb-1', null, { showToast: false, refresh: false }]);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['rb-2', null, { showToast: false, refresh: false }]);
|
||||||
|
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledTimes(1);
|
||||||
|
expect(globalThis.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
|
||||||
|
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy success toast when the response carries no batch field', async () => {
|
||||||
|
mockBulkDelete({ success: true, total_deleted: 1 });
|
||||||
|
|
||||||
|
const manager = new DuplicatesManager({});
|
||||||
|
manager.inDuplicateMode = true;
|
||||||
|
manager.selectedForDeletion.add('r1');
|
||||||
|
|
||||||
|
await manager.confirmDeleteDuplicates();
|
||||||
|
|
||||||
|
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.duplicates.deleteSuccess',
|
||||||
|
{ count: 1, type: 'recipes' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
APP_MODULE,
|
||||||
|
API_MODULE,
|
||||||
|
UTILS_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
|
||||||
|
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
|
||||||
|
UTILS_MODULE: new URL('../../../web/comfyui/loras_widget_utils.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(APP_MODULE, () => ({
|
||||||
|
app: { graph: {} },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { fetchApiMock } = vi.hoisted(() => ({ fetchApiMock: vi.fn() }));
|
||||||
|
vi.mock(API_MODULE, () => ({
|
||||||
|
api: { fetchApi: fetchApiMock },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeLoraNameKey,
|
||||||
|
buildAvailableLoraSet,
|
||||||
|
isLoraNameAvailable,
|
||||||
|
getAvailableLoras,
|
||||||
|
getAvailableLorasSync,
|
||||||
|
resetAvailableLorasCache,
|
||||||
|
onLibraryChanged,
|
||||||
|
handleLibraryChangeMessage,
|
||||||
|
} from '../../../web/comfyui/loras_widget_utils.js';
|
||||||
|
|
||||||
|
describe('normalizeLoraNameKey', () => {
|
||||||
|
it('normalizes backslashes to forward slashes', () => {
|
||||||
|
expect(normalizeLoraNameKey('sub\\folder\\lora.safetensors')).toBe(
|
||||||
|
'sub/folder/lora'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips known model extensions case-insensitively', () => {
|
||||||
|
expect(normalizeLoraNameKey('lora.safetensors')).toBe('lora');
|
||||||
|
expect(normalizeLoraNameKey('lora.CKPT')).toBe('lora');
|
||||||
|
expect(normalizeLoraNameKey('lora.pt')).toBe('lora');
|
||||||
|
expect(normalizeLoraNameKey('lora.bin')).toBe('lora');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps extensions the backend does not strip', () => {
|
||||||
|
// Matches backend _strip_lora_extension: .gguf is not a LoRA extension.
|
||||||
|
expect(normalizeLoraNameKey('lora.gguf')).toBe('lora.gguf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves extension-less names unchanged', () => {
|
||||||
|
expect(normalizeLoraNameKey('lora')).toBe('lora');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildAvailableLoraSet', () => {
|
||||||
|
it('registers both path and basename forms without extension', () => {
|
||||||
|
const set = buildAvailableLoraSet(['sub/a.safetensors', 'b.ckpt']);
|
||||||
|
expect(set.has('sub/a')).toBe(true);
|
||||||
|
expect(set.has('a')).toBe(true);
|
||||||
|
expect(set.has('b')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty entries', () => {
|
||||||
|
const set = buildAvailableLoraSet([null, '', 'sub/c.safetensors']);
|
||||||
|
expect(set.has('sub/c')).toBe(true);
|
||||||
|
expect(set.has('')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isLoraNameAvailable', () => {
|
||||||
|
const set = buildAvailableLoraSet(['sub/a.safetensors', 'b.ckpt']);
|
||||||
|
|
||||||
|
it('treats everything as available while the set is not loaded', () => {
|
||||||
|
expect(isLoraNameAvailable('anything.safetensors', null)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches by basename with or without extension', () => {
|
||||||
|
expect(isLoraNameAvailable('a', set)).toBe(true);
|
||||||
|
expect(isLoraNameAvailable('a.safetensors', set)).toBe(true);
|
||||||
|
expect(isLoraNameAvailable('b.ckpt', set)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches by full folder path', () => {
|
||||||
|
expect(isLoraNameAvailable('sub/a.safetensors', set)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports names not in the library', () => {
|
||||||
|
expect(isLoraNameAvailable('missing.safetensors', set)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the basename for folder-qualified names', () => {
|
||||||
|
// Mirror of the backend basename fallback: a folder prefix that does not
|
||||||
|
// match a stored path still resolves when the basename exists.
|
||||||
|
expect(isLoraNameAvailable('sub/b.ckpt', set)).toBe(true);
|
||||||
|
expect(isLoraNameAvailable('any/folder/a.safetensors', set)).toBe(true);
|
||||||
|
expect(isLoraNameAvailable('sub/missing.safetensors', set)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats absolute paths as available without verification', () => {
|
||||||
|
expect(isLoraNameAvailable('/abs/path/x.safetensors', set)).toBe(true);
|
||||||
|
expect(isLoraNameAvailable('C:/abs/path/x.safetensors', set)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAvailableLoras caching', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchApiMock.mockReset();
|
||||||
|
resetAvailableLorasCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the cycler list and builds the availability set', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
loras: [{ file_name: 'sub/a.safetensors' }, { file_name: 'b.ckpt' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const set = await getAvailableLoras();
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledWith('/lm/loras/cycler-list', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
});
|
||||||
|
expect(set.has('sub/a')).toBe(true);
|
||||||
|
expect(set.has('b')).toBe(true);
|
||||||
|
expect(getAvailableLorasSync().has('sub/a')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares a single in-flight request between concurrent callers', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, loras: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([getAvailableLoras(), getAvailableLoras()]);
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not refetch while the cache is fresh', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, loras: [] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await getAvailableLoras();
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(fetchApiMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves to null and does not cache on fetch failure', async () => {
|
||||||
|
fetchApiMock.mockRejectedValue(new Error('network down'));
|
||||||
|
|
||||||
|
const result = await getAvailableLoras();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
expect(getAvailableLorasSync()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves to null on non-ok response', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const result = await getAvailableLoras();
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('library change invalidation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchApiMock.mockReset();
|
||||||
|
resetAvailableLorasCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalidates the availability cache and notifies listeners on models_changed', async () => {
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
loras: [{ file_name: 'old.safetensors' }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = onLibraryChanged(listener);
|
||||||
|
try {
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(getAvailableLorasSync().has('old')).toBe(true);
|
||||||
|
|
||||||
|
// Simulate a deletion in the Lora Manager UI: the cache is dropped and
|
||||||
|
// listeners are notified so widgets re-render with fresh data.
|
||||||
|
handleLibraryChangeMessage({ type: 'models_changed' });
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(getAvailableLorasSync()).toBeNull();
|
||||||
|
|
||||||
|
fetchApiMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, loras: [] }),
|
||||||
|
});
|
||||||
|
await getAvailableLoras();
|
||||||
|
expect(getAvailableLorasSync().has('old')).toBe(false);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unrelated messages', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = onLibraryChanged(listener);
|
||||||
|
try {
|
||||||
|
handleLibraryChangeMessage({ type: 'download_progress' });
|
||||||
|
handleLibraryChangeMessage({ type: 'init_progress' });
|
||||||
|
handleLibraryChangeMessage(null);
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unsubscribes listeners', () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = onLibraryChanged(listener);
|
||||||
|
unsubscribe();
|
||||||
|
handleLibraryChangeMessage({ type: 'models_changed' });
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
const showToastMock = vi.fn();
|
const showToastMock = vi.fn();
|
||||||
|
const showActionToastMock = vi.fn();
|
||||||
|
const handleUndoDeleteMock = vi.fn();
|
||||||
const resetAndReloadMock = vi.fn();
|
const resetAndReloadMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
showToast: showToastMock,
|
showToast: showToastMock,
|
||||||
|
showActionToast: showActionToastMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/undoHelpers.js', () => ({
|
||||||
|
handleUndoDelete: handleUndoDeleteMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
resetAndReload: resetAndReloadMock,
|
resetAndReload: resetAndReloadMock,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/modalUtils.js', () => ({}));
|
||||||
|
|
||||||
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
|
const { ModelDuplicatesManager } = await import('../../../static/js/components/ModelDuplicatesManager.js');
|
||||||
const { state } = await import('../../../static/js/state/index.js');
|
const { state } = await import('../../../static/js/state/index.js');
|
||||||
|
|
||||||
@@ -230,3 +239,120 @@ describe('ModelDuplicatesManager verification state', () => {
|
|||||||
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
|
expect(manager.verifiedGroups.has('visible-hash')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('ModelDuplicatesManager confirmDeleteDuplicates undo flows', () => {
|
||||||
|
function mockDeleteAndRecheck(deletePayload) {
|
||||||
|
global.fetch = vi.fn((url) => {
|
||||||
|
if (String(url).includes('bulk-delete')) {
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => deletePayload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
statusText: 'OK',
|
||||||
|
json: async () => ({ success: true, duplicates: [] }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActionToastOptions() {
|
||||||
|
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
|
||||||
|
return call[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
handleUndoDeleteMock.mockResolvedValue(true);
|
||||||
|
state.virtualScroller = { enable: vi.fn(), disable: vi.fn() };
|
||||||
|
globalThis.modalManager = { showModal: vi.fn(), closeModal: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
state.virtualScroller = null;
|
||||||
|
delete globalThis.modalManager;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the undo action toast with the batch id and refreshes models on undo', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
mockDeleteAndRecheck({ success: true, total_deleted: 1, batch_id: 'model-batch-1' });
|
||||||
|
|
||||||
|
manager.inDuplicateMode = true;
|
||||||
|
manager.selectedForDeletion.add(carPath);
|
||||||
|
|
||||||
|
await manager.confirmDeleteDuplicates();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.undo.deletedBulk',
|
||||||
|
{ count: 1 },
|
||||||
|
'success',
|
||||||
|
expect.objectContaining({
|
||||||
|
actionText: 'toast.undo.action',
|
||||||
|
onAction: expect.any(Function),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.duplicates.deleteSuccess',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The existing reset + find-duplicates re-check path still runs
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||||
|
// No remaining duplicates -> duplicate mode exited
|
||||||
|
expect(manager.inDuplicateMode).toBe(false);
|
||||||
|
|
||||||
|
lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledWith('model-batch-1', expect.any(Function));
|
||||||
|
|
||||||
|
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||||
|
resetAndReloadMock.mockClear();
|
||||||
|
refreshFn();
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes the batch_ids fallback sequentially with one final refresh and restored toast', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
mockDeleteAndRecheck({ success: true, total_deleted: 2, batch_ids: ['mb-1', 'mb-2'] });
|
||||||
|
|
||||||
|
manager.inDuplicateMode = true;
|
||||||
|
manager.selectedForDeletion.add(carPath);
|
||||||
|
manager.selectedForDeletion.add(copyPath);
|
||||||
|
|
||||||
|
await manager.confirmDeleteDuplicates();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
resetAndReloadMock.mockClear();
|
||||||
|
await lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['mb-1', null, { showToast: false, refresh: false }]);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['mb-2', null, { showToast: false, refresh: false }]);
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||||
|
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy success toast when the response carries no batch field', async () => {
|
||||||
|
const manager = await createManager();
|
||||||
|
mockDeleteAndRecheck({ success: true, total_deleted: 1 });
|
||||||
|
|
||||||
|
manager.inDuplicateMode = true;
|
||||||
|
manager.selectedForDeletion.add(carPath);
|
||||||
|
|
||||||
|
await manager.confirmDeleteDuplicates();
|
||||||
|
|
||||||
|
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.duplicates.deleteSuccess',
|
||||||
|
{ count: 1, type: 'loras' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
import { applySortToSelect } from '../../../static/js/components/controls/SortDropdown.js';
|
||||||
|
|
||||||
const resetAndReloadMock = vi.fn();
|
const resetAndReloadMock = vi.fn();
|
||||||
const getModelApiClientMock = vi.fn();
|
const getModelApiClientMock = vi.fn();
|
||||||
@@ -190,7 +191,7 @@ describe('Random sort option', () => {
|
|||||||
sortSelect.value = 'random';
|
sortSelect.value = 'random';
|
||||||
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
controls.applySortToSelect('name:desc');
|
applySortToSelect('name:desc');
|
||||||
|
|
||||||
expect(sortSelect.value).toBe('name:desc');
|
expect(sortSelect.value).toBe('name:desc');
|
||||||
expect(randomOpt.value).toBe('random');
|
expect(randomOpt.value).toBe('random');
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
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,
|
||||||
|
STATE_MODULE,
|
||||||
|
BULK_MANAGER_MODULE,
|
||||||
|
CONSTANTS_MODULE,
|
||||||
|
I18N_MODULE,
|
||||||
|
UNDO_HELPERS_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,
|
||||||
|
STATE_MODULE: new URL('../../../static/js/state/index.js', import.meta.url).pathname,
|
||||||
|
BULK_MANAGER_MODULE: new URL('../../../static/js/managers/BulkManager.js', import.meta.url).pathname,
|
||||||
|
CONSTANTS_MODULE: new URL('../../../static/js/utils/constants.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,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const showActionToastMock = vi.fn();
|
||||||
|
const handleUndoDeleteMock = vi.fn();
|
||||||
|
const translateMock = vi.fn((key) => key);
|
||||||
|
const closeModalMock = vi.fn();
|
||||||
|
const removeItemByFilePathMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock(UI_HELPERS_MODULE, () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
showActionToast: showActionToastMock,
|
||||||
|
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: vi.fn(),
|
||||||
|
closeModal: closeModalMock,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(STATE_MODULE, () => ({
|
||||||
|
state: {
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: removeItemByFilePathMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(() => ({})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(BULK_MANAGER_MODULE, () => ({
|
||||||
|
bulkManager: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(CONSTANTS_MODULE, () => ({
|
||||||
|
NSFW_LEVELS: {},
|
||||||
|
getBaseModelAbbreviation: vi.fn(),
|
||||||
|
getMatureBlurThreshold: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(I18N_MODULE, () => ({
|
||||||
|
translate: translateMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||||
|
handleUndoDelete: handleUndoDeleteMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function setupDeleteModal() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<div id="deleteModal" data-recipe-id="recipe-1" data-file-path="/recipes/r1.json">
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
|
// jsdom maps data-file-path to dataset.filePath
|
||||||
|
deleteModal.dataset.recipeId = 'recipe-1';
|
||||||
|
deleteModal.dataset.filePath = '/recipes/r1.json';
|
||||||
|
return deleteModal;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushPromises() {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('RecipeCard confirmDeleteRecipe undo flow', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
showToastMock.mockReset();
|
||||||
|
showActionToastMock.mockReset();
|
||||||
|
handleUndoDeleteMock.mockReset();
|
||||||
|
translateMock.mockClear();
|
||||||
|
closeModalMock.mockReset();
|
||||||
|
removeItemByFilePathMock.mockReset();
|
||||||
|
setupDeleteModal();
|
||||||
|
window.recipeManager = { loadRecipes: vi.fn() };
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
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' };
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows the undo action toast and wires undo to handleUndoDelete + loadRecipes(true)', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, message: 'deleted', batch_id: 'recipe-batch-1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const card = await createCard();
|
||||||
|
card.confirmDeleteRecipe();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith('/api/lm/recipe/recipe-1', expect.objectContaining({
|
||||||
|
method: 'DELETE',
|
||||||
|
}));
|
||||||
|
// No legacy success toast when the delete was staged
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const [key, params, type, options] = showActionToastMock.mock.calls[0];
|
||||||
|
expect(key).toBe('toast.undo.deleted');
|
||||||
|
expect(params).toEqual({ name: 'My Recipe' });
|
||||||
|
expect(type).toBe('success');
|
||||||
|
expect(options.actionText).toBe('toast.undo.action');
|
||||||
|
|
||||||
|
options.onAction();
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
const [batchId, refreshFn] = handleUndoDeleteMock.mock.calls[0];
|
||||||
|
expect(batchId).toBe('recipe-batch-1');
|
||||||
|
|
||||||
|
refreshFn();
|
||||||
|
expect(window.recipeManager.loadRecipes).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
|
expect(removeItemByFilePathMock).toHaveBeenCalledWith('/recipes/r1.json');
|
||||||
|
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy success toast when the delete was not staged', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ success: true, message: 'deleted' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const card = await createCard();
|
||||||
|
card.confirmDeleteRecipe();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.recipes.deletedSuccessfully', {}, 'success');
|
||||||
|
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(closeModalMock).toHaveBeenCalledWith('deleteModal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the failure toast when the server rejects the delete', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
|
const card = await createCard();
|
||||||
|
const deleteBtn = document.querySelector('.delete-btn');
|
||||||
|
card.confirmDeleteRecipe();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.recipes.deleteFailed',
|
||||||
|
expect.objectContaining({ message: expect.any(String) }),
|
||||||
|
'error'
|
||||||
|
);
|
||||||
|
expect(deleteBtn.disabled).toBe(false);
|
||||||
|
expect(deleteBtn.textContent).toBe('Delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
UNDO_HELPERS_MODULE,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
UNDO_HELPERS_MODULE: new URL('../../../static/js/utils/undoHelpers.js', import.meta.url).pathname,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const showToastMock = vi.fn();
|
||||||
|
const showActionToastMock = vi.fn();
|
||||||
|
const handleUndoDeleteMock = vi.fn();
|
||||||
|
const resetAndReloadMock = vi.fn();
|
||||||
|
const bulkDeleteModelsMock = vi.fn();
|
||||||
|
const recipeBulkDeleteModelsMock = vi.fn();
|
||||||
|
|
||||||
|
const loadingManagerStub = {
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
restoreProgressBar: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const stateStub = {
|
||||||
|
currentPageType: 'loras',
|
||||||
|
bulkMode: false,
|
||||||
|
selectedModels: new Set(),
|
||||||
|
loadingManager: loadingManagerStub,
|
||||||
|
virtualScroller: { removeItemByFilePath: vi.fn() },
|
||||||
|
global: { settings: {} },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
|
state: stateStub,
|
||||||
|
getCurrentPageState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: showToastMock,
|
||||||
|
showActionToast: showActionToastMock,
|
||||||
|
copyToClipboard: vi.fn(),
|
||||||
|
sendLoraToWorkflow: vi.fn(),
|
||||||
|
sendEmbeddingToWorkflow: vi.fn(),
|
||||||
|
buildLoraSyntax: vi.fn(),
|
||||||
|
getNSFWLevelName: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock(UNDO_HELPERS_MODULE, () => ({
|
||||||
|
handleUndoDelete: handleUndoDeleteMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
getModelApiClient: vi.fn(() => ({ bulkDeleteModels: bulkDeleteModelsMock })),
|
||||||
|
resetAndReload: resetAndReloadMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
|
RecipeSidebarApiClient: class {
|
||||||
|
constructor() {
|
||||||
|
this.bulkDeleteModels = recipeBulkDeleteModelsMock;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateRecipeMetadata: vi.fn(),
|
||||||
|
extractRecipeId: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
MODEL_TYPES: { LORA: 'loras', CHECKPOINT: 'checkpoints', EMBEDDING: 'embeddings' },
|
||||||
|
MODEL_CONFIG: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: { showModal: vi.fn(), closeModal: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||||
|
updateCardsForBulkMode: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: vi.fn((key) => key),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/priorityTagHelpers.js', () => ({
|
||||||
|
getPriorityTagSuggestions: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/NsfwLevelSelector.js', () => ({
|
||||||
|
getNsfwLevelSelector: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('BulkManager.confirmBulkDelete undo flows', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
stateStub.currentPageType = 'loras';
|
||||||
|
stateStub.bulkMode = false;
|
||||||
|
stateStub.selectedModels.clear();
|
||||||
|
stateStub.selectedModels.add('/models/a.safetensors');
|
||||||
|
stateStub.selectedModels.add('/models/b.safetensors');
|
||||||
|
handleUndoDeleteMock.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete window.recipeManager;
|
||||||
|
delete window.modelDuplicatesManager;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createBulkManager() {
|
||||||
|
const { BulkManager } = await import('../../../static/js/managers/BulkManager.js');
|
||||||
|
return new BulkManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastActionToastOptions() {
|
||||||
|
const call = showActionToastMock.mock.calls[showActionToastMock.mock.calls.length - 1];
|
||||||
|
return call[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
it('shows one action toast for the merged batch id and undoes it with a model refresh', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: 'merged-1',
|
||||||
|
batch_ids: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.undo.deletedBulk',
|
||||||
|
{ count: 2 },
|
||||||
|
'success',
|
||||||
|
expect.objectContaining({
|
||||||
|
actionText: 'toast.undo.action',
|
||||||
|
onAction: expect.any(Function),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
// The legacy success and cancelled toasts must NOT fire
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.models.deletedSuccessfully',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.api.operationCancelled',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
|
||||||
|
lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledWith('merged-1', expect.any(Function));
|
||||||
|
|
||||||
|
// The undo refresh targets the model library
|
||||||
|
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||||
|
refreshFn();
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the legacy success toast when both batch fields are null', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.models.deletedSuccessfully',
|
||||||
|
{ count: 2, type: 'model' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('undoes the batch_ids fallback sequentially with exactly one final refresh and restored toast', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: ['id-1', 'id-2'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.undo.deletedBulk',
|
||||||
|
{ count: 2 },
|
||||||
|
'success',
|
||||||
|
expect.objectContaining({ onAction: expect.any(Function) })
|
||||||
|
);
|
||||||
|
|
||||||
|
await lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
// Sequential suppressed undos in order
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[0]).toEqual(['id-1', null, { showToast: false, refresh: false }]);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[1]).toEqual(['id-2', null, { showToast: false, refresh: false }]);
|
||||||
|
|
||||||
|
// Exactly ONE final refresh and ONE restored toast
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resetAndReloadMock).toHaveBeenCalledWith(true);
|
||||||
|
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the fallback loop on the first failure and skips the final refresh', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: ['id-1', 'id-2', 'id-3'],
|
||||||
|
});
|
||||||
|
handleUndoDeleteMock
|
||||||
|
.mockResolvedValueOnce(true)
|
||||||
|
.mockResolvedValueOnce(false);
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
await lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
// The loop stops at the failing second id — the third is never attempted
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(handleUndoDeleteMock.mock.calls[1][0]).toBe('id-2');
|
||||||
|
|
||||||
|
// The suppressed undo shows no error toast itself — the loop re-shows it
|
||||||
|
expect(showToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.undo.failed', { error: '' }, 'error');
|
||||||
|
|
||||||
|
// No final refresh, no restored toast
|
||||||
|
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith('toast.undo.restored', {}, 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the action toast for a cancelled bulk that staged a subset (batch_id)', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 1,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: 'partial-1',
|
||||||
|
batch_ids: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.undo.deletedBulk',
|
||||||
|
{ count: 1 },
|
||||||
|
'success',
|
||||||
|
expect.objectContaining({ onAction: expect.any(Function) })
|
||||||
|
);
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.api.operationCancelled',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the action toast for a cancelled bulk with the batch_ids fallback', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 1,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: null,
|
||||||
|
batch_ids: ['partial-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(showToastMock).not.toHaveBeenCalledWith(
|
||||||
|
'toast.api.operationCancelled',
|
||||||
|
expect.anything(),
|
||||||
|
expect.anything()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the cancelled toast when the user aborted and nothing was staged', async () => {
|
||||||
|
bulkDeleteModelsMock.mockResolvedValue({ success: false, cancelled: true });
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith('toast.api.operationCancelled', {}, 'info');
|
||||||
|
expect(showActionToastMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes recipes through window.recipeManager when undoing a recipe bulk delete', async () => {
|
||||||
|
stateStub.currentPageType = 'recipes';
|
||||||
|
stateStub.selectedModels.clear();
|
||||||
|
stateStub.selectedModels.add('/recipes/a.webp');
|
||||||
|
const loadRecipesMock = vi.fn();
|
||||||
|
window.recipeManager = { loadRecipes: loadRecipesMock };
|
||||||
|
|
||||||
|
recipeBulkDeleteModelsMock.mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 1,
|
||||||
|
failed_count: 0,
|
||||||
|
errors: [],
|
||||||
|
batch_id: 'recipe-batch-1',
|
||||||
|
batch_ids: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulk = await createBulkManager();
|
||||||
|
await bulk.confirmBulkDelete();
|
||||||
|
|
||||||
|
expect(showActionToastMock).toHaveBeenCalledTimes(1);
|
||||||
|
lastActionToastOptions().onAction();
|
||||||
|
|
||||||
|
expect(handleUndoDeleteMock).toHaveBeenCalledWith('recipe-batch-1', expect.any(Function));
|
||||||
|
const refreshFn = handleUndoDeleteMock.mock.calls[0][1];
|
||||||
|
refreshFn();
|
||||||
|
expect(loadRecipesMock).toHaveBeenCalledWith(true);
|
||||||
|
expect(resetAndReloadMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const MODAL_MANAGER_MODULE = new URL('../../../static/js/managers/ModalManager.js', import.meta.url).pathname;
|
||||||
|
|
||||||
|
function setupDom() {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<button id="triggerBtn">Trigger</button>
|
||||||
|
<div id="deleteModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="delete-btn">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="excludeModal" class="modal delete-modal">
|
||||||
|
<div class="modal-content delete-modal-content">
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
<button class="exclude-btn">Exclude</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="plainModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<button class="cancel-btn">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ModalManager delete-modal focus handling', () => {
|
||||||
|
let ModalManager;
|
||||||
|
let manager;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
setupDom();
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
|
({ ModalManager } = await import(MODAL_MANAGER_MODULE));
|
||||||
|
manager = new ModalManager();
|
||||||
|
for (const id of ['deleteModal', 'excludeModal', 'plainModal']) {
|
||||||
|
manager.registerModal(id, {
|
||||||
|
element: document.getElementById(id),
|
||||||
|
onClose: () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
it('focuses the cancel button when a delete-type modal opens', () => {
|
||||||
|
const trigger = document.getElementById('triggerBtn');
|
||||||
|
trigger.focus();
|
||||||
|
|
||||||
|
manager.showModal('deleteModal');
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
document.querySelector('#deleteModal .cancel-btn')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores focus to the previously focused element on close', () => {
|
||||||
|
const trigger = document.getElementById('triggerBtn');
|
||||||
|
trigger.focus();
|
||||||
|
|
||||||
|
manager.showModal('deleteModal');
|
||||||
|
manager.closeModal('deleteModal');
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch focus for a non-delete modal', () => {
|
||||||
|
const trigger = document.getElementById('triggerBtn');
|
||||||
|
trigger.focus();
|
||||||
|
|
||||||
|
manager.showModal('plainModal');
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
|
||||||
|
manager.closeModal('plainModal');
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat delete-modal-styled modals without a delete button as delete modals', () => {
|
||||||
|
const trigger = document.getElementById('triggerBtn');
|
||||||
|
trigger.focus();
|
||||||
|
|
||||||
|
manager.showModal('excludeModal');
|
||||||
|
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
|
||||||
|
manager.closeModal('excludeModal');
|
||||||
|
expect(document.activeElement).toBe(trigger);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips the focus restore when the previously focused element is gone', () => {
|
||||||
|
const trigger = document.getElementById('triggerBtn');
|
||||||
|
trigger.focus();
|
||||||
|
|
||||||
|
manager.showModal('deleteModal');
|
||||||
|
trigger.remove();
|
||||||
|
|
||||||
|
expect(() => manager.closeModal('deleteModal')).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -530,4 +530,49 @@ describe('SettingsManager recipes layout switch', () => {
|
|||||||
dispatchSpy.mockRestore();
|
dispatchSpy.mockRestore();
|
||||||
delete state.virtualScroller;
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user